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.
Open previews from your phone or tablet with a real certificate, without making them public:
work setup --tailscale
work up --tailscaleEvery routed command gets a stable hostname on a domain you control:
https://{command}-{workspace}-{project}.dev.example.com
The name resolves to this machine's Tailscale address, so only devices in your tailnet can reach it. Because each workspace has its own hostname, sessions and cookies stay separate, and an OAuth provider needs a single wildcard redirect URI.
One-time machine setup, guided:
work tailscale setupIt asks for the domain, points *.dev.example.com at this machine's Tailscale
address, gets a Let's Encrypt certificate with acme.sh
(offering to install it with Homebrew), and saves the domain in
~/.config/work/config.json. With Cloudflare DNS it does all of this itself
with an API token scoped to "Edit zone DNS" on that zone; with other providers
it prints the record and the certificate command. It also offers to let Portless
serve the domain on this machine (see below). Running it again only redoes
what is missing or about to expire, and work doctor reports both.
To configure it by hand instead, put these in a gitignored .env.local:
WORK_TAILSCALE_DOMAIN=dev.example.com
WORK_TAILSCALE_CERT=~/path/to/fullchain.pem
WORK_TAILSCALE_KEY=~/path/to/key.pem
# WORK_TAILSCALE_PORT=47443 local proxy portwork starts a small HTTPS proxy on 127.0.0.1 and points tailscale serve's
TCP port 443 at it, so no root is needed. Each request goes through the command's
local Portless URL with X-Forwarded-Host and X-Forwarded-Proto set to the
tailnet origin, so the app sees the same *.localhost host as locally. Renewed
certificate files are picked up automatically. When the last Tailscale command
stops, work stops the proxy and removes the port 443 forward.
WORK_URL and the other WORK_*_URL variables use the tailnet URLs, as with
Cloudflare. The domain resolves to the machine's own tailnet address, which
tailscale serve answers only for other devices. So that the same URLs open on the
machine itself, work tailscale setup offers to reinstall the Portless service
with the domain as a second TLD (Portless 0.15.1 or newer):
portless service install --tld localhost --tld dev.example.comPortless then registers {command}-{workspace}-{project}.dev.example.com next to
the .localhost name and points it at 127.0.0.1 in /etc/hosts. Without it,
use the *.localhost URLs on the machine itself. Like Cloudflare mode,
the workspace keeps Tailscale mode for later run, restart, and up calls;
run work down before switching back. In WorkOS or another OAuth provider, allow
https://*.dev.example.com and the callback path.
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.
Set a global default in ~/.config/work/config.json:
{ "maxTtlSeconds": 3600 }With XDG_CONFIG_HOME set, work reads $XDG_CONFIG_HOME/work/config.json instead.
Set maxTtlSeconds at the top level of the main worktree's work.config.js to
override the global default for that project's workspaces. Use
export WORK_MAX_TTL_SECONDS=3600 to override both for commands started from that
shell. Precedence is env, project config, global config, then unlimited.
Values must be finite, non-negative seconds; 0 disables TTL at any level.
The daemon captures TTL when each command starts and checks expiry every second.
Automatic restarts and daemon restarts preserve the deadline. Explicit restarts
reset it and read the current env and global default. Restarts from tracked state
use the stored project setting. Changing a default leaves running commands on
their original deadline. Expired commands stay stopped, even with restart: "on-exit".
TTL requires a running daemon.
bun run build stamps the Git commit into the CLI. work --version shows it;
-dirty means the build includes uncommitted changes. Builds without Git metadata
show an unknown commit and skip update checks.
workd checks GitHub's main at startup and hourly, without blocking process
supervision or daemon startup. work updates reads the result from
~/.work-cli/updates.json and shows when it was checked. Normal commands do no
update-related disk or network I/O. Checks time out after five seconds per request;
failed checks retain the last successful result and retry after an hour. A recent
attempt also suppresses another check after a daemon restart. Cache entries belong
to the installed commit, so another build cannot reuse an old "up to date" result.
Start workd with work daemon start to enable checks. Stop/start it after rebuilding
to load the new build. WORK_NO_UPDATE_CHECK=1 disables checks when workd starts.
Checks report available updates; they do not install them.
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
# Wait until the web app answers, then open it. Sync server is already wired up via the env var.
work wait chat-streaming web
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 brokenFor scripts and agents, work ps --json (or status --json) and work urls --json
print arrays of { project, workspace, command, ... } objects. They leave out stored
environments and credentials, and print [] when nothing matches.
If 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