CLI tool (workspace) managing isolated dev workspaces for parallel Claude Code
sessions: a git worktree per project, an allocated port block, generated env,
and daemons — an environment engine. Clean-room Go rewrite (v1.1.x, complete,
daily-driven) of an earlier personal Ruby tool, never publicly released and
since retired; never consult or port Ruby code — the spec and this repo
are the only sources.
Module path is github.com/Phaengris/claude-workspaces — the public home.
MIT-licensed.
CGO_ENABLED=0 go build -o ./workspace ./cmd/workspace # build (binary is gitignored)
go test ./... # full suite (~11s; real processes, real git)
go test -race ./internal/proc ./internal/wsp ./internal/cli
gofmt -l . && go vet ./... # both must be clean before commit
# landing a change: commit, push master to BOTH remotes (origin = private mirror, github = public),
# add its CHANGELOG entry under "Unreleased". Do NOT tag per change.
# daily-driver install between releases: build with
# -ldflags "-X github.com/Phaengris/claude-workspaces/internal/cli.version=$(git describe --tags --always)"
# then ./workspace install — the user runs master, tags are for the world.
# release (end of a day with user-visible changes, or on explicit need):
# retitle Unreleased to X.Y.Z in CHANGELOG.md, build with version=X.Y.Z,
# ./workspace install, tag vX.Y.Z, push master+tag to both remotes.
Conventional commits (feat(cli): …). The user's real install lives at
~/.local/bin/workspace — after user-facing fixes, rebuild with a bumped
version and run ./workspace install (idempotent, manifest-driven).
cmd/workspace → internal/cli (one file per command; cobra)
→ internal/wsp workspace domain: identity/Resolve, ensure-chain, writers,
daemon model, ResolveTargets grammar, CommandEnv (SOLE
spawn-env composer), runtime ${} substitution
→ internal/config strict YAML (goccy), template expansion on the raw tree,
validation (errors.Join, all-at-once)
→ internal/alloc .allocations.json registry: flock + atomic write, index
gap-filling, values math (alloc.Block is the ONE formula home)
→ internal/proc LEAF. $SHELL -lc spawn, daemons (Setpgid), pid+starttime
liveness, TERM→KILL StopGroup (leader-only)
→ internal/gitx LEAF. argv-form git only; GIT_DIR-family env neutralized
→ internal/envx LEAF. curated env: allowlist + sanitized PATH + env_allow
→ internal/xerr exit-code kinds: 0/1/2 usage/3 not found/4 config; ExitError
carries a child's code verbatim (checked before kinds)
internal/assets ← assets/ (//go:embed carrier; skill, hook, wrappers, config stub)
- Derive, don't record: the registry holds only allocations; everything
else (checked out, setup-current, running) is derived live. No status field.
Every op is an idempotent ensure; re-run converges.
newis the one transaction (LIFO undo; halts at a failed worktree undo). - Two-tier env: user commands (setup/start/stop/teardown/exec) get the
CURATED env —
wsp.CommandEnv→envx.Curated, exact-name allowlist +env_allow+ sanitized PATH (concrete/versions|installs/.../binstripped, shims kept). Claude sessions get sanitized-INHERITED env + workspace overlay.proc.Run/StartDaemonare the only user-command spawn paths; nil env means empty, never parent. - Destruction safety, layered: config rejects escaping
path:; every WorktreeRemove/RemoveAll is containment-gated (isAncestorOrSame, root Abs'd); adopted dirs are never deleted; teardown/stop failures abort before removal; gc -d's five gates (tool-created, ≥1 checked out, all merged via refs/heads-qualified IsMerged, no live pids-dir record, clean incl. Err). Doubt always reads as "keep". - pids directory is truth for "what runs": down/restart(no-target)/destroy/
gc/release/doctor enumerate
wsp.PidFileKeys, not config keys. Liveness = kill-0 AND starttime match (zombies = dead). StopGroup refuses non-leaders; "stopped (TERM)" promises leader death only. - Completions never break the shell: any error → nil + NoFileComp.
- Uninstall removes EXACTLY the manifest (survivors manifest on failures); install never edits settings.json/rc files — prints snippets.
- TDD; table tests for pure logic; testscript txtar for command flows
(internal/cli/testdata/). Shared txtar helpers in cli_test.go:
wsenv,workroot(WORKROOT/WORKDIR substitution +! grep WORKROOTguard). - Real processes (bounded
sleep 30, explicitdownepilogues, one 5s KILL-escalation case per package) and real git repos (hermetic: GIT_CONFIG_GLOBAL/SYSTEM=/dev/null + author env). PATH shims for git/claude/ xdg-open — a realclaudemust never be invocable from tests. - Culture: mutation-check load-bearing pins (state it in the commit/PR);
exact exit codes pinned in Go tests (txtar
! execis only "non-zero"). - testscript gotcha:
$VARexpands in UNQUOTED chunks only — absolute-path asserts need'…'$WORK'…'.
- goccy rejects unquoted
${…}inside flow-style YAML collections — block style in all examples. - Claude project-dir encoding: EVERY non-alphanumeric byte →
-(empirical; probe in claude.go).${WORKSPACE}= task id, NOT the dir name. --in session commands is the sniff-suppressor ONLY at the slot after the workspace; later--reaches the child verbatim.- The public docs never mention v1/the Ruby predecessor beyond one line in
README's "How this was built" — readers have no v1. Behavior details that
used to live in the v1-divergences appendix are plain facts in
docs/reference.md now (biggest:
stop:runs AFTER daemons stop).
docs/superpowers/specs/2026-07-30-claude-workspaces-go-design.md— THE spec; decided behaviors live here and in each milestone plan's Decided-behaviors table (docs/superpowers/plans/*-m[0-5]-*.md).- Ideas/backlog now live on the user's Fizzy board named
claude-workspaces(internal tracker; find it viafizzy board list— never put its URL in this public repo). The md backlog below is historical. docs/superpowers/plans/2026-08-10-m5-deferred-items.md— the post-v1.0 backlog + standing decisions (do not re-litigate without cause).README.md— the front door: short sentences, primary info only, links out.docs/reference.md— the deep contracts behind it. BOTH are accuracy-audited against the code; if behavior changes, whichever documents it changes in the same commit (README for headline behavior, reference for details).