treebox 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {treebox-0.6.0 → treebox-0.7.0}/.agents/skills/treebox/SKILL.md +2 -2
  2. {treebox-0.6.0 → treebox-0.7.0}/.github/workflows/ci.yml +3 -2
  3. {treebox-0.6.0 → treebox-0.7.0}/CLAUDE.md +105 -6
  4. {treebox-0.6.0 → treebox-0.7.0}/CONTRIBUTING.md +17 -0
  5. {treebox-0.6.0 → treebox-0.7.0}/PKG-INFO +6 -5
  6. {treebox-0.6.0 → treebox-0.7.0}/README.md +5 -4
  7. {treebox-0.6.0 → treebox-0.7.0}/ROADMAP.md +2 -2
  8. {treebox-0.6.0 → treebox-0.7.0}/docs/configuration.md +11 -3
  9. {treebox-0.6.0 → treebox-0.7.0}/docs/how-it-works.md +18 -2
  10. {treebox-0.6.0 → treebox-0.7.0}/docs/index.md +10 -7
  11. {treebox-0.6.0 → treebox-0.7.0}/docs/install.md +18 -13
  12. {treebox-0.6.0 → treebox-0.7.0}/docs/usage.md +6 -1
  13. treebox-0.7.0/scripts/golden-diff.sh +168 -0
  14. {treebox-0.6.0 → treebox-0.7.0}/scripts/validate.sh +6 -2
  15. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/cli.py +168 -155
  16. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/config.py +12 -8
  17. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/git.py +4 -2
  18. treebox-0.7.0/src/treebox/harnesses.py +140 -0
  19. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/models.py +8 -0
  20. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/output.py +13 -10
  21. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/provision.py +19 -11
  22. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/resolve.py +2 -2
  23. treebox-0.7.0/src/treebox/runners/__init__.py +60 -0
  24. treebox-0.7.0/src/treebox/runners/base.py +111 -0
  25. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/runners/docker.py +146 -129
  26. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/runners/host.py +28 -25
  27. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/state.py +3 -1
  28. treebox-0.7.0/src/treebox/system.py +29 -0
  29. treebox-0.7.0/tests/golden/README.md +19 -0
  30. treebox-0.7.0/tests/golden/create-docker-json.txt +24 -0
  31. treebox-0.7.0/tests/golden/create-docker-print.txt +22 -0
  32. treebox-0.7.0/tests/golden/create-dryrun-docker-json.txt +22 -0
  33. treebox-0.7.0/tests/golden/create-dryrun-docker.txt +21 -0
  34. treebox-0.7.0/tests/golden/create-dryrun-host-json.txt +18 -0
  35. treebox-0.7.0/tests/golden/create-dryrun-host.txt +17 -0
  36. treebox-0.7.0/tests/golden/create-host-json.txt +17 -0
  37. treebox-0.7.0/tests/golden/create-host-print.txt +17 -0
  38. treebox-0.7.0/tests/golden/doctor-docker-json.txt +52 -0
  39. treebox-0.7.0/tests/golden/doctor-docker.txt +19 -0
  40. treebox-0.7.0/tests/golden/doctor-host-json.txt +52 -0
  41. treebox-0.7.0/tests/golden/doctor-host.txt +18 -0
  42. treebox-0.7.0/tests/golden/doctor-no-login.txt +18 -0
  43. treebox-0.7.0/tests/golden/enter-docker-json.txt +24 -0
  44. treebox-0.7.0/tests/golden/enter-docker-print.txt +14 -0
  45. treebox-0.7.0/tests/golden/enter-host-json.txt +17 -0
  46. treebox-0.7.0/tests/golden/enter-host-print.txt +12 -0
  47. {treebox-0.6.0 → treebox-0.7.0}/tests/test_integration.py +13 -7
  48. {treebox-0.6.0 → treebox-0.7.0}/tests/test_units.py +286 -170
  49. treebox-0.6.0/src/treebox/runners/__init__.py +0 -19
  50. treebox-0.6.0/src/treebox/runners/base.py +0 -81
  51. treebox-0.6.0/src/treebox/system.py +0 -44
  52. {treebox-0.6.0 → treebox-0.7.0}/.github/workflows/autofix.yml +0 -0
  53. {treebox-0.6.0 → treebox-0.7.0}/.github/workflows/claude.yml +0 -0
  54. {treebox-0.6.0 → treebox-0.7.0}/.github/workflows/docs.yml +0 -0
  55. {treebox-0.6.0 → treebox-0.7.0}/.github/workflows/release.yml +0 -0
  56. {treebox-0.6.0 → treebox-0.7.0}/.gitignore +0 -0
  57. {treebox-0.6.0 → treebox-0.7.0}/.pre-commit-config.yaml +0 -0
  58. {treebox-0.6.0 → treebox-0.7.0}/AGENTS.md +0 -0
  59. {treebox-0.6.0 → treebox-0.7.0}/CHANGELOG.md +0 -0
  60. {treebox-0.6.0 → treebox-0.7.0}/LICENSE +0 -0
  61. {treebox-0.6.0 → treebox-0.7.0}/assets/treebox-demo.gif +0 -0
  62. {treebox-0.6.0 → treebox-0.7.0}/assets/treebox-logo.png +0 -0
  63. {treebox-0.6.0 → treebox-0.7.0}/docs/agents.md +0 -0
  64. {treebox-0.6.0 → treebox-0.7.0}/docs/assets/treebox-logo.png +0 -0
  65. {treebox-0.6.0 → treebox-0.7.0}/docs/javascripts/treebox.js +0 -0
  66. {treebox-0.6.0 → treebox-0.7.0}/docs/stylesheets/extra.css +0 -0
  67. {treebox-0.6.0 → treebox-0.7.0}/hooks/copy_page.py +0 -0
  68. {treebox-0.6.0 → treebox-0.7.0}/install.sh +0 -0
  69. {treebox-0.6.0 → treebox-0.7.0}/mkdocs.yml +0 -0
  70. {treebox-0.6.0 → treebox-0.7.0}/pyproject.toml +0 -0
  71. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/__init__.py +0 -0
  72. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/Dockerfile +0 -0
  73. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/allowed-domains.sh +0 -0
  74. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/container.json +0 -0
  75. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/firewall.json +0 -0
  76. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/init-firewall.sh +0 -0
  77. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/container/post-create.sh +0 -0
  78. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets/pre-push +0 -0
  79. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/assets.py +0 -0
  80. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/ecosystems.py +0 -0
  81. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/forge.py +0 -0
  82. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/locking.py +0 -0
  83. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/names.py +0 -0
  84. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/py.typed +0 -0
  85. {treebox-0.6.0 → treebox-0.7.0}/src/treebox/status.py +0 -0
  86. {treebox-0.6.0 → treebox-0.7.0}/tests/conftest.py +0 -0
  87. {treebox-0.6.0 → treebox-0.7.0}/tests/test_contract.py +0 -0
  88. {treebox-0.6.0 → treebox-0.7.0}/uv.lock +0 -0
@@ -26,7 +26,7 @@ Provisioning (fetch → resolve branch → worktree add → copy submodules →
26
26
  ## Command reference
27
27
 
28
28
  - `create [NAME]` — provision and launch. Flags: `--base <branch>` `--checkout <existing-branch>` `--isolation host|docker` `--harness claude|codex` `--template <name>` `--cold` `--no-fetch` `--firewall/--no-firewall` `--dry-run` `--print` `--json` `--repo <path>` `--root <dir>` `--quiet` `--verbose`.
29
- - `enter REF [-- AGENT_ARGS…]` — re-launch in an existing worktree; refreshes `.env`, re-syncs deps only if the lockfile changed. Flags: `--isolation` `--harness` `--template <name>` `--cold` `--print` `--json` `--repo` `--root` `--quiet` `--verbose`.
29
+ - `enter REF [-- AGENT_ARGS…]` — re-launch in an existing worktree; refreshes `.env`, reuses recorded isolation/firewall/harness/template defaults, and re-syncs deps only if the lockfile changed. Flags: `--isolation` `--harness` `--template <name>` `--cold` `--print` `--json` `--repo` `--root` `--quiet` `--verbose`.
30
30
  - `list` (alias `ls`) — table of worktrees (branch · isolation · deps freshness · `.env` presence · directory). Flags: `--repo` `--root` `--json`.
31
31
  - `teardown [REF...]` (alias `rm`) — remove selected worktrees; caches are left intact. Flags: `--force` `--delete-branch` `--remove-volumes` `--skip-container` `--json` `--isolation` `--repo` `--root` `--quiet` `--verbose`.
32
32
  - `doctor` — check git, login credentials, UID/GID, and isolation deps. Flags: `--isolation` `--repo` `--json`.
@@ -81,7 +81,7 @@ With `--json`, a failure prints **one object on stderr**: `{"schemaVersion":3,"e
81
81
  - `create` launches the agent (Claude by default) after provisioning and exits with the agent process's exit code. Use `--harness codex`, or `--print` / `--json` / `--dry-run` to avoid launching.
82
82
  - Re-running `create` on a fully provisioned same-name worktree is a `SLUG_CONFLICT` (exit `5`), not an idempotent enter. Use `enter` to re-launch. If the dir exists but setup never finished (a prior run died mid-provision), `create` resumes setup instead of launching into a half-built tree.
83
83
  - **Freshness is enforced.** `create` *requires* a successful `git fetch origin` and branches from the freshly-fetched `origin/<base>` (preferred over a stale local branch). If the fetch fails it exits `4` and does **not** fall back to stale refs. In a terminal, git/ssh will prompt for credentials (no pre-loaded ssh-agent needed); headless/`--json` runs need working non-interactive auth. Pass `--no-fetch` only when the user explicitly accepts possibly-stale local refs (e.g. offline). A repo with no `origin` skips the fetch. Run `doctor` to check origin reachability up front.
84
- - `enter` recomputes the dependency-manifest hash and re-syncs only when deps changed; it always refreshes `.env`. Pass extra agent args after `--`. `--cold` bypasses the shared cache.
84
+ - `enter` recomputes the dependency-manifest hash and re-syncs only when deps changed; it always refreshes `.env`. It reuses the worktree's recorded isolation/firewall/harness/template defaults, so config changes do not silently change an existing worktree. A mismatched explicit `--isolation` is a conflict; explicit `--harness` and `--template` are per-session overrides. Pass extra agent args after `--`. `--cold` bypasses the shared cache.
85
85
  - `--cold` is the escape hatch for a corrupted cache: from-source dependency resolution.
86
86
  - `teardown` confirms interactively. Without a TTY (scripts/agents) or with `--json` it refuses unless `--force` (exit `5`); `--force` is also required to remove a worktree with uncommitted changes. `--json` prints a record of what was removed to stdout. `--skip-container` leaves containers/images alone; cleanup failures are best-effort (`container: "failed"`) and do not undo worktree removal. With host isolation there are no containers/volumes to remove.
87
87
  - Only one `create`/`enter`/`teardown` may operate on a given worktree name at a time (a per-name lock, exit `5` if held). Wait rather than retrying immediately.
@@ -50,7 +50,8 @@ jobs:
50
50
  with:
51
51
  enable-cache: true
52
52
 
53
- # Full reproducible validation: lint, tests, and a live `treebox create`
54
- # against a throwaway local git remote.
53
+ # Full reproducible validation: lint, type checks, shell asset checks,
54
+ # tests, golden CLI snapshots, and a live `treebox create` against a
55
+ # throwaway local git remote.
55
56
  - name: validate.sh
56
57
  run: ./scripts/validate.sh
@@ -25,19 +25,34 @@ uv run --extra dev ruff check src tests # lint
25
25
  uv run --extra dev ruff format --check src tests # format check (drop --check to apply)
26
26
  uv run --extra dev mypy # strict type check (config in pyproject.toml)
27
27
  uv run --extra dev pre-commit install # install lint/format/strict type-check hooks (see CONTRIBUTING.md)
28
- ./scripts/validate.sh # lint + format + tests + live host-runner smoke (real uv, throwaway local remote)
28
+ scripts/golden-diff.sh # diff CLI output against tests/golden snapshots
29
+ ./scripts/validate.sh # full gate: lint + typecheck + shell assets + tests + snapshots + smoke
29
30
  uv pip install -e ".[dev]" # editable dev environment
30
31
  uv run --extra docs mkdocs serve # docs site (docs/ + mkdocs.yml), live-reloading
31
32
  uv run --extra docs mkdocs build --strict # build docs to site/ (gitignored)
32
33
  ```
33
34
 
34
35
  Run `validate.sh` before changes that affect provisioning, runners, git
35
- handling, or CLI output; `pytest` is enough for smaller changes.
36
+ handling, shell assets, or CLI output; it includes the golden CLI-output
37
+ snapshots and shell asset checks. Use `scripts/golden-diff.sh --update` only
38
+ after an intentional output change has been agreed. `pytest` is enough for
39
+ smaller changes.
36
40
 
37
41
  ## Architecture
38
42
 
39
43
  The whole tool is organized around one seam: **provision (always host-side) vs.
40
- run (pluggable)**.
44
+ run (pluggable)** — with three registries that define everything swappable.
45
+
46
+ **The three-seam glossary** (each term is one axis, one module, one registry):
47
+
48
+ - **Harness** — *which agent CLI launches* (`claude`, `codex`).
49
+ `harnesses.py`: the `Harness` dataclass + `HARNESSES` registry.
50
+ - **Runner / isolation** — *where it executes* (`host`, `docker`).
51
+ `runners/`: the `Runner` protocol + `RUNNERS` registry.
52
+ - **Ecosystem** — *what setup runs* (uv, npm, pnpm, go, cargo).
53
+ `ecosystems.py`: the `Ecosystem` dataclass + `ECOSYSTEMS` registry.
54
+
55
+ Module map:
41
56
 
42
57
  - **`provision.py`** owns the host-side half, identical for every runner:
43
58
  `fetch → resolve branch → git worktree add → install pre-push guard
@@ -46,17 +61,32 @@ run (pluggable)**.
46
61
  `treebox/<name>` placeholder made un-pushable by a per-worktree pre-push
47
62
  hook (`extensions.worktreeConfig` + `core.hooksPath` into the private git
48
63
  dir); `create --checkout <existing-branch>` is the only path that skips it.
64
+ - **`harnesses.py`** is the one place agent-CLI wiring lives: each `Harness`
65
+ hides its autonomous launch argv, host login dir, staged login files, and
66
+ login advice behind a small method interface; `VALID_HARNESSES` and the
67
+ doctor login rows derive from `HARNESSES`. Boundary values (CLI/TOML/state)
68
+ stay `str` and are resolved to the object once, in `cli.py`.
49
69
  - **`runners/base.py`** defines the `Runner` protocol — the *only* thing that
50
70
  differs between modes — implemented by **`runners/host.py`** (setup + agent in
51
71
  the worktree shell) and **`runners/docker.py`** (plain `docker build/run`,
52
72
  setup via a baked-in `post-create.sh`, agent via `docker exec`; the worktree
53
73
  and its git common dir are bind-mounted at their host paths so in-container
54
- git just works). `HARNESS_COMMANDS` maps `claude`/`codex` to their
55
- fully-autonomous launch argv.
74
+ git just works). `runners/__init__.py` holds the `RUNNERS` registry;
75
+ `get_runner` and `VALID_ISOLATION` derive from it. Doctor-facing vocabulary
76
+ (preflight detail, whether a login is a hard gate) lives in `RunnerFacts`,
77
+ not in the run methods; teardown options (docker's `remove_volumes`) arrive
78
+ at the runner's constructor, never through the protocol.
56
79
  - **`cli.py`** (Typer) is the entry point: `create [NAME] / enter <ref> /
57
80
  list / teardown <ref>... / doctor / version` (`ls`/`rm` are hidden aliases
58
81
  of list/teardown). `enter`/`teardown` resolve a ref as
59
82
  name → live branch → unique substring (`resolve.py`); ambiguity exits 2.
83
+ `create` and `enter` share `_run_session` for runner preflight, the
84
+ per-name lock, provision error classification, and the final
85
+ `--json`/`--print`/launch fork. `_reconcile_with_state` folds recorded
86
+ creation-time choices into existing-worktree sessions before the runner and
87
+ harness objects are resolved: recorded isolation conflicts with a mismatched
88
+ explicit `--isolation`, while recorded firewall/harness/template protect an
89
+ existing worktree from config-default drift.
60
90
  It enforces **stable exit codes** (`0` ok · `1` runtime/doctor hard-check ·
61
91
  `2` usage · `3` not-found · `4` auth/fetch · `5` conflict) and **`--json`**
62
92
  output carrying a `schemaVersion` that only gains fields within a version —
@@ -68,7 +98,9 @@ run (pluggable)**.
68
98
  - **`state.py`** stores per-worktree state (lockfile hash + provisioning
69
99
  choices) inside the worktree's private git dir (`.git/worktrees/<id>/`), so it
70
100
  never appears in `git status` and is pruned with the worktree. The lockfile
71
- hash is what lets `enter` re-sync only when deps changed.
101
+ hash is what lets `enter` re-sync only when deps changed; the recorded
102
+ choices are what let `enter`/`teardown` recover the worktree's created-time
103
+ isolation, firewall, harness, and template defaults.
72
104
  - **`models.py`** holds the `Worktree` value object and the name-as-identity
73
105
  rule: the *name* is the directory leaf and lock key, never renamed; the
74
106
  *branch* is a mutable attribute read live from git (the agent renames it
@@ -84,6 +116,73 @@ run (pluggable)**.
84
116
  and rendered into a host-side dir *beside* the worktree, never inside the
85
117
  mount, so a boxed agent cannot edit the config that defines its own sandbox.
86
118
 
119
+ ## Extending treebox
120
+
121
+ **Adding a harness** (a new agent CLI):
122
+
123
+ 1. One `Harness` entry in `harnesses.py`'s `HARNESSES` registry — name,
124
+ autonomous argv, host login dir, staged login files. If the new CLI's
125
+ credentials are *behavior* rather than plain files (generated settings, a
126
+ non-file auth store), override the relevant methods instead of growing
127
+ branches in callers.
128
+ 2. Add the harness name to `config.py`'s hard-coded `Harness` Literal alias.
129
+ The drift test `test_registry_vocabularies_cannot_drift` must keep passing:
130
+ the alias, `HARNESSES`, and `VALID_HARNESSES` must name the same set.
131
+ 3. One operator-template stanza — a `Dockerfile` install line and a
132
+ config-dir env line in `container.json`. This half is deliberately
133
+ template territory (see the `assets.py` security model): treebox never
134
+ derives sandbox contents from code paths the target repo can influence.
135
+
136
+ Runtime config validation, `--harness` help, doctor login rows, the no-login
137
+ advice, credential staging, and sandbox mounts derive from the registry.
138
+
139
+ **Adding a runner** (a new isolation backend):
140
+
141
+ 1. One `Runner` implementation (see the protocol in `runners/base.py` and
142
+ the contract below).
143
+ 2. One entry in `runners/__init__.py`'s `RUNNERS` registry — the factory
144
+ declares which generic options the adapter consumes.
145
+ 3. Add the isolation name to `config.py`'s hard-coded `Isolation` Literal
146
+ alias. The drift test `test_registry_vocabularies_cannot_drift` must keep
147
+ passing: the alias, `RUNNERS`, and `VALID_ISOLATION` must name the same set.
148
+
149
+ Runtime config validation and the `--isolation` help derive from
150
+ `VALID_ISOLATION`, which derives from the registry.
151
+
152
+ ### The isolation contract
153
+
154
+ The `Runner` seam abstracts **where the agent process runs** — it does *not*
155
+ abstract **where the workspace lives**. Host locality binds every backend;
156
+ the security invariants bind sandboxed backends. `HostRunner` is the deliberate
157
+ non-sandbox exception: it launches directly on the host with live
158
+ `~/.claude` / `~/.codex` login dirs and normal host repo access.
159
+
160
+ What a sandboxed backend must **guarantee** (the security invariants):
161
+
162
+ - Staged credential *copies* only — the live `~/.claude` / `~/.codex` are
163
+ never exposed to the sandbox (they hold host-executed config).
164
+ - The sandbox-defining config is rendered outside the mount, so a boxed
165
+ agent cannot edit the definition of its own box.
166
+ - The shared `.git/hooks` is presented read-only (host git executes it).
167
+ - Egress lockdown, when enabled, exists before any workspace-derived code
168
+ runs (firewall-before-post-create).
169
+ - Only user-level treebox config is ever read — never the target repo's.
170
+
171
+ What a backend may **assume** (host locality):
172
+
173
+ - Provisioning already happened host-side (`provision.py` writes the
174
+ worktree, guard, submodules, `.env` before the runner sees it).
175
+ - The host filesystem is visible *at identical absolute paths*: docker
176
+ bind-mounts the worktree and its git common dir 1:1, state lives in the
177
+ host-side private git dir, the lockfile hash stats host files, and the
178
+ name-is-permanent rule in `models.py` presumes the absolute-path mount.
179
+
180
+ Consequence: OCI-compatible engines (podman, etc.) and bind-mount sandboxes
181
+ (bubblewrap/nsjail-style) fit this seam as-is. SSH-remote, VM, or cloud
182
+ backends — anywhere the agent's filesystem ≠ the host's — would first need a
183
+ filesystem-transport seam that deliberately does not exist yet (no adapter
184
+ needs path translation today, so building it would be indirection).
185
+
87
186
  ## Invariants to preserve
88
187
 
89
188
  - **Code is never silently stale.** `create` requires a successful
@@ -44,6 +44,23 @@ uv run --extra dev ruff format --check src tests
44
44
  uv run --extra dev mypy
45
45
  ```
46
46
 
47
+ For changes that touch provisioning, runners, git handling, or CLI output, run
48
+ the full validation gate: lint, type checks, shell asset checks, tests, the
49
+ golden CLI-output snapshots in `tests/golden/`, and a live host-runner smoke
50
+ test against a throwaway local remote:
51
+
52
+ ```bash
53
+ ./scripts/validate.sh
54
+ ```
55
+
56
+ For issue #141 behavior-preserving refactors, `scripts/golden-diff.sh` is the
57
+ focused snapshot-only gate. Refresh snapshots only after the issue explicitly
58
+ accepts a behavior change.
59
+
60
+ ```bash
61
+ scripts/golden-diff.sh
62
+ ```
63
+
47
64
  For docs-only changes, this is enough:
48
65
 
49
66
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: treebox
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Isolated, ready-to-run git worktrees for AI coding agents — host-native or docker-sandboxed.
5
5
  Project-URL: Homepage, https://github.com/Seth-Peters/treebox
6
6
  Project-URL: Documentation, https://seth-peters.github.io/treebox/
@@ -97,9 +97,10 @@ your back. Or install directly:
97
97
  uv tool install treebox
98
98
  ```
99
99
 
100
- Host isolation needs only `git` and a logged-in agent CLI (`claude` /
101
- `codex`); docker isolation additionally needs just `docker` no Node.js, no
102
- extra CLIs. See the
100
+ Host isolation needs `git` plus the selected agent CLI on PATH and logged in.
101
+ Docker isolation additionally needs `docker`; the default template supplies the
102
+ agent CLI, and treebox copies scoped login files from your `~/.claude` /
103
+ `~/.codex` subscription login. See the
103
104
  [install guide](https://seth-peters.github.io/treebox/install/) for
104
105
  requirements and installer overrides.
105
106
 
@@ -279,7 +280,7 @@ uv run treebox ... # run the CLI from the working tree
279
280
  uv run --extra dev pre-commit install # lint/format/strict type-check hooks (see CONTRIBUTING.md)
280
281
  uv run --extra dev python -m pytest # unit + integration suite
281
282
  uv run --extra dev mypy # strict type check
282
- ./scripts/validate.sh # lint + format + tests + live host-runner smoke
283
+ ./scripts/validate.sh # full gate: lint + typecheck + shell assets + tests + snapshots + smoke
283
284
  ```
284
285
 
285
286
  ## Contributing
@@ -60,9 +60,10 @@ your back. Or install directly:
60
60
  uv tool install treebox
61
61
  ```
62
62
 
63
- Host isolation needs only `git` and a logged-in agent CLI (`claude` /
64
- `codex`); docker isolation additionally needs just `docker` no Node.js, no
65
- extra CLIs. See the
63
+ Host isolation needs `git` plus the selected agent CLI on PATH and logged in.
64
+ Docker isolation additionally needs `docker`; the default template supplies the
65
+ agent CLI, and treebox copies scoped login files from your `~/.claude` /
66
+ `~/.codex` subscription login. See the
66
67
  [install guide](https://seth-peters.github.io/treebox/install/) for
67
68
  requirements and installer overrides.
68
69
 
@@ -242,7 +243,7 @@ uv run treebox ... # run the CLI from the working tree
242
243
  uv run --extra dev pre-commit install # lint/format/strict type-check hooks (see CONTRIBUTING.md)
243
244
  uv run --extra dev python -m pytest # unit + integration suite
244
245
  uv run --extra dev mypy # strict type check
245
- ./scripts/validate.sh # lint + format + tests + live host-runner smoke
246
+ ./scripts/validate.sh # full gate: lint + typecheck + shell assets + tests + snapshots + smoke
246
247
  ```
247
248
 
248
249
  ## Contributing
@@ -4,8 +4,8 @@ treebox is intentionally small: isolated git worktrees, cache-backed setup, and
4
4
  host or Docker runners for AI coding agents. A few directions we'd like to grow in,
5
5
  roughly in priority order:
6
6
 
7
- 1. **Support more coding-agent CLIs.** Broaden `HARNESS_COMMANDS` beyond
8
- `claude`/`codex` so more agents can launch in a provisioned worktree.
7
+ 1. **Support more coding-agent CLIs.** Extend the `harnesses.py` registry
8
+ beyond `claude`/`codex` so more agents can launch in a provisioned worktree.
9
9
  2. **Support more isolation environments.** Add runners beyond host and Docker —
10
10
  Podman first, as a rootless, daemonless drop-in.
11
11
  3. **Wider support and testing for different git services.** Exercise and harden
@@ -38,12 +38,19 @@ uv = "/mnt/fast/cache/uv"
38
38
  npm = "/mnt/fast/cache/npm"
39
39
  ```
40
40
 
41
- Precedence is what you'd expect:
41
+ For a new `create`, precedence is what you'd expect:
42
42
 
43
43
  ```text
44
44
  command-line flag > config.toml > built-in default
45
45
  ```
46
46
 
47
+ For an existing worktree, `enter` and `teardown` also read the worktree's
48
+ recorded creation-time state. Recorded isolation wins over the config default,
49
+ and a conflicting explicit `--isolation` exits `5`; `enter` always reuses the
50
+ recorded firewall, while recorded harness and template beat config defaults
51
+ unless `--harness` or `--template` is passed. `teardown` uses the recorded
52
+ template for docker volume cleanup because it has no `--template` flag.
53
+
47
54
  | Key | Default | What it controls |
48
55
  | ---------- | -------------------- | --------------------------------------------------------- |
49
56
  | `isolation`| `host` | Where agents run: the worktree shell, or a docker sandbox. |
@@ -156,5 +163,6 @@ container config in the target repo itself is deliberately ignored — see
156
163
  | `XDG_CACHE_HOME` | Standard XDG base for the shared package caches treebox mounts. |
157
164
 
158
165
  Secrets stay in files: treebox copies your repo's `.env` (or the configured
159
- `env_file`) into each worktree and mounts it into containers. Agents
160
- authenticate with your subscription login.
166
+ `env_file`) into each worktree and mounts it into containers. Host isolation
167
+ uses your live subscription login; docker isolation mounts scoped copies of the
168
+ login files each harness declares and refreshes them on every entry.
@@ -4,6 +4,12 @@ treebox is orchestration glue — it shells out to `git` and `docker` rather
4
4
  than reimplementing them. The whole tool is organized around one seam:
5
5
  **provisioning always happens on the host; running is pluggable.**
6
6
 
7
+ Three closed vocabularies sit on top of that seam:
8
+
9
+ - **Harness** — which agent CLI launches (`claude`, `codex`).
10
+ - **Runner / isolation** — where it executes (`host`, `docker`).
11
+ - **Ecosystem** — what setup runs (uv, npm, pnpm, go, cargo).
12
+
7
13
  ## The provisioning pipeline
8
14
 
9
15
  Every `create` walks the same host-side pipeline, regardless of isolation mode:
@@ -66,6 +72,14 @@ is pruned together with the worktree, and is what lets `enter` re-sync
66
72
  dependencies only when they actually changed — and `list` show `fresh` /
67
73
  `stale` at a glance.
68
74
 
75
+ The same private state records the worktree's creation-time choices. For an
76
+ existing worktree, `enter` and `teardown` recover the recorded isolation and
77
+ template instead of drifting to today's config defaults; `enter` also reuses the
78
+ recorded firewall, and it reuses the recorded harness unless a per-session
79
+ harness override is passed.
80
+ An explicit `--isolation` that disagrees with the recorded mode is a conflict,
81
+ not an override.
82
+
69
83
  ## Warmth lives in the cache, not the tree
70
84
 
71
85
  treebox detects the package manager from the repo — **uv, npm, pnpm, go, or
@@ -98,8 +112,10 @@ agent's own in-container `git` remains normal, but config it writes into the
98
112
  shared git dir cannot make the next host-side treebox call execute
99
113
  repo-controlled hooks or monitors.
100
114
 
101
- Your `.env` and shared caches are mounted in; the agent authenticates with
102
- your existing `~/.claude` / `~/.codex` subscription login.
115
+ Your `.env`, shared caches, and scoped credential copies are mounted in. Host
116
+ isolation uses your live `~/.claude` / `~/.codex` login dirs; docker isolation
117
+ never mounts those live dirs. Instead, each harness declares which login files
118
+ to copy into a per-worktree credentials dir, refreshed on every `enter`.
103
119
 
104
120
  ## Built to be scripted
105
121
 
@@ -41,7 +41,8 @@ Docker sandbox.
41
41
  <div class="t-line" style="--d:3.2s;--run:.5s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> worktree <span class="c-dim">.treebox/worktrees/brave-otter</span></div>
42
42
  <div class="t-line" style="--d:3.7s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> secrets <span class="c-dim">copied</span></div>
43
43
  <div class="t-line" style="--d:4.0s;--run:.4s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> sandbox <span class="c-dim">templates written (outside worktree)</span></div>
44
- <div class="t-line" style="--d:4.4s;--run:1.9s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> image <span class="c-dim">building container image…</span></div>
44
+ <div class="t-line" style="--d:4.4s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> credentials <span class="c-dim">scoped copies staged</span></div>
45
+ <div class="t-line" style="--d:4.8s;--run:1.5s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> image <span class="c-dim">building container image…</span></div>
45
46
  <div class="t-line" style="--d:6.3s;--run:.5s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> container <span class="c-dim">started · agent execs via docker exec</span></div>
46
47
  <div class="t-line" style="--d:6.9s"> </div>
47
48
  <div class="t-line" style="--d:6.9s"> <span class="c-ok">Ready</span> <span class="c-dim">in 22.6s — launching</span> claude</div>
@@ -54,9 +55,10 @@ Docker sandbox.
54
55
  <div class="t-line" style="--d:1.9s"> <span class="c-dim">isolation</span> docker <span class="c-dim">→</span> codex</div>
55
56
  <div class="t-line" style="--d:2.1s"> </div>
56
57
  <div class="t-line" style="--d:2.2s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> secrets <span class="c-dim">copied</span></div>
57
- <div class="t-line" style="--d:2.7s"> <span class="c-dim" deps unchanged · skipping setup</span></div>
58
- <div class="t-line" style="--d:2.9s"> </div>
59
- <div class="t-line" style="--d:3.0s"> <span class="c-ok">Ready</span> <span class="c-dim">in 1.4s — launching</span> codex</div>
58
+ <div class="t-line" style="--d:2.6s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> credentials <span class="c-dim">scoped copies refreshed</span></div>
59
+ <div class="t-line" style="--d:3.0s"> <span class="c-dim">· deps unchanged · skipping setup</span></div>
60
+ <div class="t-line" style="--d:3.2s"> </div>
61
+ <div class="t-line" style="--d:3.3s"> <span class="c-ok">Ready</span> <span class="c-dim">in 1.4s — launching</span> codex</div>
60
62
  <div class="t-line" style="--d:4.4s"> </div>
61
63
  <div class="t-line" style="--d:4.5s">^C</div>
62
64
  </div>
@@ -68,9 +70,10 @@ Docker sandbox.
68
70
  <div class="t-line" style="--d:2.0s"> <span class="c-dim">isolation</span> docker <span class="c-dim">→</span> claude</div>
69
71
  <div class="t-line" style="--d:2.2s"> </div>
70
72
  <div class="t-line" style="--d:2.3s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> secrets <span class="c-dim">copied</span></div>
71
- <div class="t-line" style="--d:2.8s"> <span class="c-dim" deps unchanged · skipping setup</span></div>
72
- <div class="t-line" style="--d:3.0s"> </div>
73
- <div class="t-line" style="--d:3.1s"> <span class="c-ok">Ready</span> <span class="c-dim">in 1.2s — launching</span> claude</div>
73
+ <div class="t-line" style="--d:2.7s;--run:.3s"> <span class="t-g"><span class="t-spin"></span><span class="t-check">✓</span></span> credentials <span class="c-dim">scoped copies refreshed</span></div>
74
+ <div class="t-line" style="--d:3.1s"> <span class="c-dim">· deps unchanged · skipping setup</span></div>
75
+ <div class="t-line" style="--d:3.3s"> </div>
76
+ <div class="t-line" style="--d:3.4s"> <span class="c-ok">Ready</span> <span class="c-dim">in 1.2s — launching</span> claude</div>
74
77
  </div>
75
78
  <div class="t-scene" data-dur="4800">
76
79
  <div class="t-line" style="--d:0s;--n:10"><span class="c-ok">$ </span><span class="t-type">treebox ls</span></div>
@@ -39,20 +39,25 @@ curl -fsSL https://raw.githubusercontent.com/Seth-Peters/treebox/main/install.sh
39
39
 
40
40
  ## What each isolation mode needs
41
41
 
42
- | Requirement | `host` | `docker` |
43
- | ---------------------------------------------- | :-----------: | :-------------: |
44
- | `git` | ✓ | ✓ |
45
- | Agent CLI (`claude` / `codex`), logged in once | ✓ | ✓ |
46
- | `docker` | | |
42
+ | Requirement | `host` | `docker` |
43
+ | ------------------------------------------------ | :-----------: | :-------------: |
44
+ | `git` | ✓ | ✓ |
45
+ | Subscription login (`~/.claude` / `~/.codex`) | ✓ | ✓ |
46
+ | Host agent CLI on PATH (`claude` / `codex`) | | — (template-provided) |
47
+ | `docker` | — | ✓ |
47
48
 
48
49
  That's the whole table: docker isolation speaks plain `docker build` /
49
- `docker run` / `docker exec`, so there is no Node.js and no extra CLI to
50
- install. The worktree and its git dir are mounted at their host paths, so
51
- in-container `git` just works with no special git version requirement.
52
-
53
- Agents launch via your `~/.claude` / `~/.codex` **subscription login**. The
54
- sandbox template docker isolation provisions is pinned to CPython 3.14.6,
55
- independent of whatever runs treebox itself.
50
+ `docker run` / `docker exec`, so there is no Node.js and no dev-container CLI
51
+ to install. The default template installs the agent CLIs in the image; the host
52
+ supplies scoped subscription-login copies. The worktree and its git dir are
53
+ mounted at their host paths, so in-container `git` just works — with no special
54
+ git version requirement.
55
+
56
+ Host isolation launches against your live `~/.claude` / `~/.codex`
57
+ **subscription login**. Docker isolation stages scoped copies of those login
58
+ files and never mounts the live dirs. The sandbox template docker isolation
59
+ provisions is pinned to CPython 3.14.6, independent of whatever runs treebox
60
+ itself.
56
61
 
57
62
  ## Verify
58
63
 
@@ -120,5 +125,5 @@ uv run treebox ... # run the CLI from the working tree
120
125
  uv run --extra dev pre-commit install # lint/format/strict type-check hooks (see CONTRIBUTING.md)
121
126
  uv run --extra dev python -m pytest # unit + integration suite
122
127
  uv run --extra dev mypy # strict type check
123
- ./scripts/validate.sh # lint + format + tests + live host-runner smoke
128
+ ./scripts/validate.sh # full gate: lint + typecheck + shell assets + tests + snapshots + smoke
124
129
  ```
@@ -138,7 +138,12 @@ harness the worktree was **provisioned with** (`create -H codex` then plain
138
138
  per-session override that launches that agent this time without changing what's
139
139
  recorded on disk. The sandbox **template** is reused the same way: a worktree
140
140
  created with `--template node` re-renders that template on `enter`, not the
141
- config default, and an explicit `--template` is a per-session override.
141
+ config default, and an explicit `--template` is a per-session override. The
142
+ recorded **isolation** mode also wins over the config default; a conflicting
143
+ explicit `--isolation` exits `5` instead of entering the wrong kind of
144
+ worktree. For docker worktrees, the recorded firewall choice is reused too, so
145
+ `create --no-firewall` keeps entering cleanly even under a `firewall = true`
146
+ config.
142
147
  Dependencies re-sync **only if the lockfile changed** since the last setup
143
148
  (treebox stores the hash in the worktree's private git dir, so it never shows
144
149
  up in `git status`); a re-sync preserves the recorded harness and template
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # Golden output snapshots for the CLI's observable surface (issue #141, PR 0).
3
+ #
4
+ # Runs a fixed matrix of commands — doctor, create --print/--dry-run,
5
+ # enter --print, and their --json forms, for both isolations — against a
6
+ # hermetic throwaway repo, normalizes the machine-specific bits (paths,
7
+ # uid/gid, git version, container-name digests), and diffs the result against
8
+ # the committed snapshots in tests/golden/. The refactor PRs under issue #141
9
+ # must leave every snapshot byte-identical: the "output identical" gate is
10
+ # mechanical, not eyeballed.
11
+ #
12
+ # scripts/golden-diff.sh # compare against tests/golden/ (CI gate)
13
+ # scripts/golden-diff.sh --update # regenerate the snapshots (review the diff!)
14
+ #
15
+ # Docker is replaced by a no-op shim on PATH so the docker-isolation cases are
16
+ # deterministic and run on hosts without a daemon: `docker ps -aq` reports no
17
+ # containers and every other subcommand succeeds silently, which drives the
18
+ # fresh-create path (render config -> stage credentials -> build -> run ->
19
+ # post-create) end to end without a real container.
20
+ set -euo pipefail
21
+ cd "$(dirname "${BASH_SOURCE[0]}")/.."
22
+
23
+ MODE="diff"
24
+ [[ "${1:-}" == "--update" ]] && MODE="update"
25
+
26
+ GOLDEN_DIR="tests/golden"
27
+ ROOT_PARENT="${TREEBOX_GOLDEN_ROOT:-/tmp}"
28
+ ROOT_PARENT="${ROOT_PARENT%/}"
29
+ [[ -n "$ROOT_PARENT" ]] || { echo "FATAL: TREEBOX_GOLDEN_ROOT must not be /" >&2; exit 1; }
30
+ mkdir -p "$ROOT_PARENT"
31
+ ROOT="$(mktemp -d "$ROOT_PARENT/treebox-golden.XXXXXX")"
32
+ ROOT="$(cd "$ROOT" && pwd -P)"
33
+ WORK="$ROOT/out"
34
+ trap 'rm -rf "$ROOT"' EXIT
35
+
36
+ TREEBOX="$PWD/.venv/bin/treebox"
37
+ [[ -x "$TREEBOX" ]] || uv sync --quiet
38
+ [[ -x "$TREEBOX" ]] || { echo "FATAL: $TREEBOX not found after uv sync" >&2; exit 1; }
39
+
40
+ mkdir -p "$WORK" "$ROOT/tbhome" "$ROOT/home-empty"
41
+
42
+ # --- fake HOME with both subscription logins staged --------------------------
43
+ HOME_FAKE="$ROOT/home"
44
+ mkdir -p "$HOME_FAKE/.claude" "$HOME_FAKE/.codex"
45
+ printf '{"token": "golden"}\n' > "$HOME_FAKE/.claude/.credentials.json"
46
+ printf '{}\n' > "$HOME_FAKE/.claude/settings.json"
47
+ printf '{}\n' > "$HOME_FAKE/.codex/auth.json"
48
+ printf '\n' > "$HOME_FAKE/.codex/config.toml"
49
+
50
+ # --- docker shim: no containers exist; every subcommand succeeds -------------
51
+ SHIM="$ROOT/shim"
52
+ mkdir -p "$SHIM"
53
+ printf '#!/bin/sh\nexit 0\n' > "$SHIM/docker"
54
+ chmod +x "$SHIM/docker"
55
+
56
+ # --- hermetic config: setup is a marker-writing hook, never a real installer -
57
+ CFG="$ROOT/config.toml"
58
+ printf 'setup_hook = ["echo ran >> setup.log"]\n' > "$CFG"
59
+
60
+ # --- fixture repo: a working clone of a local bare origin --------------------
61
+ git_q() { git -c init.defaultBranch=main "$@" >/dev/null; }
62
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@e GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@e
63
+ git_q init --bare -b main "$ROOT/origin.git"
64
+ git_q clone "$ROOT/origin.git" "$ROOT/repo" 2>/dev/null
65
+ REPO="$ROOT/repo"
66
+ printf '[project]\nname="golden"\nversion="0"\n' > "$REPO/pyproject.toml"
67
+ printf 'version = 1\n' > "$REPO/uv.lock"
68
+ printf 'SECRET=golden\n' > "$REPO/.env"
69
+ git_q -C "$REPO" add -A
70
+ git_q -C "$REPO" commit -qm init
71
+ git_q -C "$REPO" push -q origin main
72
+ WTS="$ROOT/wts"
73
+
74
+ GITVER="$(git --version | awk '{print $3}')"
75
+ GITVER_PARSED="$(git --version | perl -lane 'my $s = $F[-1]; my @parts = split /\./, $s; my @nums; for my $p (@parts[0..2]) { $p //= ""; $p =~ s/\D//g; push @nums, length($p) ? $p : 0; } push @nums, 0 while @nums < 3; print join ".", @nums[0..2]')"
76
+ export GD_ROOT="$ROOT" GD_PRIVROOT="/private$ROOT" GD_GITVER="$GITVER" GD_GITVER_PARSED="$GITVER_PARSED"
77
+ # Literal (not pattern) uid/gid substitutions, so a regression that changed
78
+ # one number into another is a snapshot diff, not silently normalized away.
79
+ GD_UID="$(id -u)"
80
+ GD_GID="$(id -g)"
81
+ export GD_UID GD_GID
82
+
83
+ normalize() {
84
+ perl -pe '
85
+ s/\Q$ENV{GD_PRIVROOT}\E/__ROOT__/g;
86
+ s/\Q$ENV{GD_ROOT}\E/__ROOT__/g;
87
+ s/\Q$ENV{GD_GITVER_PARSED}\E/__GITVER__/g;
88
+ s/\Q$ENV{GD_GITVER}\E/__GITVER__/g;
89
+ s/\bUSER_UID=\Q$ENV{GD_UID}\E\b/USER_UID=__UID__/g;
90
+ s/\bUSER_GID=\Q$ENV{GD_GID}\E\b/USER_GID=__GID__/g;
91
+ s/\b\Q$ENV{GD_UID}\E:\Q$ENV{GD_GID}\E\b/__UID__:__GID__/g;
92
+ s/\btreebox-([a-z0-9._-]+)-[0-9a-f]{10}\b/treebox-$1-__HASH__/g;
93
+ '
94
+ }
95
+
96
+ FAILED=0
97
+
98
+ # run_case <name> <home-dir> <treebox args...>
99
+ run_case() {
100
+ local name="$1" home="$2"
101
+ shift 2
102
+ local rc=0
103
+ set +e
104
+ env -u TZ -u XDG_CACHE_HOME -u PNPM_HOME -u UV_CACHE_DIR -u npm_config_cache \
105
+ -u npm_config_store_dir -u GOMODCACHE -u CARGO_HOME -u TREEBOX_TEMPLATE_DIR \
106
+ -u NO_COLOR -u FORCE_COLOR -u CLICOLOR_FORCE \
107
+ HOME="$home" TREEBOX_CONFIG="$CFG" TREEBOX_HOME="$ROOT/tbhome" \
108
+ PATH="$SHIM:$PATH" COLUMNS=80 \
109
+ "$TREEBOX" "$@" >"$WORK/$name.stdout" 2>"$WORK/$name.stderr"
110
+ rc=$?
111
+ set -e
112
+ {
113
+ echo "# treebox $*" | normalize
114
+ echo "# exit: $rc"
115
+ echo "# --- stdout ---"
116
+ normalize < "$WORK/$name.stdout"
117
+ echo "# --- stderr ---"
118
+ normalize < "$WORK/$name.stderr"
119
+ } > "$WORK/$name.txt"
120
+ rm -f "$WORK/$name.stdout" "$WORK/$name.stderr"
121
+
122
+ if [[ "$MODE" == "update" ]]; then
123
+ cp "$WORK/$name.txt" "$GOLDEN_DIR/$name.txt"
124
+ echo "updated $name"
125
+ elif [[ ! -f "$GOLDEN_DIR/$name.txt" ]]; then
126
+ echo "MISSING snapshot: $GOLDEN_DIR/$name.txt (run with --update on main first)" >&2
127
+ FAILED=1
128
+ elif ! diff -u "$GOLDEN_DIR/$name.txt" "$WORK/$name.txt"; then
129
+ echo "DIFF $name" >&2
130
+ FAILED=1
131
+ else
132
+ echo "ok $name"
133
+ fi
134
+ }
135
+
136
+ mkdir -p "$GOLDEN_DIR"
137
+ R=(--repo "$REPO")
138
+ RW=(--repo "$REPO" --root "$WTS")
139
+
140
+ # --- host isolation -----------------------------------------------------------
141
+ run_case doctor-host "$HOME_FAKE" doctor "${R[@]}"
142
+ run_case doctor-host-json "$HOME_FAKE" doctor "${R[@]}" --json
143
+ run_case doctor-no-login "$ROOT/home-empty" doctor "${R[@]}"
144
+ run_case create-dryrun-host "$HOME_FAKE" create golden-host "${RW[@]}" --dry-run
145
+ run_case create-dryrun-host-json "$HOME_FAKE" create golden-host "${RW[@]}" --dry-run --json
146
+ run_case create-host-print "$HOME_FAKE" create golden-host "${RW[@]}" --print
147
+ run_case create-host-json "$HOME_FAKE" create golden-host-json "${RW[@]}" --json
148
+ run_case enter-host-print "$HOME_FAKE" enter golden-host "${RW[@]}" --print
149
+ run_case enter-host-json "$HOME_FAKE" enter golden-host "${RW[@]}" --json
150
+
151
+ # --- docker isolation (through the shim) ---------------------------------------
152
+ run_case doctor-docker "$HOME_FAKE" doctor "${R[@]}" --isolation docker
153
+ run_case doctor-docker-json "$HOME_FAKE" doctor "${R[@]}" --isolation docker --json
154
+ run_case create-dryrun-docker "$HOME_FAKE" create golden-docker "${RW[@]}" --isolation docker --dry-run
155
+ run_case create-dryrun-docker-json "$HOME_FAKE" create golden-docker "${RW[@]}" --isolation docker --dry-run --json
156
+ run_case create-docker-print "$HOME_FAKE" create golden-docker "${RW[@]}" --isolation docker --print
157
+ run_case create-docker-json "$HOME_FAKE" create golden-docker-json "${RW[@]}" --isolation docker --json
158
+ run_case enter-docker-print "$HOME_FAKE" enter golden-docker "${RW[@]}" --print
159
+ run_case enter-docker-json "$HOME_FAKE" enter golden-docker "${RW[@]}" --json
160
+
161
+ if [[ "$MODE" == "diff" && "$FAILED" -ne 0 ]]; then
162
+ echo >&2
163
+ echo "golden-diff: OUTPUT CHANGED — the stability contract (issue #141) requires" >&2
164
+ echo "byte-identical CLI output. If the change is intentional, discuss on the" >&2
165
+ echo "issue first; only regenerate with --update after that decision." >&2
166
+ exit 1
167
+ fi
168
+ echo "golden-diff: all snapshots match"