capy command is the main loop. Run it any time and Capy:
- Authenticates you (if needed) via a browser-based login.
- Unlocks your master key by co-decrypting it with the Capy service.
- Pulls the encrypted values for the current branch from the service.
keep.lockitself is git-owned - Capy only rebuilds it from the service when the file is missing entirely. - Compares the hashes pinned in
keep.lock, your local.env, and the remote values. - Shows you every difference and asks what to do with them.
- Rewrites
.envin place withcapy:…snippets that encode the resolved values, updateskeep.lock, and commits it.
.env is the ciphertext plus a short cosmetic preview - the first and last few characters of the value, so you can still tell entries apart at a glance. The one place full plaintext hits disk is the first run, which saves your original .env to .env.pre-capy.old.
First run vs. subsequent runs
On the first run in a project, Capy treats.env as authoritative:
- Asks which organization and project to use (or creates them).
- Generates a project encryption key via the zero-trust co-decrypt flow.
- Writes
keep.lockto the project root and commits it to git. - Asks which branch the project should start with -
developmentby default - and creates it. - Asks you to confirm, then encrypts every value in
.envand uploads the ciphertext. - Adds
.env,.capy,.env.pre-capy.old, and.env.*.decryptedto.gitignore. - Installs git hooks (
post-checkoutandpost-merge) that runcapy statusafter branch switches and merges. - Writes a commented-out backup of your original
.envto.env.pre-capy.old(gitignored) and rewrites.envwithcapy:…snippets.
.env. Any difference stops the run and asks what to do with it - resolve everything one way, or pick a value per variable.
Conflicts and resolution
Everycapy run is a three-way compare between:
- Pinned - hashes stored in
keep.lockfrom the last sync. - Local - what’s in your
.envright now (decrypted in memory). - Remote - what’s on the service for this branch.
(pinned, local, remote). Here’s every case, using A for one value, B and C for others, and — for “absent”. The last column is the line capy status prints for that case; the capy sync flow makes the same classification but renders it as the three values side by side, without labels.
The first two rows are the only ones Capy handles without asking. Everything else lands in the diff table and waits for your decision - Capy never pushes or pulls a variable on its own. If nothing at all differs, Capy prints
Everything is up to date! and exits.
When something does differ, Capy prints a three-way diff table and then a bulk-action menu:
abc...xyz snippets - the first and last three characters of the plaintext, or the whole value when it is six characters or shorter. Which menu entries appear depends on which columns actually differ: Capy drops the options that can’t apply, so a run where only the remote moved offers Retrieve all remote values and Retrieve all pinned values but no commit option. In local-only mode there is no remote, so the first entry reads Commit all local values.
Pick Individually resolve to open an interactive arrow-key table where each variable has its own row and its own Choice:
pinned, local, remote, and delete; ↑ ↓ moves between variables; Enter confirms the row and jumps to the next unresolved one. Each row starts on the pinned value when Capy can resolve it, otherwise on local, then remote. Once every row is confirmed, Capy applies the merged set and rewrites .env in place. q cancels without writing anything.
Resolving individually updates your local .env and keep.lock - it doesn’t upload anything. Only Commit and push all local values pushes to the service, so run capy push afterwards if you want your resolved values shared with the team.
Remote unreachable
If the service is unreachable, Capy printsCannot reach remote. Showing local changes only. and falls back to a two-way compare of pinned vs. local. Anything it could not read is left out of the comparison entirely, so a variable that exists only on the remote never shows up as a difference to resolve.
Losing access to the branch is a different case: the run fails with a permission error, and Capy lists the branches you can still read so you can capy checkout one of them. In capy status, an unreachable or forbidden remote instead marks every variable with a ? and reports the reason.
Status and push
status only reports - it never changes your .env, your keep.lock, or the remote. push encrypts whatever is in .env right now and sends it to the branch you’re on; it never fetches or diffs first, so run capy when you want to see what a teammate changed.
What ends up in git
keep.lock- a versioning manifest with no keys and no plaintext. Committed - Capy commits it for you (chore(capy): pin <branch> secrets) whenever a sync, push, or edit changes it. SetCAPY_NO_AUTOCOMMIT=1to opt out and commit it yourself..env- ciphertext snippets at rest, but gitignored. Capy syncs the content over the wire instead of through git..env.pre-capy.old- commented-out backup of your original.envfrom before first-run. Gitignored..capy/- local state for this project (the active branch, sync state). Gitignored. Your session and keys live in the global~/.capy/instead.
Branches
Capy has its own concept of secret branches - independent sets of values for dev, staging, prod, or ephemeral environments. Secret branches are tracked separately from git branches: thepost-checkout and post-merge hooks run capy status so drift surfaces right after you move around the tree, but switching secret branches is an explicit capy checkout <branch>. See Branches.
What’s next
capy (CLI reference)
Every flag on the sync command.
Branches
Isolate secrets per environment.