ck, the command-line client for CacheKit.
There is no install script. Install with Cargo, or download a release binary and verify where it came from.
cargo install --locked cachekit-cli
This builds ck from the crate on crates.io and puts it in ~/.cargo/bin.
Each crates.io release is published by this repository's release workflow.
Each release carries
static Linux binaries for x86_64 and aarch64, and a macOS binary for Apple
silicon. On other platforms, install with Cargo. Download the archive for your
platform, then verify it with the GitHub CLI before
you unpack it:
gh attestation verify ck-v0.1.0-x86_64-unknown-linux-musl.tar.gz --repo cachekit-io/cachekit-cli \
--signer-workflow cachekit-io/cachekit-cli/.github/workflows/release.yml \
--source-ref refs/heads/main --deny-self-hosted-runners
The check passes only if the file was built by this repository's release
workflow, run from main, on a GitHub-hosted runner. --owner cachekit-io
alone is not enough:
it accepts a file built by any workflow in any cachekit-io repository. The
ck binary inside the archive is attested too, so the same command verifies it
after unpacking.
Build with cargo build --release --locked and put target/release/ck on
your PATH.
ck run wraps a command and caches its stdout, so a slow or rate-limited call
is not repeated. A typical use is a secret read in a shell startup file, where
every new shell would otherwise call the secret manager again:
API_TOKEN="$(ck run --ttl 12h --stale 7d -- op item get api --fields token --reveal)"
export API_TOKENThe first call runs op and stores its output. Calls in the next 12 hours
print the stored output without running op. After that, ck runs op again;
if op fails, ck serves the stored output for up to 7 more days. That is
also the revocation bound: a token rotated at the source can still be served
for up to 12 hours plus 7 days, so set --stale to how long you can tolerate
serving a revoked secret (see How long a cached secret lives).
Assigning before exporting keeps ck's exit code visible to the shell.
ck run [--backend file|saas] [--ttl D] [--stale D] [--scope S] [--refresh] -- <command> [args...]
| Flag | Default | Meaning |
|---|---|---|
--ttl D |
60s |
Serve the stored output as fresh for D. At least 1s. |
--stale D |
0s |
After that, serve it for D more when the command fails. |
--scope S |
empty | A string folded into the cache key (see below). |
--refresh |
off | Run the command now and store the result, ignoring what is stored. |
--backend B |
file |
file keeps entries in ~/.cache/ck. saas keeps them in CacheKit, shared across hosts (see Sharing a cache across hosts). |
A duration D is a whole number followed by one unit: s, m, h or d,
such as 90s, 15m, 12h or 7d. --ttl plus --stale may not exceed
30d. ck's own errors, such as a bad flag, exit 125 before anything runs.
ck stores a run only when the command exits 0. A run that fails is never
stored and never served. Commands that use a non-zero code for a normal
result, like grep (1 for no match) or diff (1 for a difference), are
therefore not cacheable.
When a run fails, ck backs off: it records the failure and does not run the
command again for 30 seconds, doubling after each consecutive failure up to
15 minutes. During the backoff a call serves the stored output if --stale
allows it (exit 0, with one line on stderr giving its age), and otherwise
prints nothing and exits with the failed run's code. --refresh runs the
command whatever the backoff says:
ck run --refresh -- date -uConcurrent calls for the same command on one machine run it once: the first
takes a lock and the rest wait for its result, or serve stale output if they
have it. Output larger than 20 MiB is passed through and not cached; with
--backend saas the limit is 1 MiB, so that every host can read an entry back
within its 1-second request deadline. Either way ck prints one warning when
output passes the limit. With --backend saas the backoff is shared by every
host, but the lock is not, so hosts that miss at the same moment each run the
command.
ck runs the command with an empty stdin (/dev/null), whatever ck's own stdin
is, and never reads its own. The cache key does not cover stdin, so this is
what lets a call cache the same way from a terminal, a pipe, CI or an agent's
shell. Pass input in arguments, files or --scope instead. Programs that
prompt by opening the terminal themselves, as op, ssh and sudo do, still
reach you.
A command that reads its input from stdin, such as jq, envsubst or
python3 -, therefore reads nothing. If it still exits 0, ck caches that
output like any other, with no warning: curl -s "$URL" | ck run -- jq -r .token
caches an empty token. To cache a pipeline, wrap the command that produces the
input instead: ck run --ttl 5m -- curl -s "$URL" | jq -r .token caches curl's
output and runs jq on it every time.
The key is the command and its arguments, plus --scope. It does not cover
the environment, the working directory, the contents of files the command
reads, or the network. Wrapping a command in ck is your assertion that none of
those change the output. When one does, put it in --scope:
ck run --ttl 1h --scope "$PWD" -- lsRun the command directly rather than through sh -c:
ck run --ttl 5m -- uname -aWhen ck cannot start the command at all (it is not installed, or not
executable), it exits 127 or 126 and records no failure, so the next call
tries again at once. Through sh -c, a missing command is just the shell
exiting 127, which ck must treat as an ordinary failure and back off.
Stored output can be served for up to --ttl plus --stale after it was
produced. ck gets no signal when a secret is rotated at its source, so if you
cache a secret, set --stale to how long you can tolerate serving a revoked
one. The default --stale 0s keeps the bound at the TTL.
To stop serving old output:
- lower
--staleor--ttl, which takes effect on the next call; - delete
~/.cache/ckto remove every entry; - delete
~/.config/ck/file.key, or changeCACHEKIT_MASTER_KEYif you set one, to make every stored entry unreadable. This is the purge for--backend saas: once a host has the new key, it can no longer read any entry stored under the old one. A host still on the old key keeps reading them, and CacheKit keeps the old entries until they expire, at most--ttlplus--staleafter they were written.
Entries are encrypted with AES-256-GCM, and their file names are keyed hashes,
so neither the output nor the command line appears on disk. The key comes from
CACHEKIT_MASTER_KEY (exactly 64 hex characters, from openssl rand -hex 32) if
it is set. Otherwise ck
creates a random key in ~/.config/ck/file.key on first use, mode 0600. If
that file is damaged, ck exits 125 and tells you to delete it; it never
overwrites it. A stored entry that fails to decrypt, because it was corrupted
or tampered with, is never served: ck treats it as a miss and prints one
warning.
ck removes every CACHEKIT_* variable from the command's environment, so a
log or environment dump from the command does not carry your keys.
--backend saas keeps entries in CacheKit, so a laptop
and a CI runner that run the same command share one result. ck uses it only
when you pass --backend saas; setting CACHEKIT_API_KEY alone never selects
it. It needs two variables:
CACHEKIT_API_KEY: an SDK API key (ck_sdk_...) or ack_live_key from the CacheKit dashboard. ck stores its entries in theck-runnamespace, which ack_api_key cannot write, so ck refuses one with exit 125. A key limited to named namespaces must be grantedck-run.CACHEKIT_MASTER_KEY: 64 hex characters fromopenssl rand -hex 32. It encrypts every entry before it leaves the machine and keys every entry's name, so CacheKit stores only ciphertext under names it cannot reverse. Without it, ck exits 125 before running anything. It never falls back to the file key.
Give every host that shares the cache the same two values:
ck run --backend saas --ttl 12h --stale 1d -- date -u +%FThe master key is the trust boundary. Every host that holds it can store
output that every other holder will then serve as the command's result.
Encryption keeps the output from CacheKit, but it does not tell one holder's
output from another's, and --scope does not separate holders either. So:
- use one master key per group of machines that already trust each other;
- never give it to a runner that executes untrusted code, such as a build of a pull request from a fork;
- never
evalor execute shared output unless every holder of the key may already run code on the machine that consumes it.
A CacheKit fault never stops your command. When CacheKit cannot answer, or
answers with an error, ck runs the command uncached and prints one warning
naming the cause. When another call on the same machine is already running
the command and this one has stale output it may serve, ck serves that
output instead, with the cause on its one stderr line. That covers an outage, a timeout, and a rejected key
(revoked, rotated or missing the ck-run grant), whose warning also names the
fix. Each request to CacheKit gets 1 second: a call adds at most 1 second when
CacheKit is down, and a first call that stores its output adds at most 3. On a
link where requests routinely take longer than that, every call warns and
nothing is cached; use the file backend there. The cost of carrying on is the
quota caching was saving you: while the fault lasts, every call reaches the
command's origin. An outage ends by itself, but a bad key lasts until someone
fixes it, so against a daily quota it can use up the rest of the day. Watch
for the warning.
Keep the clocks in sync. Each call judges an entry's age by its own clock
against the time the writing host recorded. A writer whose clock runs behind
makes entries look older, so they refresh early. A writer whose clock runs
ahead makes them look fresh for longer, by up to the difference, until
CacheKit expires them at --ttl plus --stale after they were written.
What CacheKit receives. Each request carries the API key, the entry's
keyed name, and for a write the encrypted entry and its lifetime. A command's
failure record is stored beside its output, under the same name plus :neg.
So CacheKit can see how many entries you have, their sizes and lifetimes, when
each is read and written, and which commands have a recorded failure, but
neither the commands nor their output. The CacheKit client also sends three
headers on every request, which ck cannot turn off: X-CacheKit-Session-ID, a
random identifier for each ck process; X-CacheKit-Session-Start, when that
process began; and X-CacheKit-L1-Status: disabled. Requests go through the
proxy in HTTPS_PROXY when it is set. ck always talks to
https://api.cachekit.io, and ignores CACHEKIT_API_URL, so a project's
environment cannot send your key elsewhere.
When ck itself receives SIGTERM, SIGHUP, SIGINT or SIGQUIT, the run is interrupted. ck forwards SIGTERM and SIGHUP to the command; SIGINT and SIGQUIT already reach it from the terminal. ck then waits for the command to exit, but not for a background job it left holding the output. Later SIGTERM and SIGHUP signals are still forwarded, so a command that handles the first one gracefully can still be stopped.
An interrupted run discards the output ck was holding back, stores nothing,
and exits 128 plus the number of a signal it received; when several arrive
close together, which one is unspecified. Output already written, by an
uncached call or past 20 MiB, stays written, and a cache entry being written
is never left half-written. A signal while ck is printing stored output can
cut that output off; ck still exits 128 plus the signal number, so a caller
that captures output with $(…) must check the exit code. On an interrupted
run streamed past 20 MiB, ck ends as soon as the command does, so the last
part of the output it was still relaying can be cut off. A SIGINT or SIGQUIT
that lands while ck is starting the command may not reach it; ck still exits
128 plus the signal number when the command exits.
A command that dies of a signal ck did not receive, such as one that kills
itself, has simply failed: ck backs off and serves stored output as for any
other failure. A signal ck inherits as ignored, as under nohup, stays
ignored: ck neither forwards it nor stops for it, and the command inherits it
ignored too.
A hit and a stored miss print nothing, so you cannot tell from the output
whether ck served the stored output or ran the command. Set CK_LOG=debug and
every ck run call prints one more line on stderr saying what it did. Export
it once, and the calls in your shell startup files and scripts report too:
$ CK_LOG=debug ck run --ttl 12h -- op item get api --fields token --reveal
ck: debug: served fresh: backend=file program="op" entry=ab6888403fb1 age=2873104ms exit=0 elapsed=1msThe line starts with what happened: served fresh, served stale,
ran and stored, ran, not stored (the command failed, or its output was too
large or could not be written), refreshed and stored or refreshed, not stored
(--refresh), suppressed (backing off after a failure), ran uncached (the
cache was unavailable), or could not start the command. Then come the backend,
the program name, the entry id, the stored output's age when ck read one, ck's
exit code and how long the call took. Any warning ck prints for the call comes
first and is unchanged. An interrupted call may print no debug line.
The line names only the program, never its arguments: they can hold a secret,
as can the output, which is never logged either, and neither key is. The entry
id is the first 12 characters of the entry's lock file name in
~/.cache/ck/locks/. It comes from the keyed hash, so it cannot be matched
against a guessed command without the master key.
CK_LOG takes only debug. Any other non-empty value prints one warning and
is otherwise ignored, so a typo never stops the command. ck reads CK_LOG
rather than RUST_LOG because the wrapped command sees every variable ck does
except CACHEKIT_*, so RUST_LOG would switch on a Rust program's own logs
too. To keep the lines, redirect stderr: 2>>ck.log.
Linux and macOS. The cache directory must be on a local filesystem: on NFS or SMB, concurrent first calls may each run the command, though entries are never torn.
User-facing docs in this repository follow CacheKit's shared rule on what belongs in them:
What belongs in these docs.
prek install (or pre-commit install) sets up hooks that reject internal references in README
files, docs/ and commit messages.
MIT. See LICENSE.