worm-cli
v0.2.0
Published
Manage warm, parallel git worktree environments for AI coding agents.
Maintainers
Readme
wormhole
A hub for your coding agents — one git-tracked home for their config and instructions, the tooling to maintain it, and a warm pool of worktrees to run several at once.
A coding agent is only as good as its setup: the right instructions and skills, an allowlist of commands it may run unattended, a sandbox around the ones you don't fully trust, and room to work without colliding with the next agent. That setup is usually ad-hoc, trapped on one machine, and untracked — and it falls apart the moment you run more than one agent on a project.
worm turns it into a managed, version-controlled layer with three parts:
- A meta-repo for your agents —
~/.worm. A single git repository holds the config and instructions for every project's agents:CLAUDE.md, skills, slash commands, settings, the permission allowlist, the sandbox policy. Because it's just git, your agent setup is reviewable, shareable, and portable — re-clone a machine and your agents come back configured. - Tooling to maintain that config — recipes. Composable capabilities you switch on per project: a Docker sandbox that keeps filesystem-mutating commands off the host, permission sync so approving a command in one slot teaches every agent, shared history across worktrees.
worm syncreconciles everything declaratively; templates seed new projects. - Tooling to run agents in parallel — a warm worktree pool. Permanent
git worktreeslots let several agents work different branches at once without trampling each other. Think of them as parallel universes of your repo: your main clone is Slot 0, siblings are<repo>-1,<repo>-2, … Each universe is isolated, always-warm (no re-npm installper task), and wired with the same shared config via tunnels - this is wherewormholegets its name from.
The worktree pool used to be the whole story; now it's the backend. The product is the cognitive layer it serves — the git-tracked home that keeps your agents consistent, contained, and reproducible across every universe.
Built around Claude Code today (the recipes wire Claude's hooks and settings) on a deliberately agent-agnostic core — the pool, tunnels, and lifecycle hooks don't care which agent you run. Opening the recipe set and wiring other agents are the next iterations — see Roadmap.
Video - Overview & Demo
(in French, English version incoming!)
Install
npm install -g worm-cli # exposes the `worm` binaryRequires Node ≥ 20 and git ≥ 2.31.
pnpm install
pnpm build
pnpm link --global # exposes the `worm` binaryShell integration (recommended)
Add this to your ~/.zshrc (or ~/.bashrc):
eval "$(worm shell-init)"
eval "$(worm completion zsh)" # or `bash`worm shell-initinstalls aworm()wrapper soworm cd <branch>/worm tp <N>actually change your shell's working directory.worm completion <shell>registers tab completion: subcommand names (worm sta<tab>→worm status), branch completion forswitch, and slot/branch completion forcd/tp/path.
Quick start
# Clone a repo and bind it as Slot 0 (a normal clone — no bare container).
worm clone https://github.com/you/mkpc.git ~/git/mkpc
cd ~/git/mkpc
# Day to day, just switch branches in place — Slot 0 stays warm.
git switch my-feature # or: worm switch my-feature (also re-runs setup.sh)
# Want a second branch checked out at the same time? Add a permanent universe.
worm universe add my-other-feature # creates ~/git/mkpc-1, runs setup.sh
worm cd my-other-feature # hop into it
# See the whole pool.
worm status
# Done with a universe? Collapse it (Slot 0 can never be removed).
worm universe rm my-other-featureworm sync reconciles your shared-file tunnels across every slot (run it after editing shared_paths). worm sync --global does the HOME-scope equivalent and installs any global recipes (autosync) from ~/.worm/config.json into ~/.claude/settings.json. Drop your install commands into .worm/scripts/setup.sh — it runs on worm universe add and worm switch.
Commands
| Command | What it does |
|---|---|
| worm clone <url> [path] [--name X] [--template <dir>] [--skip-hook] | Recommended entry point. Normal-clones <url>, binds it as Slot 0, and warms it via on_create. |
| worm init [--name X] [--template <dir>] [--skip-hook] | Bind the current git clone as Slot 0 and warm it via on_create. Lazily creates ~/.worm/ on first use. Idempotent. |
| worm status [--json] | List every slot in the pool (Slot 0 + siblings) and the branch each is on. |
| worm universe add <branch> [--create] [--skip-hook] | Create a permanent sibling worktree on <branch> at <repo>-<N>, link shared paths, run on_create. Refuses a branch already checked out in another slot. If <branch> doesn't exist, prompts to create it (--create skips the prompt). |
| worm universe rm <ref> [--force] [--skip-hook] | Remove a sibling universe — <ref> is a slot index or a branch. Refuses Slot 0; refuses uncommitted changes unless --force; runs on_remove. |
| worm switch <branch> [--create] [--skip-hook] | git switch <branch> in the current slot and re-run the warm-up hook. If <branch> doesn't exist, prompts to create it (--create skips the prompt). (Plain git switch works too — this just adds the hook + the "branch held elsewhere" guard.) |
| worm sync | Declaratively reconcile shared-path links across all slots: create missing tunnels, prune removed ones, clone any missing store. Idempotent. |
| worm wire [path] | Apply the cognitive layer (shared-path tunnels, the env file, recipe hooks) to a worktree worm didn't create (default: cwd). Call it from another tool's worktree-create hook (Conductor, worktrunk, plain git worktree) to use worm without adopting its topology. Idempotent. |
| worm detach <file> | Sever a shared-path tunnel in the current worktree only — replace the symlink with an independent local copy. The other slots keep the link; worm sync won't restore it. Reverse it by deleting the local file and re-running worm sync. |
| worm sync --global | Reconcile HOME-scope links from ~/.worm/config.json's shared_paths (e.g. ~/.claude/commands → ~/.worm/shared/.claude/commands), and install/strip global recipes (autosync) into ~/.claude/settings.json — machine-wide setup, independent of any project. |
| worm template render <file> [KEY=VALUE …] | Render a {{var}} template file to stdout (worm's templating primitive; leaves shell ${VAR} untouched). For setup scripts that want to drop hand-rolled sed. |
| worm cd <branch> / worm tp <N> | Change directory into a slot by branch name or 0-based index. Requires the shell-init wrapper. |
| worm path <ref> | Print the worktree path for a branch or slot index (what cd/tp use under the hood). |
| worm destroy [--force] | Unbind the project: remove sibling universes, .worm/, and the global profile. Slot 0 (your repo) is left intact. Prompts unless --force. |
| worm shell-init | Print the shell function described in Shell integration. |
| worm completion <bash\|zsh> | Print a tab-completion script for the chosen shell. |
Run worm <command> --help for the full option list. Project-scoped commands resolve Slot 0 via git (--git-common-dir), so they work from any slot or subdirectory.
How it works
For a project named mkpc with two extra universes:
~/git/
├── mkpc/ ← Slot 0: the primary working tree (a normal clone)
│ ├── .git/ ← standard git directory (the common dir for all slots)
│ ├── .worm/ ← thin wiring: (almost) all pointers (git-excluded locally)
│ │ ├── .gitignore ← single line: `*`
│ │ ├── config.json → ~/.worm/projects/mkpc/config.json
│ │ ├── scripts/ → ~/.worm/projects/mkpc/scripts/ (setup.sh lives here)
│ │ ├── recipes/ → ~/.worm/projects/mkpc/recipes/ (materialized artifacts)
│ │ └── logs/ → ~/.worm/projects/mkpc/logs/ (recipe-hook logs)
│ └── .env → ~/.worm/projects/mkpc/.env (a tunnel, on Slot 0)
├── mkpc-1/ ← sibling universe (worm universe add …)
│ └── .env → ~/.worm/projects/mkpc/.env
└── mkpc-2/ ← another sibling universe
~/.worm/ ← your agents' meta-repo (a git repo)
└── projects/mkpc/ ← mkpc's PROFILE — the durable state, survives a reclone
├── config.json .env scripts/ recipes/ logs/
└── .managed-links.json ← manifest of the symlinks worm created per slotSlots are permanent — there's no spawn/teardown, so they stay warm (your node_modules, build state, etc. just persist in each one). Sibling worktrees live one level up so Slot 0's git status never sees them, and .worm/ is hidden from git locally. A project's .worm/ is almost entirely symlinks into its profile (~/.worm/projects/<project>/), where the durable state lives; each slot's shared files link straight at the profile (absolute, one hop), recorded in the profile's manifest so worm sync can add/prune them safely.
Configuration
Each project gets a config at ~/.worm/projects/<project-name>/config.json. The built-in default is intentionally minimal — projects start with no shared files, so worm doesn't presume your stack:
{
"shared_paths": [],
"stores": {},
"hooks": {
"on_create": "bash \"$WORM_PROJECT_ROOT/.worm/scripts/setup.sh\""
},
"recipes": {}
}Edit the file (or pre-seed a --template <dir>) to add what your project needs.
💡 A complete real-world example:
worm-mkpcis the full profile behind the demo video —config.json,setup.sh, a per-worktreeenvblock, the recipe set, and the docker/CLAUDE.local.mdtemplates for a real project. A good setup to crib from.
shared_paths— files tunnelled into every slot. Each entry is either a bare path, pulled from the project profile (~/.worm/projects/<project>/<path>, sprouting an empty placeholder if absent), or{ "path": ".claude/docs", "store": "team" }to pull it from a named store instead. Each slot gets an absolute symlink straight at the source. Common entries:.env,CLAUDE.local.md,.mcp.json. Runworm syncafter changing this list.stores— named external sources forshared_paths, e.g.{ "team": { "root": "~/git/team-shared", "url": "[email protected]:org/team-shared" } }. Ashared_pathsentry with"store": "team"links from that store'srootinstead of the profile — so team docs/commands can live in a separate git repo, shared with your team and editable in place (your edits land as changes in that repo). Ifrootis missing and aurlis given,worm syncclones it on demand. Declare stores per project here, or machine-wide in~/.worm/config.json(project stores win on a name clash).env— a per-worktree dotenv file (off unless present). Unlikeshared_paths(one source symlinked identically everywhere),envwrites a different file into each slot. Shape:{ "file": ".env.worm", "vars": { "FRONT": "{{ 3000 + index * 10000 }}" } }.file(default.env.worm) is the generated filename, gitignored automatically. Eachvarsvalue is an integer arithmetic expression (+ - * / %, parentheses) over two offset bases — pick the one that fits:index— the slot number (positional).{{ 3000 + index * 10000 }}→3000,13000,23000. Clean and sequential; stable for a fixed pool, but drifts if worktrees are ephemeral (the slot number isn't tied to the branch).offset/hash— derived from a stable hash of the branch.{{ 8080 + offset }}gives the same port for a given branch on any machine and any slot order (offset∈ 0–999), at the cost of non-sequential values. The ephemeral-worktree-safe choice.
Plus the text vars
{{ slot }}/{{ branch }}(e.g.DB_NAME=app_{{ slot }}). Multiple components just share the same basis:FRONT={{ 3000 + index * 10000 }},BACK={{ 3001 + index * 10000 }}. Regenerated oninit,universe add,switch, andsync(rewritten only when the content changes), and may not also appear inshared_paths(worm refuses the clash). For advanced cases (a real config file with holes) keep usingworm template renderinsetup.sh;envis the zero-file-to-maintain path for the common one.hooks—on_createruns inside a slot to warm it up: when Slot 0 is bound (init/clone), when a sibling is created (universe add), and onswitch.on_removeruns before a slot is removed. The defaulton_createinvokes.worm/scripts/setup.sh— drop your install commands there (npm install,pip install -r requirements.txt, …) instead of editing the JSON. A non-zeroon_createwarns but doesn't abort; a non-zeroon_removeaborts the removal unless--force. Pass--skip-hookto any of these commands to bind/switch without running the hook (e.g. on an already-warm checkout).recipes— composable capabilities, keyed by name (provider-style). A recipe is enabled iff its key is present; each value is validated by that recipe's own schema. Two kinds of thing back a recipe, kept deliberately separate:- Worm-owned code ships with the binary — config-independent scripts (the sandbox interceptor, the permission-sync script) parameterized at run time, so they live once and a fix propagates by upgrading
worm, with nothing to re-materialize per project. - Genuinely per-project artifacts (the sandbox
Dockerfile/compose.yml/ policy) are materialized under.worm/recipes/<name>/(→ the profile), non-clobbering — edit a generated file and it's kept. To pull a scaffold update, delete it (e.g.rm .worm/recipes/sandbox/Dockerfile) and re-runworm sync. (A planned regenerate/upgrade command is the last roadmap item — see docs/recipes-roadmap.md.)
Hooks are inverted: each slot's
.claude/settings.local.json(gitignored, per-slot) holds one static entry per event —worm hook trigger <event>— installed once. At tool/session time that dispatcher resolves the live slot, runs each enabled recipe's command for the event, injects env, and owns logging. So enabling, disabling, or updating a recipe is a pureconfig.jsonedit — settings never churn, and recipes compose without clobbering each other or your own hooks. Recipe-hook logs land in.worm/logs/(→ the profile):<container>.logfor the container'sup/downoutput,<container>-redirect.logfor the sandbox's allow/deny decisions,sync-permissions.logfor permission sync.tail -fthem to see what fired.Built-in recipes (enable by adding the key, e.g.
"recipes": { "sandbox": {}, "syncPermissions": {} }). Most are project-scoped (per-project config, wired per-slot);autosync,notifyPendingInputandsyncGlobalPermissionsare global — declare them in~/.worm/config.jsonand runworm sync --global(they wire into~/.claude/settings.jsonand fire for every Claude session, any project):sandbox—{ "backend": "docker", "image": "node:22-bookworm", "tools": [], "neverSandbox": [...], "exemptDirs": [], "autostart": true, "autostop": false }. Materializes a Dockerfile (fromimage+tools), a compose file, and a sandbox policy (the interceptor itself ships with worm), then wires each slot so its container auto-starts (autostart) and filesystem-mutating commands are redirected into it (mounted at the same path via$SANDBOX_DIR). See docs/strategy-3-spec.md §6.syncPermissions({}) — wiresSessionStart/SessionEndhooks that union thepermissionsblock of each slot'ssettings.local.jsonwith a canonical store shared across slots (approve a command once, every slot learns it). Merge-preserving — it never touches other recipes' hooks.shareHistory({}) — symlinks each sibling slot's Claude history dir (~/.claude/projects/<slot-slug>) to Slot 0's, so all slots share one conversation history. Refuses to clobber a real history dir (warns instead).shareMemory({}) — symlinks every slot's Claude memory dir (~/.claude/projects/<slot-slug>/memory) at one canonical store in the profile (~/.worm/projects/<name>/.claude/memory), so all slots share one memory that's durable across a Slot 0 reclone. Because the store is the profile (not Slot 0), Slot 0 is linked too; on first run it seeds the store from an existing memory dir, and refuses to clobber a real one once the store exists (warns instead).autosync({ "remote": "origin", "debounceMinutes": 5, "notify": true }) — the one global-scope recipe: declare it in the global~/.worm/config.json("recipes": { "autosync": {} }) and runworm sync --globalto wire it into~/.claude/settings.json, so it fires for every Claude session machine-wide (any project, even non-worm dirs) — not per-slot. It keeps the~/.wormmeta-repo synced across machines without manual push/pull on every node: pull (fetch+rebase) onSessionStart, push (auto-commit + push, debounced) on every turn'sStop— the reliable trigger for a session that's never closed — plus a best-effort flush onSessionEnd. Runs are serialized by a machine-local lock (many open windows all firingSessionStart/Stopwon't run git on~/.wormconcurrently — a busy run just skips; the lock carries an owner token so a stale-steal can't hand it to two racers). Each sync commits local work before rebasing onto the remote — a committed change survives a conflict abort (back onHEAD), whereas an autostash pop-conflict would strand it invisibly. Conflicts are never auto-resolved: a cleangit rebase --abort, a durable marker thatworm statussurfaces, and an OS notification (notify: falseto silence). Requires a git remote on~/.worm(else it no-ops). Machine-local state (.managed-links.json, logs, the conflict marker) is gitignored so it never syncs. Remove it from the config + re-runworm sync --globalto uninstall.notifyPendingInput({ "openOnClick": "" }, global) — fires an OS notification when Claude is waiting on you (your input is pending): "Response ready" onStop, "Waiting for approval" onPermissionRequest. On macOS it usesterminal-notifierand falls back toosascript; on Linux,notify-send. SetopenOnClickto anyopen -aapp name ("Visual Studio Code","Cursor","Windsurf", …) to make clicking the notification open the project folder there; default""= no click action (no editor is presumed). Debounces the burst of intermediateStopyields a background-agent turn produces (one notification on the final answer) and ignores sub-agent events. Shares the notification backend (src/recipes/_lib/notify.js) withautosync.syncGlobalPermissions({}, global) — the machine-wide analogue ofsyncPermissions: bidirectionally unions thepermissionsblock of~/.claude/settings.jsonwith a git-tracked canonical copy in~/.worm/shared/.claude/settings.json, so your global allow/deny rules are version-controlled (and synced across machines onceautosyncpushes them). Onlypermissionsis touched —hooks, marketplaces, trusted dirs in the live file are left intact.
- Worm-owned code ships with the binary — config-independent scripts (the sandbox interceptor, the permission-sync script) parameterized at run time, so they live once and a fix propagates by upgrading
Hook environment
Hook commands (and any script they invoke, like setup.sh) receive:
| Variable | Value |
|---|---|
| WORM_PROJECT_ROOT | Absolute path to Slot 0 (the primary working tree). |
| WORM_SLOT | Slot name being acted on (main for Slot 0, <N> for siblings). |
| WORM_SLOT_INDEX | The numeric, 0-based slot index. Handy for derived values: PORT=$((8080 + WORM_SLOT_INDEX)) (positional — stable only for a fixed pool). |
| WORM_BRANCH | Branch name. |
| WORM_WORKTREE | This slot's worktree path (equals WORM_PROJECT_ROOT for Slot 0). |
| WORM_BRANCH_HASH | Stable 32-bit hash of the branch — same value across machines and slot reordering. |
| WORM_PORT_OFFSET | Stable per-branch offset in 0–999. PORT=$((8080 + WORM_PORT_OFFSET)) is the ephemeral-worktree-safe alternative to WORM_SLOT_INDEX (same basis as the env block's {{ offset }}). |
Templates
On first run, ~/.worm/templates/default/ is seeded with a config.json and scripts/setup.sh. New projects are bootstrapped from that template — edits to it apply to projects created afterwards (existing projects are untouched). Pass --template <dir> to bootstrap from a custom directory; it must contain a config.json, with an optional scripts/ subdirectory.
Environment
| Variable | Effect |
|---|---|
| WORM_HOME | Override the global root (default: ~/.worm). Useful for sandboxes, CI, or multiple worm "homes". |
| WORM_DEBUG | Set to 1 to print stack traces on error. |
Roadmap
worm's substrate is already agent-agnostic — the warm pool, shared-file tunnels, lifecycle hooks, and your instructions file (CLAUDE.md / AGENTS.md / …) don't care which agent you run. The recipe engine is the part still wired specifically to Claude Code. The next two iterations open both up:
- Shareable recipe packages — author a recipe in its own folder (
~/.worm/recipes/<name>/) or a git repo and drop it in, instead of forking wormhole. A recipe becomes a directory of data + real script files, loaded alongside the built-ins, with its config validated from a manifest. Details: docs/recipes-roadmap.md §1–§2 and the "Next up" plan. - Agents beyond Claude — an agent-adapter seam so recipes can wire Cursor, Gemini, Codex, … not just Claude Code. Agents with a pre-tool/session hook system get full recipe support (sandbox, permission sync); agents without one still get the pool, tunnels, and shared instructions. Details: docs/multi-agent-roadmap.md.
The two halves fit together: a recipe-as-data format is what an agent adapter renders per agent. Earlier iterations — live-once recipe code, the inverted hook dispatcher, named stores, home-scope worm sync --global, and worm template render — are already shipped (the roadmap docs carry the full history). The one deferred item is a Terraform-style plan/apply for regenerating scaffolding you've edited.
Changelog
See CHANGELOG.md for the release history and any breaking
changes (the current release, 0.2.0, has a few — notably the removal of the
worm config command and a hook-dispatch migration). worm follows
Semantic Versioning.
Development
pnpm install
pnpm typecheck
pnpm build
pnpm test # runs node:test against the built CLI
pnpm demo # visual walkthrough in an isolated sandboxSee src/README.md for architecture and CLAUDE.md for agent contributor guidelines.
