A workflow CLI for parallel git worktrees.
You hit "new worktree" in Codex, or start Claude Code with --worktree. Now you want to actually see what it built. So:
- install deps — easy
- get the env vars right — maybe
- get the supporting services (db, queue, sync server, …) isolated from the other worktrees — meh
- and now those isolated services need their own URLs and credentials threaded back into the env — urgh
- open a terminal for each long-running process — times every worktree you're juggling
Your agent runs for 30+ minutes, so you'd like to spin up the next worktree in parallel. Instead you have a pile of terminals open per workspace and you've lost track of which one is which.
The individual pieces are already solved:
| Tool | Solves |
|---|---|
git worktree |
coexisting checkouts per branch |
portless |
stable *.localhost URLs, no port collisions |
work is the glue: a per-workspace setup script, a config of long-running commands, a tiny supervisor, and a single CLI to drive it.
Zero runtime dependencies — just Node ≥ 20.12 stdlib. The full CLI ships as one ~30 KB JS file.
work create feature-x # create a worktree without setup or processes
work create feature-x --remote origin # fetch + track a remote branch
work up feature-x --create # create worktree, run setup, start configured servers
work urls feature-x # see where everything is reachable
work logs -f web # tail one service
work down feature-x # tear it all downRouted commands get a stable URL of the form:
{command}-{workspace}-{project}.localhost
# e.g. web-feature-x-tilly.localhost, sync-feature-x-tilly.localhost
Logs and state live as plain files in ~/.work-cli/ so you can cat, tail, jq them.
Not on npm yet — clone and link locally with Bun:
git clone https://github.com/ccssmnn/work-cli.git
cd work-cli
bun install
bun run build
bun linkOptional but recommended: install portless (for route: true commands). Verify with work doctor.
Shell integration (completion + work cd) — add to ~/.zshrc or ~/.bashrc:
eval "$(work shell-init zsh)" # or: bashOpt in per invocation; existing commands and setup scripts stay unchanged:
work setup --cloudflare
work up --cloudflareEvery routed command gets one stable HTTPS label:
https://{machine}-{command}-{workspace}-{project}.example.com
WORK_URL, every WORK_<ID>_URL, WebSocket variants, and both JSON maps use these URLs. Normal commands remain on *.localhost.
One-time machine setup:
brew install cloudflared
cloudflared tunnel login
cloudflared tunnel create work-cbookPut the settings in a gitignored .env.local at the project root:
WORK_CLOUDFLARE_DOMAIN=example.com
WORK_CLOUDFLARE_TUNNEL_ID=<tunnel UUID>
# Recommended: owner + random 64-bit token + hostname
WORK_CLOUDFLARE_MACHINE=carl-7f3a91c8d2e4b6a8-cbookGenerate the random token once with openssl rand -hex 8, keep the resulting machine value stable, and use a different value per machine. This makes URLs impractical to guess while preserving stable origins. It is obscurity, not authentication: anyone who learns a URL can open it. Do not publish the value in a public repository.
work loads .env.local from the project root, then the selected workspace;
the invoking shell overrides both. It removes all WORK_CLOUDFLARE_* values
before starting setup scripts or dev servers.
Other Cloudflare variables, such as CLOUDFLARE_API_TOKEN, remain available to
commands that need them. Once a workspace is running through Cloudflare,
work run, work restart, and work up inherit that mode without another flag.
Run work down before switching the workspace back to local mode.
cloudflared tunnel create stores credentials in its standard directory; work
discovers them automatically.
The domain must use Cloudflare DNS. Keeping the complete work name in one label, directly below the zone, allows standard Universal SSL to cover it. work creates each exact DNS CNAME and keeps one machine-wide connector synchronized with active commands. DNS records remain stable after commands stop.
In WorkOS, allow CORS origin https://*.example.com and the required non-default redirect wildcard, such as https://*.example.com/callback. Published Tunnel hostnames remain internet-reachable; the random label only makes discovery difficult.
A real work.config.js from tilly — an Astro PWA with a Jazz sync server. Each workspace gets its own isolated sync server, and the web app is told which sync URL to talk to via env var:
// tilly/work.config.js
export default {
project: "tilly",
worktrees: {
dir: "../tilly.worktrees",
setup: "bun scripts/work-setup.ts",
},
commands: {
sync: {
run: 'bunx jazz-run sync --port "$PORT" --host "$HOST"',
autoStart: true,
route: true,
},
web: {
run: 'PUBLIC_JAZZ_SYNC_SERVER="wss://sync-${WORK_WORKSPACE}-tilly.localhost" astro dev --port "$PORT" --host "$HOST"',
autoStart: true,
route: true,
},
},
}scripts/work-setup.ts does the per-worktree prep — copy .env.local, run codegen, whatever the workspace needs. It receives:
| Env var | What it points to |
|---|---|
WORK_ROOT |
the workspace (worktree) being set up |
WORK_SOURCE_ROOT |
the main repo — useful for copying .env.local etc. |
WORK_WORKSPACE |
slugified branch name |
WORK_PROJECT |
project slug from config |
WORK_URL |
primary routed URL (the web command, if routed) |
WORK_WEB_URL |
same as WORK_URL |
WORK_SYNC_URL |
full URL for the sync command, if routed |
WORK_SYNC_WS_URL |
WebSocket URL for the sync command, if routed |
WORK_URLS |
JSON of all routed URLs, keyed by command id |
WORK_WS_URLS |
JSON of WebSocket URLs, keyed by command id |
WORK_SOURCE_ROOT always resolves to the main worktree via git worktree list, so it works the same whether you ran work from the main repo or from another worktree.
Configured commands receive the same routed URL variables.
Day in the life:
# Codex finishes a worktree on the `chat-streaming` branch.
# Spin it up — creates ../tilly.worktrees/chat-streaming, runs setup, starts both servers.
work up chat-streaming --create
# Open the web app. Sync server is already wired up via the env var.
open https://web-chat-streaming-tilly.localhost
# Meanwhile, the agent is grinding for 30 minutes. Start the next worktree in parallel.
work up image-uploads --create
# Or just create a clean worktree without setup or servers.
work create refactor-sidebar
# Need to debug? Tail the sync server logs.
work logs -f -w chat-streaming sync
# Done with this branch — stop everything.
work down chat-streamingAt any moment:
work ps # what's running here
work ps -a # what's running everywhere
work watch # live-refresh the ps table
work watch -a # live-refresh everything
work urls # routed URLs for the current workspace
work doctor # diagnose anything brokenIf a worktree already exists — created by git worktree add directly, Codex, Claude Code, or anything else — cd into it and let work derive everything from the current branch:
cd /path/to/the/worktree
work setup # run the per-workspace setup script against this worktree
work up # start the configured servers (no --create needed)The workspace name is the slugified branch name. The worktree root is $PWD. No path flag needed.
When the worktree doesn't exist yet, work create (and work up --create) resolves the branch like git checkout does:
- a local branch with that name → used as-is
- exactly one remote has the branch → fetched, then checked out as a local tracking branch
- otherwise → a new branch off
HEAD
To grab a branch you've never fetched, name the remote — work fetches it first and fails if the branch doesn't exist there:
work create feature-x --remote origin
work up feature-x --create --remote origin # same, plus setup + serversBranch names keep their real spelling; only the workspace slug is normalized (feat/foo → workspace feat-foo, branch stays feat/foo).
work --help # all subcommands
work <command> --help # one subcommand
work help run # same help, command form
work docs # list built-in topics (config, urls, daemon, …)
work docs config # full config field reference
work docs setup # setup-hook env varsRequires Bun for the dev loop. Build targets Node ≥ 20.12 with zero runtime dependencies.
bun run dev -- doctor # run the CLI from source
bun test # node:test runner
bun run check # typecheck + lint + knip
bun run build # emit dist/MIT © Carl Assmann