An environment engine: it provisions N isolated, runnable instances of one
stateful dev stack on a single machine — a git worktree per project, a private
block of ports and other index-derived values, a curated per-instance
environment, and the service lifecycle to go with it — primarily so several
Claude Code sessions can work on several tasks at once without fighting over
port 3000, one database, or one checkout; it is equally usable without them.
Nothing about a workspace is recorded except its allocation: branches, setup
freshness and running daemons are all derived from git, the filesystem and
/proc at the moment you ask, so there is no status machine, no "broken
workspace" state, and every command is an idempotent ensure that converges when
re-run. Single static Go binary, no runtime on the host, POSIX only (Linux
first; macOS builds and is expected to work — where /proc is absent, daemon
liveness degrades to pid-only by design).
The first question worth answering before any command reference: how are
workspaces isolated? They aren't — they are separated, and the
distinction is the design. A workspace is ordinary processes in ordinary
directories on your host: a git worktree per project, a block of numbered
values no other live workspace holds, an environment built from those
values, daemons started with that environment. No container, no VM, no
namespace. Two workspaces don't collide because — and only because —
everything collidable is routed through the allocated values: the server
listens on ${PORT0}, the database is my_app_${WORKSPACE}, and each
workspace gets its own numbers and its own name.
Which means the separation is exactly as complete as your config makes
it. The tool guarantees no two live workspaces share an allocation; it
cannot know that your app also hardcodes port 3000 somewhere or that two
workspaces write to the same Redis keyspace. If workspaces fight over a
resource, that resource is not yet routed through values/env — the fix
is a config edit, and the starter config shows the patterns
(per-workspace database names, teardown that drops them, ports everywhere a
port appears).
Why separation first instead of real isolation? Because it is simple and it
runs anywhere: no daemon to install, no images to build, identical behavior
on Linux and macOS — and native processes mean native tooling, so your
debugger attaches, your shell works, and version-manager shims resolve each
worktree's own .ruby-version without a translation layer. A
container-based flavor (true isolation; explored during the predecessor's
planning) is on the roadmap — just not today's trade.
Isn't this built into Claude Code? Partly — and the parts compose. Claude Code's own worktree isolation gives a session or subagent its own checkout, so parallel edits don't collide; it is ephemeral and scoped to the work. What it doesn't provide is everything around the checkout: a worktree of your app still wants port 3000, the same dev database, the same Redis keyspace, and someone to run setup and start the server. That runtime layer — allocated values, generated env, service lifecycle, durable task-scoped identity that outlives any one session (and needs no Claude at all) — is this tool. A session inside a workspace can still spawn worktree-isolated subagents: code isolation nested inside environment separation.
The quick path, straight from the module proxy:
go install github.com/Phaengris/claude-workspaces/cmd/workspace@latest(workspace --version reports dev on a go-install'd binary — the version is
stamped via -ldflags, which go install does not apply.) Or build from a
checkout:
CGO_ENABLED=0 go build -ldflags "-X github.com/Phaengris/claude-workspaces/internal/cli.version=1.3.0" -o workspace ./cmd/workspaceCGO_ENABLED=0 is the point of the exercise (one static file, no libc
coupling). The -ldflags value is what workspace --version reports; without
it the version reads dev, which is a perfectly good answer for a local build.
There is no Makefile and no build tags.
On a fresh machine the prerequisites are a Go toolchain (see go.mod for
the floor) and git; everything the tool ships — the skill, the hook, the shell
wrappers, the starter config — is embedded in the binary, so the two commands
below are the whole install. The daemons you configure have their own
prerequisites, of course.
./workspace installinstall copies the running binary (the assets — skill, hook, shell
wrappers, config stub — are embedded in it, so the installed copy never
references your build tree) and writes:
| Path | What |
|---|---|
~/.local/bin/workspace |
the binary, mode 0755 |
~/.config/fish/functions/workspace.fish |
the fish cd wrapper (autoloaded) |
~/.config/fish/completions/workspace.fish |
generated fish completions (autoloaded) |
~/.local/share/workspace/shell/workspace.bash |
the bash/zsh cd wrapper — you source it |
~/.local/share/workspace/completions/workspace.bash |
generated bash completions — you source it |
~/.local/share/workspace/completions/_workspace |
generated zsh completions — you add the dir to $fpath |
~/.claude/skills/claude-workspaces/SKILL.md |
the Claude Code skill (its own name, so it coexists with the v1 skill) |
~/.local/share/workspace/hooks/session-start.sh |
the SessionStart hook script, mode 0755 |
~/.local/share/workspace/install-manifest.json |
the list of the paths above — the uninstall contract |
<root>/config.yml |
a commented starter config, only if absent |
<root>/ |
the workspaces root, created if missing |
The completion scripts are generated from the live command tree at install time (not shipped as static assets), so they always match the binary you installed.
install deliberately edits nothing that is yours: no
~/.claude/settings.json, no shell rc file. Instead it prints what to add. The
hook needs this merged into ~/.claude/settings.json (keep whatever else that
file already has):
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "/home/you/.local/share/workspace/hooks/session-start.sh" } ] }
]
}
}The hook is context-only: inside a managed workspace it prints the workspace's
identity and workspace status output at session start; outside one it prints
nothing and exits 0. It never mutates anything.
Shell integration — workspace cd can only print a directory (a child
process cannot chdir its parent shell), so a small function does the actual
cd and passes every other subcommand straight through:
# ~/.bashrc
. "$HOME/.local/share/workspace/shell/workspace.bash"
. "$HOME/.local/share/workspace/completions/workspace.bash"
# ~/.zshrc
. "$HOME/.local/share/workspace/shell/workspace.bash"
fpath=("$HOME/.local/share/workspace/completions" $fpath)
autoload -U compinit && compinitfish needs nothing added: both of its files land in autoload directories.
Make sure ~/.local/bin is on your PATH.
Re-running install is idempotent: every tool-owned file is overwritten in
place, config.yml is written only when absent (your edits are never
overwritten), and the manifest is rewritten last. Installing from the
installed binary is safe — the same-file case is detected and the copy skipped.
workspace uninstallRemoves exactly the paths the manifest lists, then the manifest, and says
what it left behind: the workspaces root (your config.yml and your
workspaces), the SessionStart entry you added to settings.json (not ours to
remove, since it was not ours to add), and the now-empty tool directories.
Nothing else is ever touched — a manifest entry pointing at /, at $HOME, at
the workspaces root or at anything inside it is refused rather than executed.
With nothing installed it prints nothing installed and exits 0.
The root is ~/claude-workspaces (override with CLAUDE_WORKSPACES_ROOT_DIR;
each root is a fully independent universe with its own config and registry).
Describe a project in <root>/config.yml — the installed stub explains every
key, and this is enough to follow along with any git repo:
values:
PORT:
start: 5000
per_workspace: 10
projects:
demo:
repo: ~/dev/demo
setup:
- echo "setup ran"
start:
- echo "migrations go here"
- web: python3 -u -m http.server ${PORT0}
teardown:
- echo "teardown ran"
env:
DEMO_URL: http://localhost:${PORT0}
browse_port: ${PORT0}Then:
workspace doctor
workspace new DEMO-1 "try the tool" demo
workspace up DEMO-1
workspace ls -g
workspace status DEMO-1
workspace logs DEMO-1 web -n 20
workspace cd DEMO-1
workspace which
workspace exec DEMO-1 demo env
workspace claude DEMO-1
workspace down DEMO-1
cd ~
workspace destroy DEMO-1
workspace gcWhat just happened: new allocated index 0 (so PORT0..PORT9 = 5000..5009),
created ~/claude-workspaces/DEMO-1_try-the-tool, wrote WORKSPACE.md and a
one-line CLAUDE.md, added a demo worktree on branch DEMO-1_try-the-tool, wrote the
project's .env, and ran setup: once (stamped). up ran the bare start:
entry to completion and then started the named daemon web. cd moved the
shell (via the wrapper) and which confirmed where it landed. exec ran a
command in the curated environment. claude launched Claude Code in the
workspace dir with --dangerously-skip-permissions injected. down stopped
the daemon with a confirmed group TERM, destroy ran teardown:, removed the
worktree, the directory and the allocation, and gc found nothing left to
collect.
Three notes on the example. python3 -u: a daemon's stdout is a file, and
interpreters block-buffer that, so -u / STDOUT.sync = true / equivalent is
what makes workspace logs readable at all. The logs line above may still
print nothing — a daemon started a second ago has not necessarily written
anything yet; run it again. And cd ~ before destroy, because destroying the
directory you are standing in leaves your shell in a directory that no longer
exists.
One file: <root>/config.yml. It is decoded strictly — an unknown or
misspelled key is an error with a line:column position — and validated at
every load, so every command sees the same verdict. workspace doctor prints
the full report. There is no reload step and no cache.
Onboarding a real project is two halves: this file, and making the app
consume what the workspace env provides (no hardcoded ports or database
names). examples/rails.md walks a typical Rails +
Vite + Sidekiq app through both — and the installed Claude Code skill
teaches sessions the same procedure, so "configure workspaces for my
project" is a thing you can simply ask a session to do.
# index-derived numbers. Workspace with index i gets
# NAME0 .. NAME<per_workspace-1> = start + i*per_workspace + n
values:
PORT:
start: 5000 # must be positive
per_workspace: 10 # must be >= 1
REDIS_DB:
start: 1
per_workspace: 1 # a block of one is still named REDIS_DB0
# environment for every project's commands, ${…} substituted
env:
RAILS_ENV: development
TASK_ID: ${WORKSPACE}
# extra PARENT variables allowed through the curated environment
env_allow: [MY_API_TOKEN, DOCKER_HOST]
# reusable project definitions
templates:
rails-client:
params: [NAME] # declared load-time parameters
repo: ~/dev/clients/${NAME}
base_branch: main
setup:
- bundle install
start:
- rails: bin/rails s -p ${PORT0}
browse_port: ${PORT0}
projects:
my-app:
repo: ~/dev/my-app # REQUIRED; ~ expanded
base_branch: main # branch off this (default: the repo's HEAD)
path: my-app # subdir inside the workspace (default: the key)
depends: shared-lib # a name or a list; orders setup/up, reversed for down
setup: # at checkout; re-run when these lines change
- bundle install
- bin/rails db:prepare
start: # what `up` runs, in order
- bin/rails db:migrate # bare string = run-and-wait
- rails: bin/rails s -p ${PORT0} # {name: cmd} = DAEMON
- worker: # {name: {command, description}} = DAEMON,
command: bin/sidekiq # with a description `status`/
description: background jobs # WORKSPACE.md show (${…} substituted)
stop: # optional; runs AFTER this project's daemons stop
- bin/rails tmp:clear
teardown: # on `destroy`, before the worktree goes
- dropdb --if-exists my_app_${WORKSPACE}
env: # project env, over the global env
DATABASE_URL: postgres:///my_app_${WORKSPACE}
PORT: ${PORT0}
env_allow: [MY_APP_SECRET] # also valid per project
browse_port: ${PORT0}
instructions: | # appended verbatim to WORKSPACE.md
Tests: `bin/rspec`.
shared-lib:
repo: ~/dev/shared-lib
acme: # built from the template above
template: rails-client
params:
NAME: acmeRuntime tokens are substituted when a command runs, in env, setup,
start, stop, teardown and browse_port:
${WORKSPACE}— the task id (DEMO-1), not the directory name (DEMO-1_try-the-tool). The name is historical; the value is the id.${PROJECT}— the project name (only when a project is in scope).${PORT0},${PORT1},${REDIS_DB0}, … — the index-derived values.
Unknown ${…} tokens pass through untouched, which is what lets load-time
template params and runtime tokens share one syntax. Substitution applies to
values only, never to keys, and a substituted value must not itself contain
another token (single-pass, order-dependent, unsupported).
Templates. A project's own keys shallow-merge over the template's — a
key replaces it wholesale, there is no deep merge. ${PARAM} is substituted at
load time for names declared in params: only; runtime tokens pass
through. An unknown template, a missing param, or a param that was never
declared is a validation error.
Values math. For value NAME with start: s and per_workspace: k, the
workspace at index i gets NAME0..NAME(k-1) = s + i*k + n. Indices are
assigned lowest-free-first, so a released index is reused — which is exactly
why release refuses while daemons are running (see Workspace hygiene).
workspace ports shows the blocks in use.
depends. A string or a list of project names. It gives a topological
order over the workspace's checked-out projects (edges to projects that are
not checked out here are ignored): checkout/up follow it, down and
teardown reverse it. A cycle, or a dependency on an unconfigured project, is
a validation error.
path. Where the worktree lands inside the workspace; defaults to the
project key. It must be relative, must contain no .. component, and must not
resolve to the workspace dir itself — destroy force-removes that directory,
so an escaping value is rejected at load time rather than trusted later.
.env seeding. At checkout the source repo's own .env is read as
defaults and the workspace's resolved env is merged on top (the workspace
always wins); the result is written to the worktree's .env, sorted K=V,
mode 0600. Blank lines and # comments in the source are skipped; a line
without = is skipped. A line spelled export FOO=bar parses as the key
export FOO — it round-trips into the written file unchanged, but it does not
override a workspace FOO, so write plain FOO=bar in repo .env files you
want to layer under this. Add .env to each repo's .gitignore: checkout
writes it into the worktree, so a repo that tracks .env reads dirty in every
workspace — to ls -g's * and to gc --destroy-dirs's clean check alike.
Two YAML caveats, both worth knowing before they bite:
- Flow style and
${…}: the YAML spec forbids{/}in plain (unquoted) scalars inside flow collections, soenv: {A: ${PORT0}}andsetup: [echo ${PORT0}]are invalid YAML. Use block style (as every example here does) or quote the value:env: {A: "${PORT0}"}. Ruby's Psych tolerated this; the Go parser correctly rejects it. - A colon inside a bare
start:entry:- echo "run: it"is a single-key map to YAML, so it becomes a daemon namedecho "run— not a run-and-wait command. Quote the whole entry (- 'echo "run: it"') whenever it contains:.
Error positions. Strict-decode errors quote the position of the bytes that
were decoded. A config with no templates is decoded from your file, so the
positions point at config.yml. A config that uses templates must be
expanded and re-marshaled first, so its positions refer to that regenerated
(key-sorted, re-laid-out) document — the error message says so explicitly when
that applies.
Ordering of user-visible output (env files, project lists, workspace listings) is alphabetical by contract, never insertion order.
Grouped as the design document groups them. <ws> is a workspace
identifier: the full directory name or the task id. An ambiguous task id is a
plain error listing the candidates.
| Group | Command | What it does |
|---|---|---|
| Lifecycle | new <task_id> <description> [project…] |
Allocate, create the dir, write WORKSPACE.md + CLAUDE.md, check the listed projects out. Transactional: any failure undoes everything this invocation made. |
checkout <ws> <project> [project…] |
Add projects: worktree, .env, stamped setup, refresh WORKSPACE.md. Idempotent. |
|
destroy [--force] <ws> |
down → teardown → remove worktrees + dir → release the allocation. Only ever removes dirs the tool created. |
|
| Allocation | adopt [dir] [--projects a,b] |
Give an existing directory an allocation, values and per-project .envs. Detects projects from git, or takes the list. Never creates, never claims ownership. Idempotent. |
release [dir] |
Drop the allocation, touch no files. Defaults to the workspace containing the cwd. | |
gc [-d|--destroy-dirs] |
Release vanished allocations, reap stale pid files; with -d also destroy fully merged, clean, daemonless, tool-created workspaces. |
|
| Services | up <ws> [target…] (alias start) |
Ensure setup is current, run the run-and-waits, start daemons not already running, in dependency order. Idempotent. |
down <ws> [target…] (alias stop) |
Stop daemons (group TERM → poll ≤5s → KILL), report which signal was needed, then run stop:. |
|
restart <ws> [target…] |
down with confirmed death, then up, over the same target set. |
|
logs <ws> <daemon> [-n N] [-f] |
Print one daemon's stdout log (-n, default 50; -n 0 for none) and optionally follow it (-f, which also streams stderr). |
|
exec <ws> [project] <cmd> [args…] |
Replace this process (execve) with the command, in the workspace or a project worktree, under the curated environment. |
|
browse <ws> [project] |
Open http://localhost:<browse_port>; with no xdg-open on PATH, print the URL instead (exit 0 — the SSH-friendly path). |
|
| Observe | ls [-g] [-a] |
Every workspace, one line each; -g appends project@branch per checked-out project, * when dirty (git runs concurrently, bounded). -a also lists root dirs no allocation claims: released workspaces (identity derived from the dir name, adopt to reuse) and stranger dirs, labeled (unmanaged). |
status [ws] |
One workspace in full, every field derived live; with no argument, the ls listing. Renders the workspace CLAUDE.md's ## Status section verbatim when present — the durable handoff note sessions maintain, answering "where was I with this?" from outside any session. |
|
env <ws> [project] |
The resolved environment as sorted K=V. |
|
ports |
Allocated value blocks across workspaces. | |
| Sessions | claude <ws> [-S] [-R] [claude args…] |
Claude Code in the workspace dir, with flag injection. |
launch <task_id> [<description> [project…]] [-S] [-R] [-- claude args…] |
new-or-reuse + checkout + claude, one shot. Daemons are lazy — not started here; use up. |
|
try <description…> [-S] [-R] [-- claude args…] |
A draft workspace: no projects, generated TRY-<n> id, description from every word you type (no quotes), straight into a session. Allocated values included — a draft's scratch server has its ports. Graduate with checkout, or destroy it. |
|
| Navigate | cd <ws> [project] |
Print the directory; the installed shell wrapper performs the cd. |
which |
The workspace containing the cwd (exit 3 when there is none — the scriptable "am I in a workspace?"). | |
| Meta | doctor |
Config report + registry + allocations + missing repos + daemon health. Reports, never fixes. |
install / uninstall |
See Install. | |
completion <shell> |
The generated completion script (bash, zsh, fish; powershell comes free from the framework and is untested here). |
Global flags. --version/-v, --help/-h, and --json. --json is
scoped to the query commands — ls, status, env, ports, which,
doctor. Every other command accepts and ignores it (so a caller that sets it
globally never breaks), because there is no query result to serialize;
cd/browse print a single path or URL, which already is the machine-readable
form, and a log is bytes some process wrote.
Exit codes (v1 exited 1 for everything; scripts and the skill can now distinguish):
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | command/operation failure |
| 2 | usage (bad flag, wrong arg count, unknown command) |
| 3 | workspace or project not found |
| 4 | config error |
A claude/launch session propagates Claude's own exit code verbatim, so a
3 from there is Claude's 3, not "not found".
Service targets (up/down/restart/logs) share one grammar: a target
is a project name or a daemon name, resolved against the workspace's
checked-out projects first, then their daemons. A daemon name defined by more
than one project is ambiguous — qualify it as project:daemon. Addressing a
single daemon still runs its project's ensure-chain first. No target means the
whole workspace.
Completions cover workspace identifiers, project names (templated projects
included), and daemon targets for the command and position you are at. A
completer never reports a failure: a broken config or registry collapses to "no
suggestions" rather than printing an error at your prompt or falling back to
file names in a workspace slot. The two session commands (claude, launch)
disable flag parsing so everything can reach Claude, which also means the shell
can only be helped with their first argument.
There is no per-command version-manager wrapper. Three mechanisms, applied to
every command the tool runs for you — setup, start, stop, teardown,
exec:
-
An allowlist. A spawned command receives only these parent variables, by exact name (not globs — the list cannot grow by accident):
HOME USER LOGNAME SHELL TERM TERM_PROGRAM LANG LANGUAGE LC_ALL LC_CTYPE LC_MESSAGES LC_COLLATE LC_NUMERIC LC_TIME TZ DISPLAY WAYLAND_DISPLAY XAUTHORITY SSH_AUTH_SOCK SSH_AGENT_PID GPG_AGENT_INFO GNUPGHOME XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME DBUS_SESSION_BUS_ADDRESSplus the resolved workspace/project
envon top. Version-manager pin variables are dropped by prefix —RBENV_ PYENV_ NODENV_ PLENV_ GOENV_ RUBYENV_ ASDF_ MISE_ __MISE_— so an activated shell cannot pin a workspace's commands to the version that happened to be active when you launched them. Anenv_allowentry that names a pin variable exactly outranks the prefix drop (explicit intent wins). -
A sanitized
PATH.PATHsurvives, minus the segments that are concrete per-version install bins — a segment containing/versions/or/installs/and ending in/bin. Version-manager shims therefore stay reachable and resolve each worktree's own.ruby-version/.tool-versionsby cwd.PATHis always sanitized, even if you name it inenv_allow; theenv:blocks are the raw-override channel. -
Startup self-sanitize. The same treatment is applied to the tool's own process environment before anything is spawned, so even inherit-spawns start clean.
exec closes v1's documented env-poisoning bug by construction: the curated
slice is the complete child environment, and exec has no other spawn path.
Two tiers, deliberately. Commands the tool runs for you get the curated
environment above. A Claude session (claude, launch) gets the full
inherited environment instead — already self-sanitized at startup, so
version-manager pins are gone — overlaid with the workspace's resolved
global env and the runtime variables themselves (WORKSPACE, PORT0, …)
exported as real variables. Claude is the operator's tool and needs the real
login environment. Note the consequence: a project's env entry reaches
exec, setup and daemons, but not the session — workspace exec T-1 app env
and the session's environment legitimately differ.
This is a compromise, as any answer here is. The behavior is documented rather than mysterious:
- A parent variable that is not on the allowlist silently does not reach
spawned commands. No warning is possible — the tool cannot know which of
your thousand variables mattered. The fix is
env_allow(global or per project) or an explicitenv:entry. - The
PATHheuristic recognizes common version-manager layouts (…/versions/<v>/bin,…/installs/<tool>/<v>/bin). An unrecognized layout degrades to over-keeping a segment, which reproduces pin-to-launch-time behavior — never anything worse. - Commands run under
$SHELL -lc "<command>"(fallback/bin/sh), so your login shell's own init runs first and can reintroduce environment the allowlist just removed. That is outside the tool's control; if it matters, keep environment mutation out of your login files (or out of the non-interactive branch of them). $SHELLis read from the tool's own process environment, not from the curated one, so settingSHELLinenv:changes what the child sees but not the interpreter. The practical consequence: your config's command strings are interpreted by your login shell — if that is fish,FOO=bar cmd,export FOO=barand$(…)are not valid, whatever your CI thinks. Write one-liners that work in your shell, or spell it out:sh -c '…'.- A failed run-and-wait reports the first non-empty stderr line. Under a
login shell that can be a line your
/etc/profileprinted rather than the command's own complaint;workspace logsand the command's own output are the fallback when a message looks unrelated.
start: entries are two kinds of thing, and one custom YAML unmarshaler owns
the distinction:
- a bare string is a run-and-wait:
upruns it to completion, captures its output, and reports only on failure. Nothing is logged and nothing is tracked. Use it for migrations and other preludes. - a single-key map (
name: command) is a daemon: its own process group, stdout to.workspace/logs/<project:daemon>.logand stderr to<project:daemon>.err.log(both truncated at every start), and a pid file.workspace/pids/<project:daemon>holding<pid> <starttime>. The value can also be a nested{command, description}map — same daemon, plus an optionaldescription:thatstatusandWORKSPACE.mdshow (with${…}substituted) so a session knows what the daemon is for before starting it.
Run-and-waits belong to the project, not to any one daemon: they run when
the whole project is targeted, and are skipped when you address a single daemon
— exactly as stop: is.
Liveness is pid and starttime (field 22 of /proc/<pid>/stat), which
makes it pid-reuse-proof: a recycled pid reads as not running rather than as
someone else's process. On systems without /proc the starttime records as 0
and the check degrades to pid-only. A pid file that is missing, corrupt or names
a dead process is not running — every consumer treats it identically, and
gc reaps it.
up ensures each project (worktree, .env, stamped setup) even when you
addressed a single daemon, then runs the prelude, then starts what is not
already running. started means spawned and recorded, not healthy — a
daemon that exits immediately says so in its .err.log and reads as not
running from then on. Setup is re-run when the rendered setup: lines change
(the stamp is a SHA-256 of them). new, checkout and up report the
ensure chain as it actually runs: each checkout and setup command actually
executed prints its own label… ok (0.3s) (or failed) line, with a real
duration — a project that was already checked out with current setup stays
silent, exactly as before.
down walks the dependency order backwards, and within a project its
daemons in reverse listed order. Each running daemon gets SIGTERM to its
process group, then ≤5s of polling, then SIGKILL, and the line says which
sufficed: stopped app:rails (TERM) / (KILL). Already-stopped daemons print
already stopped and are not signaled. The pid file is removed only on
confirmed death, so a failed stop leaves the record for a retry.
stopped (TERM)promises the recorded leader is dead — not every group member. A member that ignores TERM under a leader that obeys it can linger. Polling for group emptiness is deliberately absent: zombies would hang it.
stop:runs AFTER this project's daemons stop — v1 ran it before killing them. Astop:command that talks to a running daemon (a graceful drain, say) therefore behaves differently here; drain logic belongs in the daemon's own TERM handler. There is no pre-stop hook in v1.0. When the project is not checked out,stop:is skipped (stopping must not create worktrees) — loudly, if you configured any, because those commands may manage state outside the worktree.
With no explicit target, down (and destroy's stop phase, and
restart's down half) takes its inventory from the pids directory, not
from the config: a pid file is named after the key up wrote it under, so a
daemon you renamed, dropped from start:, or whose project you deleted from
the config still holds this workspace's ports while being invisible to any
config-driven walk. Those extra keys are stopped after everything
config-resolved, in alphabetical order, and only when actually live (a dead
or corrupt stray is gc's garbage, not down's work, and is passed over in
silence). An explicitly named target is still resolved through the config
alone — a name you typed must mean what config says it means.
restart is down then up over the same targets, and it converges: a
target that was already stopped just starts. The two halves are not
symmetric with no explicit target, and cannot be — the down half stops every
live recorded key, config-known or not, while the up half can only start what
start: defines. So restarting a workspace whose config no longer defines a
running daemon stops it and does not bring it back; restore the config
entry (or use up). If the pids directory cannot even be listed, the up half
is refused outright: starting daemons beside processes we cannot see would
double whatever holds these ports.
logs prints the .log only (-n, default 50). With -f it follows
both streams, raw and interleaved, unlabeled — stderr is where a dying
daemon explains itself — printing only what arrives after the follow starts. A
daemon that writes exclusively to stderr (python -m http.server, for
instance) therefore shows an empty tail; when that happens and the .err.log
is non-empty, one note points at it: (no stdout output; stderr has output — try -f).
A daemon that has never run is a note and exit 0, not a failure.
browse substitutes browse_port for this workspace, checks something
is actually listening on the port (a quick TCP dial — the socket is asked
directly, so a hand-started server counts and a daemon that died during
boot does not), and opens http://localhost:<port> with xdg-open,
detached. Nothing listening is a refusal that names the workspace up to
run and hands over the URL for when the app serves. With no xdg-open on
PATH it prints the URL and exits 0 — on a remote box, printing is the
feature. With one project checked out it needs no argument; with several it
asks you to pick.
Daemons get their own process group (that is what makes group stop
possible) but deliberately not their own session — no setsid. They are
released by the CLI and reparented to init, with stdio already redirected to
their log files.
workspace claude DEMO-1 # session in the workspace dir
workspace claude DEMO-1 -S # ... without --dangerously-skip-permissions
workspace claude DEMO-1 -R # ... without --continue
workspace claude DEMO-1 --model opus # anything else goes to claude
workspace launch DEMO-1 "try the tool" demo -- --model opusBoth disable flag parsing: every flag except the tool's own two belongs to Claude, and the injection rules are:
--dangerously-skip-permissionsis injected unless you passed-S/--claude-no-skip-permissions, or took your own permission stance (--permission-mode[=…], or the flag itself).--continueis injected only when a conversation already exists for this workspace directory, and not when you passed-R/--claude-no-resume, not in print mode (-p/--print), and not when you passed your own resume flag (-c,--continue,-r,--resume[=…],--from-pr[=…]).
Everything after a literal -- is Claude's, verbatim — including a later --,
and including strings spelled exactly like -S/-R. The tool's flags are only
recognized before the first --. The workspace identifier must come first:
with flag parsing off, workspace claude --json DEMO-1 would otherwise resolve
a workspace literally named --json, so a leading flag-looking token is a usage
error (exit 2) that says so.
Three sharp edges worth knowing:
- The history probe. Whether a conversation exists is decided by looking for
~/.claude/projects/<encoded dir>/*.jsonl, where the encoding maps every non-alphanumeric byte to-(verified empirically against real Claude Code state, not guessed:/home/cat/claude-workspaces/PATADM_patternima-admin-panel→-home-cat-claude-workspaces-PATADM-patternima-admin-panel, underscore included). Both the directory as recorded and its symlink-resolved form are probed, so a workspaces root reached through a symlink still finds its history. Every failure to look is "no history", and the safe failure direction is a fresh session instead of--continue— never the reverse. - Bundled short flags are invisible to the injection detector. It compares
whole tokens, so
-cpis not seen as-c+-p:--continuewould be injected alongside your-c. Write short flags separately (-c -p) when you care. exec's project sniff, and the--rule. Inworkspace exec <ws> [project] <cmd…>the argument right after the workspace is the project if (and only if) it names a configured project; otherwise it is the command. To run a command that happens to be named like a project, put--in that one slot (workspace exec T-1 -- app) or give a path (./app).--is the sniff suppressor only in that position: a later one belongs to your command and is passed through untouched, soworkspace exec T-1 app git checkout -- READMEstill restores a file.
Titles. A session names its terminal (OSC escape, when stdout is a
terminal, first 40 characters of the workspace name) and, inside tmux, the
current window (tmux rename-window, first 20 characters of the workspace
name) — and un-sets the window's automatic-rename when the session ends, so
tmux auto-naming resumes exactly where it left off. Best-effort: no tmux, no
tty, no problem.
launch composes the daily entry sequence — create-or-reuse, check out, then
hand over the terminal — by calling the same work functions the individual
commands use, so it cannot drift from them. Any phase that fails stops the
sequence, so a session never opens onto a half-built environment. Daemons
are lazy: launch starts none of them, on either the create or the reuse
path, and reuse no longer converges a dead one back to running — workspace up <ws> [target…] is the explicit start, and the skill/session is expected to
call it for whatever it actually needs. Reuse ignores a supplied description
silently (the using existing workspace <name> line is the notice), and
positional 2 is always the description slot on both paths — so when it
happens to name a configured project, a note says what became of it, because
you almost certainly meant launch <id> <desc> <project…>. On the create
path, launch does not print new's hint: workspace cd <id> — the terminal
is about to become the session's, so a cd hint would read as a stale
to-do — and instead prints tip: in another terminal: workspace cd <id> — work alongside this session. The reuse path prints neither.
The installed skill (~/.claude/skills/claude-workspaces/SKILL.md) is
the session-facing interface — "work on FIZZY-123" turns into
new/checkout/up and a cd. The SessionStart hook adds identity and
status as context and never mutates. WORKSPACE.md holds the task, the
allocated values, the per-project instructions, and each project's daemons
and what they're for; CLAUDE.md is created
once with a single reference line to it and never rewritten, so notes
accumulated there survive every regeneration.
Four commands, and one rule that runs through all of them: the tool never deletes a directory it did not create.
adopt [dir]gives an existing tree an allocation — an index, values, a.envper project,WORKSPACE.md,CLAUDE.mdif absent — and records it as adopted. It creates no worktrees, clones nothing, moves nothing, and runs no setup. The task id is the directory's base name, verbatim (it must be a valid id — rename the directory rather than have the name on disk and the name in the tool disagree). Projects are detected from git worktree metadata unless--projectsreplaces the detected set outright. Adopting the workspaces root itself is refused.release [dir]drops the allocation and touches no files — not even for a tool-created workspace. It is the "this is mine now, stop managing it" escape hatch, and it is idempotent (a miss is exit 0). It refuses while any daemon is running: the allocation is what makes the workspace addressable and its index exclusive, so releasing it under a live daemon would strand the process twice over —down <name>could no longer resolve it, and the freed index would be handed to the nextnew, whose daemons would collide on the same ports. Stop them first.destroy [--force] <ws>isdown(the whole workspace, pids directory included) →teardownper checked-out project in reverse dependency order → remove worktrees → remove the dir → release. A stop or teardown failure aborts everything and removes nothing, so re-running converges. A project's first failing teardown command stops that project's teardown; the remaining projects still run theirs (convergence over strict ordering — teardown commands are expected idempotent). Worktree removal discards the working copy but never the branch: a branch is your work. An adopted workspace getsdown+teardown+releaseonly, with its files left exactly where they are.--forceis narrow on purpose: it downgrades worktree-removal failures to warnings (then prunes best-effort), which is the escape hatch for a source repo that moved or was deleted. It does not skip safety — a corrupt registry entry pointing outside the root is still refused, and live daemons and failing teardowns still abort.--forceis also how you finish the one documented exception tonew's "leaves nothing behind": ifnew's undo cannot remove a worktree it stops there deliberately, leaving the workspace dir and its allocation in place — addressable state beats an orphan git still has bookkeeping for.gc [-d]is the batch sweep, in up to three passes: release every allocation whose dir has vanished; reap stale pid files — every record in the pids directory, dead or corrupt alike, live ones untouchable; and with-d, destroy what is provably collectable. One workspace's failure never abandons the rest, and a batch failure is exit 1 (per-workspace codes are meaningless once several failures are one error). Note that an unreadable dir is not a vanished dir, and an unreadable pids directory is a loud per-workspace error, never an assumed quiet.
What survives gc --destroy-dirs. A workspace is destroyed only on
evidence — every gate must say yes:
| Gate | Destroyed when | Survives when |
|---|---|---|
| Ownership | tool-created | adopted (never deleted, silently) |
| Content | ≥ 1 project checked out | nothing checked out — an empty workspace has no branches, hence no merge evidence at all. A half-destroyed workspace reads as empty here too: finish it with destroy (or destroy --force). |
| Daemons | nothing in the pids directory names a live process | any live record, config-known or not; an unreadable pids dir is an error and a skip |
| Merged | every checked-out project's actual branch (read from the worktree, whatever it is named) is fully merged into its base | any unanswerable merge question — unreadable branch, missing base, moved repo, detached HEAD — reads as not merged |
| Clean | no checked-out worktree is dirty (modified or untracked) | dirty, and loudly: skipped <name> (uncommitted changes). destroy keeps the power to discard uncommitted work; a batch sweep does not. |
The base for the merge check is the project's base_branch, or — when that is
empty — the source repo's own HEAD branch at gc time, mirroring what
checkout branched from. (The branch is compared refs/heads-qualified while the
base is taken unqualified: a documented asymmetry.) The clean check is the same
predicate ls -g renders — which is why an untracked .env in a repo that does
not gitignore it makes gc -d collect nothing, tool-wide. One definition of
dirty, and the fix lives in the repo.
A live Claude session does not protect a workspace. The daemon gate reads
pid files, and a session (or a plain shell) sitting in the directory writes
none — so a merged, clean, daemonless workspace can be collected out from under
one. A session pid marker is post-1.0 work; until then, gc -d is a deliberate
act, and an unmerged or dirty workspace is safe by the gates above.
doctor reports and never fixes (gc and down fix). It prints the config
verdict, the configured projects and value blocks, then: stale allocations
(dir gone), allocations outside the root — informational for an adopted
workspace, a finding for a tool-created one, which is a corrupt or hand-edited
registry and something destroy will refuse — configured projects whose repo
does not exist, and per-workspace daemon health from the pids directory (live
counts, stale records, keys the config cannot name). It ends with
doctor: N finding(s) or doctor: no findings and exits 0 regardless:
findings are observations, not failures. Only an invalid config (exit 4) and an
unreadable registry (exit 1) are errors. --json gives findings and
informational arrays with a stable kind per entry.
Consolidated, for anyone coming from the Ruby tool — "v1" throughout this
README is an earlier personal Ruby tool of the same shape, never publicly
released, whose documented behavior served as the oracle for this clean-room
rewrite. This is a new tool with
a new command surface and new on-disk formats, not a port: live v1 workspaces
are not migrated (they finish on Ruby), and the two coexist — the skill here is
installed under its own name, claude-workspaces.
- Dropped commands:
archive/unarchive(a workspace with daemons down and its allocation released is archived; re-provisioning isadopt),resolve(there is no broken state to resolve),setupas a command (it is an ensure-step insidecheckoutandup),welcome(folded into--help),prune(→gc). Also gone:command_runner— environment curation replaces it.titlereturned in v1.2 as automatic behavior onclaude/launch(tmux window + terminal title, restored on exit) — the first dropped command daily use proved missed. - Exit codes are meaningful: 0/1/2/3/4 instead of 1 for everything.
stop:runs after the project's daemons stop, not before.launchno longer starts daemons. v1launchbrought the whole workspace up as part of the one-shot; v1.1launchstarts none, on either the create or the reuse path, and reuse no longer converges a dead daemon back to running either —workspace up <ws> [target…]is the explicit start (daemons are lazy).execruns under the curated environment. v1'sexecleaked the parent environment; here there is no inherit path to leak through.- State: one registry,
<root>/.allocations.json(JSON, per-root, flock'd, atomically written), and nothing else. Everything else is derived on demand: no status machine, no "broken workspace", no recorded project list. WORKSPACE.md+CLAUDE.md-once: regeneration only ever rewritesWORKSPACE.md, so agent notes inCLAUDE.mdsurvive (v1 lost them).- YAML is stricter: unknown keys are errors with positions, and unquoted
${…}inside flow collections is rejected — Ruby's Psych tolerated it. Error positions refer to the regenerated document when templates are in play. - A pre-existing non-empty directory at a project's destination now errors
loudly (v1 succeeded silently). An empty directory is adopted by
git worktree add, as git does. stopped (TERM/KILL)promises leader death only (see Services).- A workspace directory reached through a symlink reads as "not checked
out", and
whichcompares paths as written (noEvalSymlinks) — the registry records a spelling, and the shell wrappercds to the spelling the tool printed, so the two agree by construction. Reaching the same directory by another route is honestly reported as "not inside a workspace". (The Claude history probe is the one place that tries both spellings, because Claude Code records whichever it ran in.) - Non-goals carried from the design: no Docker runtime, no Windows, and no
profiles (named
start:subsets, e.g.test= databases only) in 1.0.
go test ./... -count=1 # table tests + testscript command flows
gofmt -l . # must print nothing
go vet ./...
CGO_ENABLED=0 go build -ldflags "-X github.com/Phaengris/claude-workspaces/internal/cli.version=$(git describe --tags --always)" -o workspace ./cmd/workspaceRuntime dependencies are deliberately three, four modules with pflag (which is
cobra's own): spf13/cobra for the CLI and the completion generators,
goccy/go-yaml for YAML with positions in its errors, golang.org/x/sys for
flock. Test-only: rogpeppe/go-internal/testscript. go version -m on a
built binary is the check.
Layout: cmd/workspace is a thin main (error → exit code); internal/cli is
the cobra tree, one file per command; the domain packages are config (strict
load, templates, validation), alloc (the registry, locking, index assignment,
values math), wsp (workspace operations, derived status, stamps, the writers,
the curated spawn env), proc (run-and-waits, daemons, liveness, kill
escalation); the leaves depend on nothing internal: envx (allowlist, PATH
sanitizer), gitx (argv-form git only, never a shell), ui (human/JSON
rendering), xerr (error kinds → exit codes). Assets under assets/ are
embedded with //go:embed. Config is loaded once and passed down explicitly —
no package-level singletons.
Tests are two layers: table tests for the pure logic (template expansion,
values math, start-entry unmarshal, allowlist and PATH sanitizer, claude-flag
extraction, index gap-filling, topo sort) and testscript scripts under
internal/*/testdata for command flows, each in a tmpdir root isolated by
CLAUDE_WORKSPACES_ROOT_DIR. Install/uninstall tests only ever run against a
fake $HOME.
This tool is itself a product of the workflow it serves. I designed and specified it and own every product and architecture decision in it — what it does, what it refuses to do, and why; the implementation was AI-driven (Claude Code, end to end) under per-task independent review, as a spec-driven agentic project. The paper trail is checked in — the commit trailers say who typed, the specs and decided- behaviors tables say who decided.
- Spec first: the design was brainstormed and pinned in
docs/superpowers/specs/before any code — every decided behavior has a written row to point at. - Milestone plans: implementation ran in six milestones
(
docs/superpowers/plans/), each plan carrying binding contracts, named test cases, and a decided-behaviors table. - Subagent-driven execution: each task was implemented by a fresh AI subagent against its plan brief, then gated by an independent AI review (spec compliance + code quality), with whole-branch final reviews before each merge. Findings were fixed in review loops, not waved through.
- TDD with mutation-checked pins: failing test first, and load-bearing assertions are verified to actually fail when the behavior they pin is broken (the commit messages say when).
- Human owner in the loop: design decisions, reviews of the reviews, and every release call were made by a human; the git history's commit trailers record the division of labor honestly.
Post-1.0 changes follow the same loop in miniature — the lazy-daemons feature (v1.1.0) went brainstorm → spec → plan → subagent implementation → reviews → release in one session, driven by the first day of real-world use.