interlock.sh docs

Operating a project #

Raw markdown: /operating.md

For humans #

Read this if you run the interlock CLI. Skip it if you only use Studio in the browser.

Do this #

  1. Check which project you are pointed at before your first sync in a tree.
interlock whoami
  1. Confirm the key file is beside code/. Parent-directory keys do not authorize a code tree.
ls -la .interlock-key
  1. Dry-run anything destructive.
interlock sync code --dry

What will bite you #

Then read #

For robots #

How a git tree, a project, and a server stay bound to each other, and what breaks when they come apart. Every command below was checked against code/assets/interlock-cli.js; nothing here is invented surface.

The CLI is Node 18+ with zero dependencies. The API base resolves in this order: --api, then INTERLOCK_URL, then the profile config, then http://localhost:8090.

.interlock-key binds a tree to a project #

The rule. A code tree declares which Interlock project it belongs to by carrying a .interlock-key in the exact directory that owns code/. sync, yolo, status, pull, and push require that exact binding before they read the tree or reach the network. They do not inherit a parent-directory key and do not fall back to the home login. Ordinary non-tree commands may still walk upward for the nearest key.

Why it exists. Credential resolution for tree commands is the exact tree owner's .interlock-key, then stop. INTERLOCK_SDK_KEY, a parent key, and the home profile do not authorize a tree. A home profile proves who the operator is; it does not prove which project owns this tree. An SDK key is bound to exactly one project server-side, and exact placement makes the filesystem boundary equally explicit.

The failure it prevents. This one is destructive and it looks fine while it happens. The old behavior let yolo waive the missing-key warning and use a personal home token. It then diffed THIS tree against THAT token's project: server-only units appeared in the working copy and local-only units published to the wrong environment. A key inherited from a monorepo root creates the same cross-project ambiguity for sibling APIs. Both cases now fail locally:

interlock: yolo refuses an unbound code tree.
        Expected /Users/you/workspace/yourapp/.interlock-key
        (the exact owner of /Users/you/workspace/yourapp/code).
        Parent keys and the home login are intentionally ignored for tree synchronization.

There is no confirmation, environment-variable, or --project escape hatch: rejection happens before network, sync-index, or lock activity. CI must provision .interlock-key in the project workspace with mode 0600.

Minimal example. Bind the tree once, from its root:

interlock connect --dir .

It signs you in, lets you pick or create a project, mints a key for this directory, and writes the file with mode 0600:

interlock: wrote /Users/you/workspace/yourapp/.interlock-key (project yourapp, key il_9f3c2a…, mode 0600)
interlock: every command run at or below /Users/you/workspace/yourapp now uses this project.

The established filename stays .interlock-key, but new files are versioned JSON so project and server metadata can evolve without adding more credential files:

{
  "version": 1,
  "project": "your-project-id",
  "api": "https://interlock.sh",
  "key": "<secret>"
}

The CLI still reads legacy one-line keys. Treat the entire JSON document as secret and keep **/.interlock-key in .gitignore.

For non-tree commands, INTERLOCK_SDK_KEY still overrides the file. Tree commands always use the exact owner file. Tooling only ever CREATES .interlock-key and never rewrites it.

Verify it.

interlock whoami

It prints the active profile, the API base, which credential is in play, and the project directory the key governs. A home-profile credential remains valid for account commands, but tree commands will reject it.

Commit the key file deliberately #

The rule. Decide, once and in writing, whether .interlock-key is tracked. Tracking it is a legitimate choice for a small team; drifting into it is not. Whatever you choose, never print more than the file's first 8 characters.

Why it exists. The key is a credential AND a binding. Untracked, a fresh clone has no project and the first sync in it is the destructive case above. Tracked, git diff .interlock-key catches a stray key that some tool wrote, and a new machine works immediately. Note that the CLI's own connect output advises the opposite default:

interlock: add .interlock-key to .gitignore — it is a credential.

That is the safe default for a public repo. A team that tracks it on purpose should say so where a reader will look, because .gitignore has no effect on an already-tracked file and listing it there would tell a reader the key is absent when it is not.

The failure it prevents. Two, symmetrically. A gitignored key means every teammate's fresh clone silently uses their personal token. A key that is tracked by accident, with nobody having decided it, is an undocumented secret in the history that no rotation plan covers.

Minimal example. The comment that makes the decision reviewable, at the top of .gitignore:

# .interlock-key is deliberately TRACKED, not ignored — the same call made for the DB password.
# It is NOT listed below, because gitignore has no effect on an already-tracked file and listing
# it would tell a reader the key is absent from the repo when it is not.
# When the team grows: `git rm --cached .interlock-key`, add it back here, rotate the key.

Never echo the file. When you must show which key is in play, show a prefix:

cut -c1-8 .interlock-key

Verify it.

git ls-files --error-unmatch .interlock-key

Exit 0 means tracked, exit 1 means ignored. Either is fine; not knowing is not.

One key, one project, one server #

The rule. An SDK key is bound to one project ON ONE SERVER, and .interlock-key is a single slot. A host project develops against RELEASED Interlock. Do not point a tree at a dev Interlock server "just to try something".

Why it exists. The sync index is per server (code/.interlock-index/<server>/<env>/…), so a tree can legitimately sync to several servers and keep separate bases for each. The KEY is not per server. There is one slot, so repointing the tree overwrites the credential that identified it.

The failure it prevents. Pointing a host tree at a local Interlock overwrote the production key and deleted entries from the committed sync base for the production server, which then read as mass deletion on the next production sync. There is no error text for this: it is a clean, successful sync against the wrong server.

Minimal example. When the host project needs an Interlock change, review that code and cut a release; do not aim the tree at a development server. If you genuinely need a second server from the same tree, keep the key file for the primary and pass the other explicitly for that one command:

INTERLOCK_SDK_KEY=$OTHER_KEY interlock sync code --api http://localhost:8090

Verify it. Before any sync you are unsure about, print the plan and change nothing:

interlock sync code --dry

The first line names the profile and the API base it will talk to, for example sync (DRY RUN) [prod] → https://api.interlock.sh (dev; 41 unit(s) in git).

Two CLI identities #

The rule. interlock targets PRODUCTION (~/.interlock/config.json). interlock-local targets a local dev server (~/.interlock/local.json). Same binary, chosen by the invoked name, by --local, or by INTERLOCK_PROFILE=local.

Why it exists. Two configs make the target unambiguous at the call site, in the shell history, and in a script that somebody reads six months later. A flag you can forget would not.

The failure it prevents. Syncing a work-in-progress tree to production because the config happened to point there. Production syncs also prompt:

interlock: this targets PRODUCTION. Continue? [y/N]

Pass --yes in CI, deliberately. Note again that yolo implies --yes, so a yolo against the production profile announces itself and then just runs:

YOLO: two-way live mirror with PRODUCTION — saves deploy, Studio edits land in your tree. No prompts. Godspeed.

Minimal example. Day-to-day loop against the local server, release against production:

interlock-local sync code
interlock sync code --yes

Verify it.

interlock version

It prints the CLI version, the active profile in brackets, and the API base, for example interlock 0.1.0 [prod] · node v22.4.0 · api https://api.interlock.sh.

code/.interlock-index/ is the sync base #

The rule. code/.interlock-index/<server>/<env>/<id>.<ext>.interlock records, per unit, the server version and the content hash as of the last sync. Commit it in the SAME commit as the code change it describes. Indexes for localhost-* servers are gitignored, as is code/.interlock-index/yolo.lock.

Why it exists. The index is what makes every sync decision three-way. The version says whether the SERVER moved; the hash says whether the LOCAL file moved. Without it, sync can only compare two sides and has to guess which one wins.

local vs indexserver vs indexsync does
unchangedunchangednothing
changedunchangedpush, compare-and-swap on the version (a 409 becomes a conflict, never a lost update)
unchangedchangedpull into the tree
changedchangedCONFLICT, nothing overwritten

The failure it prevents. A commit that changes a unit but not its index entry makes the next sync see a local change against a stale base, which at best is a needless push and at worst is a conflict manufactured out of nothing. Committing the index with the code keeps the base honest for everyone who pulls. The per-server layout prevents a worse one: a single shared index read an empty second server as mass remote deletion and wiped a working tree (git restored it, because git is the master). If you are upgrading from a single-server layout, the CLI migrates it in place and says so:

  (index migrated to per-server layout: .interlock-index/interlock.sh/dev)

Minimal example.

interlock-local sync code && git add code && git status --short

code/caller/UseIt.java and code/.interlock-index/<server>/dev/caller/UseIt.java.interlock should appear in the same commit.

Verify it. A clean tree that has just been synced must be a no-op on the next sync:

interlock sync code --dry

Expect 0 pushed, 0 pulled and a nonzero unchanged. Anything else means the committed base does not match what was actually synced.

Conflicts surface as <file>.conflict-server #

The rule. When both sides moved, nothing is overwritten. The server's copy is written next to your file as <file>.conflict-server (gitignored), and you resolve explicitly with --ours <id> to keep the local copy or --theirs <id> to take the server's.

Why it exists. A three-way sync can detect the collision but cannot know which side is right. Writing the server copy beside yours makes the comparison a plain diff instead of a fetch, and leaves both versions on disk while you decide.

The failure it prevents. Silent clobbering in either direction. The conflict is printed with the exact commands that resolve it:

  !! CONFLICT caller/UseIt (dev) — changed locally AND on the server (server v7)
     server copy: code/caller/UseIt.java.conflict-server
     keep yours:  interlock sync code --ours caller/UseIt    ·    take server: --theirs caller/UseIt

Deletions collide the same way and say so:

  !! CONFLICT caller/UseIt (dev) — you deleted it, but the server changed it (v7). Restore it: interlock sync code --theirs caller/UseIt  (or delete it in Studio)

A non-yolo sync exits 1 when anything conflicted or failed, so a pre-flight script notices.

Minimal example. Look before choosing:

diff code/caller/UseIt.java code/caller/UseIt.java.conflict-server
interlock sync code --theirs caller/UseIt

Resolution removes the .conflict-server file and rewrites the index entry. If the id matches nothing on either side, the CLI says so and changes nothing:

interlock: could not resolve 'caller/UseIt' — no such unit locally or on the server

Verify it.

find code -name '*.conflict-server'

Empty output means nothing is outstanding. Add that check to your pre-flight; a stale .conflict-server file is also a *.java file that a naive compile gate would try to build, which is why the gate script excludes it by name.

An agent must not resolve a conflict on a human's behalf #

The rule. An AI agent surfaces a conflict, shows both sides, and STOPS. It does not run --ours or --theirs unless the human asked for that specific resolution.

Why it exists. A conflict means two people made a decision about the same unit. Only they can say which decision survives. Every other part of sync is recoverable from git; a resolution is the one step that deliberately discards one side.

The failure it prevents. An agent picking --ours because the local tree is the one it can see throws away a colleague's Studio edit that exists nowhere else, with a green summary line reporting success. There is no error text, because nothing failed.

Minimal example. What an agent should produce instead of a resolution:

CONFLICT caller/UseIt (dev), server v7.
  local:  code/caller/UseIt.java
  server: code/caller/UseIt.java.conflict-server
  diff:   23 lines differ, both changed the cooldown branch
Resolve with `interlock sync code --ours caller/UseIt` (keep local) or `--theirs caller/UseIt` (take server).
Which one do you want?

Verify it. After any agent-run sync, the conflicts must still be there:

find code -name '*.conflict-server'

If the agent reported conflicts and this comes back empty without you having chosen, the rule was broken.

sync versus yolo #

The rule. sync runs once and stops. yolo first computes that same plan without changing anything. Byte-identical stale or missing sync records are refreshed automatically because there is no ownership decision to make. If authored content differs in a terminal, it lists the affected units and offers [G] make the target match Git, [S] import the target into the tree, [D] show a syntax-colored target → local Git diff, [R] review the complete plan, or [C] cancel. A direction requires a typed second confirmation; YOLO runs the normal three-way/CAS sync, rechecks byte-for-byte equality, and attaches only when that recheck is clean. Non-interactive callers still change nothing unless they explicitly pass --git-wins --yes or --server-wins --yes. Once attached, local saves upload, server and Studio edits arrive in the tree, and deletes propagate both ways. Run yolo only when a human explicitly asked for it, and only in a tree whose exact owner directory has .interlock-key.

Why it exists. The mirror is genuinely useful when someone is editing in Studio and you want those edits in git as they happen. It is also a process that deletes files in your working copy without asking after startup, which is fine when the tree is bound to the right project and catastrophic when it is not. Startup therefore makes ownership explicit instead of sending the operator away to understand sync metadata and run a second command. Importing the server is refused while authored files are dirty in Git, because “take theirs” without a recovery path is data loss.

The failure it prevents. Two mirrors fighting over the same tree, which the lock refuses:

interlock: another yolo is already mirroring this tree (pid 4711) — stop it first

And a non-interactive caller that did not choose ownership:

interlock: YOLO startup refused: dev and this tree are not already in sync. Nothing was changed. Run interactively, or pass --git-wins --yes / --server-wins --yes explicitly.

The larger failure remains a mirror running in an unbound tree, which is the wipe described in .interlock-key binds a tree to a project. The CLI now rejects that state before opening the mirror.

Minimal example. Safe daily loop:

interlock-local sync code

Mirror, when a human asked for it and whoami shows the right key:

interlock-local yolo code

If the preflight differs, choose a side in that same command. For scripts and CI, spell it out:

interlock yolo code dev --git-wins --yes

Once mirroring, it reports live feed state (⚡ live feed connected, or a fall back to 2.5s polling), and every download prints ↓ <id> v<n> (<env>) → tree (review with git). Review those with git diff and commit what you keep.

Verify it. Before starting a mirror, prove the target and the plan:

interlock whoami && interlock sync code --dry

Verified command reference #

Everything below exists in code/assets/interlock-cli.js. Flags not listed here were not verified.

commandwhat it does
interlock connect [--dir <d>] [--project <id>] [--label <l>]sign in, pick or create a project, mint a key, write .interlock-key (mode 0600)
interlock initsign in if needed, then install the Interlock skills into Claude Code, Codex, and Cursor for this project; skips sign-in when a key already exists
interlock whoamiprofile, API base, which credential is in play, which directory the key governs
interlock versionCLI version, active profile, API base
`interlock sync [dir] [--dry] [--prune] [--git-wins\--server-wins] [--env <e>] [--yes] [--project <id>]`one-shot three-way sync; ownership policies require --yes, and server-wins refuses a dirty authored tree
interlock sync [dir] --ours <id> / --theirs <id>resolve one conflict, keeping local or taking the server copy
`interlock yolo [dir] [--git-wins\--server-wins] [--yes]`live two-way mirror; a terminal guides drift reconciliation, scripts must state ownership, and attach still requires a clean recheck; one per tree
interlock push <file> [--promote]upsert one unit, id and env inferred from the code/… path
interlock pull [--env <e>] [--dir <d>]download an env's units into the tree, then review with git
interlock run <id> [--env <e>] [--save <path>]execute a unit; exit 4 when no such unit, exit 1 on other failures
interlock get <id> [--env <e>] [--save <path>]fetch a unit's source; binary units require --save
interlock list [--env <e>]list units with language and version
`interlock search <query> [target] [--in <prefix>] [--limit <n>] [-C <n> \-B <n> -A <n>]`ranked content search across units, with optional asymmetric line context
interlock tasks list [--limit n]list durable tasks in the selected project
interlock tasks submit <type> [--input JSON] [--schema n] [--idempotency-key k]submit one task; input is JSON and schema version is a separate integer
`interlock tasks get\watch\cancel <taskId>`inspect, follow, or cooperatively cancel one task
interlock tasks workersshow ready, full, draining and offline task workers
interlock tasks doctor [--spool <dir>]verify Tasks authorization, clock drift, queue/outbox health, and optional spool access
interlock check [dir]author-time syntax gate for .js units; .jsx and .java are reported as skipped and guarded server-side
interlock promote <id> [--from <env>]dev to staging to prod
interlock projects [new <id> --name <n>]list your projects, or create one
`interlock keys <list\new\roll\revoke> --project <id>`manage project keys; `new --kind sdk\task-producer\task-worker` chooses authority and the plaintext is shown once
`interlock members <list\add\remove> --project <id>`manage project members
interlock login [--api <url>] [--token <t>]set this profile's API base and token

Environment: INTERLOCK_SDK_KEY (overrides the key file), INTERLOCK_URL (API base), INTERLOCK_PROFILE=local (same as --local).