oneharness-cli 0.3.5__py3-none-win_amd64.whl

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.
@@ -0,0 +1,993 @@
1
+ Metadata-Version: 2.4
2
+ Name: oneharness-cli
3
+ Version: 0.3.5
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Environment :: Console
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Rust
10
+ Classifier: Topic :: Software Development :: Build Tools
11
+ Classifier: Topic :: Software Development :: Testing
12
+ License-File: LICENSE
13
+ Summary: One CLI to drive many agentic coding harnesses non-interactively and return uniform JSON.
14
+ Keywords: cli,agent,claude,codex,harness
15
+ Author: Nick DeRobertis
16
+ License: MIT
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
19
+ Project-URL: Homepage, https://github.com/nickderobertis/oneharness
20
+ Project-URL: Repository, https://github.com/nickderobertis/oneharness
21
+
22
+ # oneharness
23
+
24
+ One CLI across many agentic coding harnesses. `oneharness` drives **Claude Code,
25
+ Codex, OpenCode, Goose, Qwen Code, Crush, GitHub Copilot CLI, and Cursor** through
26
+ a single non-interactive interface, runs them **in parallel**, and returns **one
27
+ stable JSON shape** built for programmatic consumers.
28
+
29
+ It exists to make cross-harness automation boring: instead of hand-rolling a
30
+ `run_agent()` for each tool — different flags, different output, different
31
+ "don't prompt me" switch, different skip-if-not-installed dance — you call one
32
+ command and read one JSON document.
33
+
34
+ ```console
35
+ $ oneharness run --all --prompt "Reply with the single word: pong" --model haiku
36
+ ```
37
+
38
+ ```jsonc
39
+ {
40
+ "schema_version": "0.1",
41
+ "oneharness_version": "0.1.0",
42
+ "prompt": "Reply with the single word: pong",
43
+ "model": "haiku",
44
+ "resume": null,
45
+ "fork": false,
46
+ "bypass_permissions": true,
47
+ "dry_run": false,
48
+ "config_files": ["/home/me/.config/oneharness/config.toml"],
49
+ "results": [
50
+ {
51
+ "harness": "claude-code",
52
+ "bin": "claude",
53
+ "available": true,
54
+ "status": "ok",
55
+ "exit_code": 0,
56
+ "duration_ms": 1840,
57
+ "command": ["claude", "-p", "Reply with…", "--permission-mode", "bypassPermissions", "--output-format", "json"],
58
+ "output_format": "json",
59
+ "text": "pong",
60
+ "text_source": "json:result",
61
+ "usage": { "input_tokens": 1234, "output_tokens": 8, "cache_read_tokens": 7, "cache_write_tokens": null, "cost_usd": 0.0095 },
62
+ "usage_source": "json",
63
+ "session_id": "0f3c…",
64
+ "failure_kind": null,
65
+ "failure_kind_source": null,
66
+ "stdout": "{\"type\":\"result\",\"result\":\"pong\"…}",
67
+ "stderr": "",
68
+ "error": null
69
+ },
70
+ { "harness": "codex", "available": false, "status": "skipped", "error": "`codex` not found on PATH; harness skipped. Install it: npm install -g @openai/codex", "…": "…" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ ## Supported harnesses
76
+
77
+ The table doubles as the **config support matrix**: each column after the
78
+ binary is a unified setting (CLI flag and/or `oneharness.toml` field) and shows
79
+ how — or whether — it reaches that harness.
80
+
81
+ | id | CLI | default binary | `model` | `system` | bypass mode requested | synced config file | allow / deny | hooks | output format | `--resume` (continue / fork) |
82
+ |----|-----|----------------|:-------:|----------|-----------------------|--------------------|:------------:|:-----:|:-------------:|:---------:|
83
+ | `claude-code` | Claude Code | `claude` | ✓ | native flag | `--permission-mode bypassPermissions` | `.claude/settings.json` | ✓ / ✓ | ✓ | ✓ | `--resume` + `--fork-session` |
84
+ | `codex` | OpenAI Codex CLI | `codex` | ✓ | prepended | `--dangerously-bypass-approvals-and-sandbox` | — | — | — | — | `exec resume <id>` (linear) |
85
+ | `opencode` | OpenCode | `opencode` | ✓ | prepended | `--dangerously-skip-permissions` | `opencode.json` | via `settings` | — | ✓ | `--session` + `--fork` |
86
+ | `goose` | Goose | `goose` | — | native flag | (runs unattended) | — | — | — | — | `--resume --name` (linear)¹ |
87
+ | `qwen` | Qwen Code | `qwen` | ✓ | prepended | `--yolo` | `.qwen/settings.json` | ✓ / ✓ (interactive) | — | — | `--resume` (linear) |
88
+ | `crush` | Crush | `crush` | ✓ | prepended | `run -q` (non-interactive) | `crush.json` | ✓ / ✓ | — | — | `--session` (linear) |
89
+ | `copilot` | GitHub Copilot CLI | `copilot` | ✓ | prepended | `--allow-all-tools --allow-all-paths --no-ask-user` | — | — | — | — | `--resume` (linear)¹ |
90
+ | `cursor` | Cursor CLI | `cursor-agent` | ✓ | prepended | `--force` (`--trust` under `--no-bypass`) | `.cursor/cli.json` | ✓ / ✓ | — | ✓ | `--resume` (linear) |
91
+
92
+ The `--resume` column shows each harness's headless continuation flag and whether
93
+ it can **fork** (`run --resume <id> --fork`: branch a new session from the resumed
94
+ one, leaving the original — and its cached prefix — untouched). Only Claude Code
95
+ (`--fork-session`) and OpenCode (`--fork`) fork headlessly; the rest *resume
96
+ linearly* (append in place), and `--fork` is a usage error for them, never a
97
+ silent linear resume. ¹ Goose and Copilot emit no session id to stdout headlessly,
98
+ so their continuation handle is **caller-supplied** (a `--name`, or a minted UUID
99
+ respectively) and reused on the next run — `session_id` stays `null` for them
100
+ (nothing to extract); every other harness reports an id oneharness captures.
101
+
102
+ - **`model`** — ✓ means the harness takes a model flag. Goose selects its model
103
+ from its own provider config, so `model` is intentionally not mapped for it.
104
+ - **`system`** — "native flag" means the system prompt maps to a real flag
105
+ (Claude Code's `--append-system-prompt`, Goose's `--system`); "prepended"
106
+ means the harness has no such flag, so the text is prepended to the prompt —
107
+ it always reaches the model, never silently dropped.
108
+ - **synced config file** — the project-scoped file `oneharness sync` merges the
109
+ unified settings into. Because the policy lands in each harness's *own*
110
+ config, it also governs the tools when used directly — oneharness is not in
111
+ the loop at run time. Codex and Goose read only user-global config, and
112
+ Copilot takes permission rules only as flags (deliverable via
113
+ `[harness.copilot] args`), so they have no sync target.
114
+ - **allow / deny** — whether `allowed_tools` / `denied_tools` lists have a
115
+ place in that file, in each harness's own rule syntax: Claude Code, Qwen, and
116
+ Cursor use `permissions.allow` / `permissions.deny`. Qwen's rules govern its
117
+ *interactive* approval flow only — live testing showed its headless mode
118
+ never auto-approves from settings (only the `-y` CLI flag executes
119
+ approval-gated tools), so synced qwen rules protect regular usage, not
120
+ headless runs. Crush uses
121
+ `permissions.allowed_tools`, with deny mapped to `options.disabled_tools`
122
+ (the tool is hidden entirely — its strongest deny). OpenCode's `permission`
123
+ is a policy map, not a list, so the lists are rejected for it — express it
124
+ with `[harness.opencode.settings]` instead. A rule aimed at a harness with no
125
+ mapping is a parse error (per-harness fields) or reported `unmapped` (top
126
+ level) — never silently dropped.
127
+ - **hooks** — Claude Code's `hooks` table in `.claude/settings.json`. Other
128
+ harnesses keep hooks in places oneharness doesn't manage yet (Copilot's
129
+ `.github/hooks/`, Cursor's `hooks.json`, OpenCode's JS plugins).
130
+ - **output format** — ✓ means the harness takes a format flag the
131
+ `output_format` setting maps onto; a `—` harness emits plain text and the
132
+ setting only changes how `text` is extracted.
133
+ - **`--resume`** — the flag each adapter maps `run --resume <session>` onto
134
+ (every harness supports headless continuation). The cell also shows whether the
135
+ harness can **fork** (`--fork`): Claude Code and OpenCode branch a new session
136
+ from the resumed one; the rest *resume linearly* (append in place), so `--fork`
137
+ is a usage error for them, never a silent linear resume.
138
+
139
+ The remaining unified settings — `timeout`, `env`, `bin`, per-harness `args`,
140
+ `cwd`, selection — are enforced by oneharness itself at run time, so they work
141
+ for **every** harness — as does `--schema` ([structured output](#structured-output),
142
+ prompt-based where a harness has no native schema flag). `oneharness list` prints
143
+ this registry as JSON, including each adapter's exact command, its `sync_file`,
144
+ and `supports_resume` / `supports_fork` / `supports_native_schema` /
145
+ `supports_allowed_tools` / `supports_denied_tools` / `supports_hooks` capability
146
+ flags.
147
+
148
+ ## Install
149
+
150
+ ```console
151
+ # from PyPI (per-platform wheel wrapping the prebuilt binary — no Rust toolchain)
152
+ pip install oneharness-cli # installs the `oneharness` command
153
+ # or the latest prebuilt release for your platform via the install script
154
+ curl -fsSL https://raw.githubusercontent.com/nickderobertis/oneharness/main/scripts/install.sh | sh
155
+ # or pin a release tag / install directory
156
+ curl -fsSL https://raw.githubusercontent.com/nickderobertis/oneharness/main/scripts/install.sh \
157
+ | sh -s -- --version v0.1.0 --to ~/.local/bin
158
+ # or from crates.io / a published release tag
159
+ cargo install oneharness --locked
160
+ cargo install --git https://github.com/nickderobertis/oneharness --tag v0.1.0 --locked
161
+ # or from a clone
162
+ cargo install --path .
163
+ # or just build the release binary
164
+ just build-release # -> target/release/oneharness
165
+ ```
166
+
167
+ A tagged release ships four ways: **PyPI** wheels (`pip install oneharness-cli`,
168
+ the distribution is `oneharness-cli`, the command is `oneharness`), **crates.io**
169
+ (`cargo install oneharness`), prebuilt checksummed binaries on its
170
+ [GitHub Releases](https://github.com/nickderobertis/oneharness/releases) page for
171
+ Linux, macOS, and Windows, and `cargo install --git`. Building from source
172
+ requires a stable Rust toolchain and [`just`](https://github.com/casey/just).
173
+ The install script honors `ONEHARNESS_VERSION`, `ONEHARNESS_INSTALL_DIR`,
174
+ `ONEHARNESS_RELEASE_BASE_URL`/`--base-url`, `ONEHARNESS_CHECKSUM_BASE_URL`, and
175
+ `GITHUB_TOKEN` (for higher GitHub API rate limits when resolving the latest
176
+ release).
177
+
178
+ ### Supply-chain verification
179
+
180
+ The install script never trusts a mirror to attest its own download. It verifies
181
+ every archive against a trust root **independent of where it was downloaded**,
182
+ and aborts if nothing independent can vouch for it. Two roots, tried in order:
183
+
184
+ 1. **Sigstore build-provenance attestation (preferred).** Each release ships a
185
+ keyless [Sigstore](https://www.sigstore.dev/) bundle beside the archive
186
+ (`oneharness-<tag>-<target>.sigstore.json`), logged to the public Rekor
187
+ transparency log and bound to this repo's release workflow's OIDC identity —
188
+ no signing key or secret. When a verifier is present —
189
+ [`cosign`](https://github.com/sigstore/cosign),
190
+ [`sigstore`](https://pypi.org/project/sigstore/) (`pip install sigstore`), or
191
+ [`gh`](https://cli.github.com/) — the installer verifies the archive against
192
+ the bundle **offline**. The trusted digest comes from the signed attestation
193
+ itself (no checksum file is consulted), so a mirror cannot forge it, and it
194
+ works behind a mirror that can't reach github.com. Where github.com is
195
+ unreachable a verifier is one registry install away (`pip install sigstore`,
196
+ `npm i -g @sigstore/cli`, or `go install …/cosign@latest`).
197
+ 2. **SHA-256 checksum from canonical GitHub (fallback, only when no verifier is
198
+ installed).** The `.sha256` is fetched from github.com, never from the mirror.
199
+ A checksum that shares the mirror's origin is no trust root at all — the mirror
200
+ would just serve a matching tampered checksum — so the installer **refuses**
201
+ it and tells you to install a verifier, rather than trust the mirror to vouch
202
+ for its own download.
203
+
204
+ Serve the archive from a mirror with `ONEHARNESS_RELEASE_BASE_URL` (or
205
+ `--base-url`) — for a network that can reach a mirror but not github.com, ship
206
+ the `.sigstore.json` bundle on the mirror too and install a verifier, and the
207
+ whole flow works offline. `ONEHARNESS_CHECKSUM_BASE_URL` points the checksum
208
+ fallback at a specific independent root. You can also verify any archive out of
209
+ band:
210
+
211
+ ```console
212
+ cosign verify-blob-attestation --new-bundle-format \
213
+ --bundle oneharness-v0.1.0-x86_64-unknown-linux-gnu.sigstore.json \
214
+ --type https://slsa.dev/provenance/v1 \
215
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
216
+ --certificate-identity-regexp '^https://github.com/nickderobertis/oneharness/\.github/workflows/release\.yml@' \
217
+ oneharness-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
218
+ # or, more simply:
219
+ gh attestation verify oneharness-v0.1.0-x86_64-unknown-linux-gnu.tar.gz \
220
+ --repo nickderobertis/oneharness
221
+ ```
222
+
223
+ Every release runs a `verify-attestation` CI job that installs real `cosign` and
224
+ `sigstore-python` and runs these exact commands against the just-published
225
+ bundle, so a drift in the signing identity or flags reddens the release instead
226
+ of silently degrading installs to the checksum fallback.
227
+
228
+ ## Usage
229
+
230
+ Six subcommands; `list`/`detect`/`config`/`sync`/`run` emit JSON to **stdout**
231
+ (diagnostics go to **stderr**), and `gate` speaks a harness's hook protocol on
232
+ stdin/stdout.
233
+
234
+ ```console
235
+ oneharness list # describe the registry
236
+ oneharness detect --all # which harnesses are installed (+ versions)
237
+ oneharness config # effective layered config + where each value came from
238
+ oneharness sync # merge the unified settings into each harness's own config file
239
+ oneharness sync --global # install [[hooks]] into the user-global config instead of the project
240
+ oneharness run --all --prompt "…" # run everywhere, in parallel
241
+ oneharness run --harness claude-code,codex --prompt-file task.md
242
+ oneharness run --harness claude-code --system "$(cat ctx.md)" \
243
+ --prompt "Q1" --prompt "Q2" --prompt "Q3" --batch-strategy min-tokens # batch: one harness, N prompts, shared cache prefix
244
+ oneharness run --all --print-command --prompt "…" # dry run: show commands, run nothing
245
+ oneharness gate claude-code --deny-if-contains X # the pre-tool gate an installed hook invokes (reads stdin)
246
+ ```
247
+
248
+ Useful `run` flags:
249
+
250
+ - `--all` / `--harness <id,…>` / `--exclude <id,…>` — selection.
251
+ - `--prompt <text>` or `--prompt-file <path|->` — the prompt (file or stdin). Both
252
+ are **repeatable**; passing more than one prompt switches to a [batch
253
+ run](#batch-runs-same-prefix-prompt-caching) (one harness, N prompts). Each
254
+ `--prompt-file` is read whole as one prompt (not split per line); `-` (stdin)
255
+ may appear once. Combined order is every `--prompt`, then every `--prompt-file`.
256
+ - `--batch-strategy <speed|min-tokens>` — for a batch run, how the calls are
257
+ scheduled to exploit the shared prefix cache (`speed`, the default, or
258
+ `min-tokens`); see [batch runs](#batch-runs-same-prefix-prompt-caching). No
259
+ effect on a single-prompt run.
260
+ - `--model <m>` — passed to each harness that supports a model flag.
261
+ - `--system <text>` — portable system prompt for **every** harness: mapped to a
262
+ native flag where one exists (Claude Code's `--append-system-prompt`, Goose's
263
+ `--system`), and prepended to the prompt otherwise, so the instructions always
264
+ reach the model.
265
+ - `--resume <session>` — continue a prior session, sending the prompt as its next
266
+ turn. **Single-harness only** (a session belongs to one harness); every harness
267
+ supports it, but multi-harness selections are still a usage error. The continued
268
+ `session_id` is surfaced on each result (see below). Harnesses that emit no id
269
+ headlessly (Goose, Copilot) take a **caller-supplied** handle you reuse across
270
+ runs (a `--name`, or a minted UUID).
271
+ - `--fork` — with `--resume`, branch a **new** session from the resumed one
272
+ instead of appending to it, leaving the original (and its cached prefix)
273
+ untouched — so one expensive initial prompt can seed many independent follow-ups
274
+ that each reuse the cached prefix. Only Claude Code (`--fork-session`) and
275
+ OpenCode (`--fork`) fork headlessly (`supports_fork` in `oneharness list`);
276
+ requesting it for any other harness is a usage error, never a silent linear
277
+ resume. Requires `--resume`.
278
+ - `--output-format <text|json|stream-json>` — override the format requested from
279
+ each harness (default: per-harness); affects the emitted flag and how `text` is
280
+ extracted.
281
+ - `--schema <path>` / `--schema-max-retries <n>` — **structured output**:
282
+ constrain each harness's final answer to a JSON Schema, validate it, and
283
+ re-prompt on failure. See [Structured output](#structured-output) below.
284
+ - `--output-dir <dir>` — also write each harness's raw stdout/stderr to
285
+ `<dir>/<harness>.stdout` and `<dir>/<harness>.stderr` (read transcripts from
286
+ files without a JSON parser).
287
+ - `-- <args…>` — extra arguments appended verbatim to each harness command (for
288
+ single-harness runs, since flags differ per harness).
289
+ - `--timeout <secs>` — per-harness timeout (default 120); a hang becomes a
290
+ `timeout` result, not a stuck process.
291
+ - `--cwd <dir>` / `--env KEY=VALUE` — run each harness in a directory / with extra
292
+ env (useful for sandboxed e2e).
293
+ - `--max-parallel <n>` — cap concurrency (default: all selected at once).
294
+ - `--mode <read-only|plan|default|edit|auto|bypass>` — the approval mode
295
+ requested from each harness (default `default`; see *Approval modes* below). A
296
+ mode a selected harness **can't express** is a loud usage error before anything
297
+ spawns; one that **may block on a prompt** headlessly is warned about and run,
298
+ with `--timeout` as the backstop.
299
+ - `--no-bypass` / `--bypass` — shorthands for `--mode default` / `--mode bypass`;
300
+ `--bypass` forces bypass on over a config's `mode` / `bypass`.
301
+ - `--permit-prompts` — silence the "may block on a prompt" warning for the chosen
302
+ mode (use once allow-rules are synced so the prompt never fires).
303
+ - `--require-available` — treat a not-installed harness as a failure.
304
+ - `--bin <id>=<path>` — override a harness binary (also via `ONEHARNESS_BIN_<ID>`).
305
+ - `--config <path>` / `--no-config` — load exactly one config file / ignore all
306
+ config files (see below).
307
+ - `--compact` — single-line JSON.
308
+
309
+ ### Configuration
310
+
311
+ Most `run` flags have a persistent counterpart in **`oneharness.toml`**, so a
312
+ project (or a user) states its defaults once instead of repeating flags. Several
313
+ sources layer per field, lowest precedence first:
314
+
315
+ 1. **Built-in defaults.**
316
+ 2. **User-level** — `~/.config/oneharness/config.toml` (honoring
317
+ `$XDG_CONFIG_HOME`; `%APPDATA%\oneharness\config.toml` on Windows), or the
318
+ file named by `$ONEHARNESS_CONFIG`.
319
+ 3. **Project-level** — the nearest `oneharness.toml` (or `.oneharness.toml`),
320
+ discovered by walking up from the directory the harnesses run in (`--cwd`,
321
+ else the current directory).
322
+ 4. **Environment overrides** — `ONEHARNESS_<FIELD>` variables (see below); beat
323
+ every config file.
324
+ 5. **CLI flags** — always win.
325
+
326
+ Every top-level field with a `run` flag also has a standard
327
+ **`ONEHARNESS_<FIELD>`** environment override, the field name upper-snake-cased
328
+ so the env var, config key, and flag stay in sync (`model` → `ONEHARNESS_MODEL`,
329
+ `schema_max_retries` → `ONEHARNESS_SCHEMA_MAX_RETRIES`). List fields are
330
+ comma-separated like their repeatable flags (`ONEHARNESS_HARNESSES=claude-code,codex`),
331
+ booleans take `true`/`false` (or `1`/`0`), and an empty value counts as unset. A
332
+ malformed value (bad boolean/integer/format, unknown harness id) is the same
333
+ loud usage error a file would raise. The sync-policy fields (`allowed_tools`,
334
+ `denied_tools`, `hooks`, `settings`), the `[env]` table, and the
335
+ `[harness.<id>]` overrides have no env form by design.
336
+
337
+ ```console
338
+ ONEHARNESS_MODEL=gpt-5 ONEHARNESS_TIMEOUT=300 oneharness run --harness codex --prompt hi
339
+ ONEHARNESS_HARNESSES=claude-code,codex oneharness run --prompt hi # selection from the env
340
+ ```
341
+
342
+ Within one file, a `[harness.<id>]` value beats the top-level value for that
343
+ harness. Every field is optional, and an unknown field or harness id is a loud
344
+ usage error (exit 2), never silently ignored. The run report's `config_files`
345
+ array records exactly which files shaped a run.
346
+
347
+ ```toml
348
+ # oneharness.toml — every field optional; shown with its CLI counterpart
349
+ harnesses = ["claude-code", "codex"] # --harness (or `all = true` for --all)
350
+ exclude = ["cursor"] # --exclude (applies to an `all` selection)
351
+ model = "gpt-5" # --model
352
+ system = "Be terse." # --system
353
+ bypass = true # legacy --bypass toggle (opt-in; default false)
354
+ mode = "default" # --mode; beats `bypass` (default: "default")
355
+ timeout = 120 # --timeout, in seconds
356
+ output_format = "json" # --output-format
357
+ schema_file = "person.json" # --schema (structured output; relative to project)
358
+ schema_max_retries = 2 # --schema-max-retries (default 2)
359
+ max_parallel = 4 # --max-parallel
360
+ require_available = false # --require-available
361
+ allowed_tools = ["Bash(git log:*)"] # synced into each harness's config file
362
+ denied_tools = ["Bash(rm:*)"] # (see `oneharness sync` below)
363
+
364
+ # A normalized pre-tool hook, fanned across every synced harness and rendered
365
+ # into each one's native shape (a shared config file, a dedicated hooks file,
366
+ # or a plugin). `{harness}` is replaced with the harness id. Unlike the
367
+ # verbatim `[harness.<id>.hooks]` table below, this reaches ALL harnesses.
368
+ [[hooks]]
369
+ command = "mygate hook {harness}" # required; {harness} → claude-code, codex, …
370
+ matcher = "Bash" # optional tool-name matcher (harness dialect)
371
+ timeout = 10 # optional; honored where the schema has one
372
+ # plugin_name = "mygate" # optional identity for plugin/Copilot files
373
+ # harnesses = ["claude-code"] # optional; default = every synced harness
374
+
375
+ [env] # --env, for every harness
376
+ RUST_LOG = "warn"
377
+
378
+ [harness.claude-code] # per-harness: beats the top level for this id
379
+ model = "claude-sonnet-4-5" # each harness can name its own model
380
+ bin = "/opt/claude" # like --bin (the flag and ONEHARNESS_BIN_* win)
381
+ args = ["--max-turns", "6"] # extra argv appended for this harness only
382
+ allowed_tools = ["Bash(git:*)", "Read"] # this harness's rule syntax
383
+ env = { ANTHROPIC_LOG = "debug" }
384
+
385
+ # Lifecycle hooks, in the harness's own hooks schema, synced into its config
386
+ # file (Claude Code's .claude/settings.json `hooks` key) uninterpreted.
387
+ [harness.claude-code.hooks]
388
+ PreToolUse = [{ matcher = "Bash", hooks = [{ type = "command", command = "./validate.sh" }] }]
389
+
390
+ # Raw settings merged verbatim into a harness's config file — the escape
391
+ # hatch for shapes the unified fields don't model, like OpenCode's
392
+ # permission policy map.
393
+ [harness.opencode.settings.permission]
394
+ edit = "deny"
395
+ bash = { "git *" = "allow" }
396
+ ```
397
+
398
+ ### Syncing harness configs
399
+
400
+ `allowed_tools`, `denied_tools`, `hooks`, `settings`, and the top-level
401
+ `[[hooks]]` are **sync settings**: instead of being passed on each invocation,
402
+ **`oneharness sync`** merges them into each harness's *own* project config file
403
+ (the *synced config file* column in the matrix). That makes oneharness a
404
+ config-sync dev tool: state the policy once in `oneharness.toml`, run `sync`,
405
+ and it governs Claude Code, Cursor, Qwen, crush, and OpenCode even when they're
406
+ used directly — oneharness is not needed at run time.
407
+
408
+ Hooks come in two forms. A `[harness.<id>.hooks]` table is written *verbatim*
409
+ in that harness's own hooks schema, so it only reaches harnesses whose hooks
410
+ live in the config file oneharness already syncs (Claude Code). A top-level
411
+ `[[hooks]]` entry is **normalized**: oneharness renders it into each harness's
412
+ native shape and delivers it the right way for that harness — merged into a
413
+ shared file (Claude Code, Qwen, crush), written to a dedicated hooks file
414
+ (Codex, Cursor, Copilot), or installed as a plugin (Goose's manifest +
415
+ `hooks.json`, OpenCode's JS shim). One `[[hooks]]` entry therefore installs the
416
+ same gate into **all eight** harnesses. The per-harness install appears under a
417
+ `hooks` array in each entry of the `sync` JSON report.
418
+
419
+ ```console
420
+ oneharness sync # write/merge the harness config files in this project
421
+ oneharness sync --check # CI mode: exit 1 (writing nothing) if out of sync
422
+ oneharness sync --harness claude-code --cwd ~/proj
423
+ oneharness sync --global # install [[hooks]] into the user-global config (~ / $XDG_CONFIG_HOME)
424
+ ```
425
+
426
+ By default `sync` writes the **project** config files. `--global` instead
427
+ installs the normalized `[[hooks]]` into each harness's **user-global** location
428
+ (`~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.copilot/hooks/…`,
429
+ `$XDG_CONFIG_HOME/crush/crush.json`, `$XDG_CONFIG_HOME/opencode/plugin/…`, etc.),
430
+ so the gate applies to every project. Permission rules and raw `settings` are
431
+ project-scoped only, so configuring them under `--global` is a loud usage error
432
+ rather than a silent half-write.
433
+
434
+ #### The runtime gate (`oneharness gate`)
435
+
436
+ A normalized `[[hooks]]` entry's `command` is what each harness runs before a
437
+ tool call. **`oneharness gate <id>`** is a ready-made such command: it reads the
438
+ harness's pre-tool hook event on **stdin**, and — when the event matches
439
+ `--deny-if-contains <substr>` — emits that harness's native *deny* verdict on
440
+ **stdout** (otherwise nothing, so the call proceeds). It always exits 0, so a
441
+ gate never blocks a call on its own error. The per-harness deny shapes are
442
+ sourced from each CLI's hook protocol. The decision is a deliberately trivial
443
+ substring match: `gate` exists to prove a synced hook is honored end to end (the
444
+ live e2e drives a real harness through it), not to be a policy engine — that is
445
+ [allowlister](https://github.com/nickderobertis/allowlister)'s role, which
446
+ consumes `oneharness-core`'s installer as a library.
447
+
448
+ The merge is deliberately conservative:
449
+
450
+ - **Unrelated keys are never touched** — objects merge per key, and only the
451
+ keys oneharness manages are written.
452
+ - **Lists union** — existing entries keep their order and place; missing ones
453
+ are appended. Re-syncing is therefore idempotent (`sync` adds and updates,
454
+ it never removes — delete by hand or edit the harness file directly).
455
+ - **Scalars oneharness manages take the config's value** — the unified config
456
+ is the source of truth for the keys you declared, and only those.
457
+ - **Unparseable files are refused, untouched** — a JSONC file with comments,
458
+ say, fails loudly rather than being rewritten without them. Writes are
459
+ atomic (temp file + rename), and an existing higher-precedence variant
460
+ (crush's `.crush.json`) is merged into rather than shadowed.
461
+ - **Nothing is dropped silently** — a setting with no mapping for a harness is
462
+ a parse error (per-harness fields) or surfaced as `unmapped` in the JSON
463
+ report plus a stderr warning (top-level fields).
464
+
465
+ To opt out: `--config <path>` loads exactly that file and skips discovery (the
466
+ `ONEHARNESS_<FIELD>` overrides still apply on top); `--no-config` (or
467
+ `ONEHARNESS_NO_CONFIG=1` for wrappers and hermetic test suites) ignores every
468
+ config file **and** the env overrides, leaving only flags and defaults. `detect`
469
+ honors the configured `bin`s too, so it probes the same binaries `run` would
470
+ invoke.
471
+
472
+ **`oneharness config`** is the debugging surface for the layering: it prints
473
+ the effective configuration with every value's provenance — the config file
474
+ path that supplied it, `"environment"` for an `ONEHARNESS_*` override, or
475
+ `"default"` for a built-in — plus per-key attribution
476
+ for `[env]` and per-field attribution for each `[harness.<id>]` section. It
477
+ takes the same `--cwd`, `--config`, and `--no-config` as `run`, so it shows
478
+ exactly what a run from that directory would load:
479
+
480
+ ```console
481
+ $ oneharness config --cwd ~/proj | jq '{config_files, model, timeout}'
482
+ {
483
+ "config_files": ["/home/me/.config/oneharness/config.toml", "/home/me/proj/oneharness.toml"],
484
+ "model": { "value": "gpt-5", "source": "/home/me/proj/oneharness.toml" },
485
+ "timeout": { "value": 30, "source": "/home/me/.config/oneharness/config.toml" }
486
+ }
487
+ ```
488
+
489
+ Which settings can reach which harness is the support table above: `model`,
490
+ `system`, bypass, and output format are per-harness capabilities; `timeout`,
491
+ `env`, `bin`, and `args` are enforced by oneharness and work everywhere.
492
+
493
+ ### Exit codes
494
+
495
+ - `0` — every selected harness was `ok` or `skipped` (or it was a dry run).
496
+ - `1` — at least one harness `nonzero`/`timeout`/`spawn-error`ed (or, under
497
+ `--require-available`, was missing; or, under `--schema`, never produced a
498
+ schema-conforming answer).
499
+ - `2` — usage/configuration error (bad args, unknown harness, no prompt, an
500
+ unreadable or invalid `--schema` file).
501
+
502
+ ### The result envelope vs. the normalized signals
503
+
504
+ The execution envelope — `command`, `exit_code`, `duration_ms`, `status`,
505
+ `stdout`, `stderr` — is **guaranteed and identical** across harnesses.
506
+
507
+ Alongside it, oneharness lifts a few **best-effort** signals out of each
508
+ harness's bespoke stdout so consumers don't have to parse it per harness. Each is
509
+ `null`/empty when it can't be found, is **never fabricated**, and (where there's
510
+ more than one possible method) records how it was found:
511
+
512
+ - `text` / `text_source` — the final assistant message, normalized to one clean
513
+ string across harnesses (`json:result` for Claude Code's terminal event,
514
+ `json:opencode-parts` for OpenCode's JSONL text parts, `stream-json:result` for
515
+ Cursor, `raw` for a plain-text harness, …). **`text` is a convenience, not a
516
+ guarantee: it is `null` whenever extraction isn't possible, and `text_source`
517
+ is then `null` too.** A consumer that needs certainty reads the guaranteed
518
+ `stdout` — when `text` is `null`, `stdout` is the fallback that always carries
519
+ the harness's real output.
520
+ - `usage` / `usage_source` — `{ input_tokens, output_tokens, cache_read_tokens,
521
+ cache_write_tokens, cost_usd }`, each field independently `null` when the harness
522
+ doesn't report it (cost is commonly absent on subscription auth). The `usage`
523
+ object is always present so the shape is stable for cross-harness cost/latency
524
+ tables. `usage_source` records the method: `json` for a harness that reports a
525
+ whole-run total in one event (Claude Code), `json:summed-steps` for one that
526
+ reports per-step usage that oneharness sums (OpenCode). The two cache fields
527
+ surface **provider-side prompt-cache** counts — `cache_read_tokens` is prefix
528
+ tokens served cheaply from cache, `cache_write_tokens` is tokens written to it
529
+ (a.k.a. cache creation) — so a consumer can confirm a repeated/forked run
530
+ actually hit the cache. Cache-reporting support today (the rest leave both cache
531
+ fields `null` — never `0` as a guess):
532
+
533
+ | harness | cache fields | source field(s) |
534
+ |---------|:------------:|-----------------|
535
+ | `claude-code` | ✓ read + write | `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens` |
536
+ | `opencode` | ✓ read + write | summed `part.tokens.cache.{read,write}` |
537
+ | all others | — | (no cache counts emitted; `cursor` emits no usage at all) |
538
+
539
+ Each supported harness has a live drift alarm (`oh_cache_assert` in its
540
+ `scripts/e2e-<id>.sh`): a second run within the cache TTL must surface
541
+ `cache_read_tokens > 0`, proving the extraction matches the real output shape.
542
+ - `session_id` — the handle a harness exposes for continuation, read from the
543
+ snake_case `session_id` (Claude Code, Cursor, Qwen), camelCase `sessionID`
544
+ (OpenCode), or Codex's `thread_id`; feed it back via `run --resume <session>`
545
+ (single-harness) to drive a faithful multi-turn against the real agent, or add
546
+ `--fork` (Claude Code / OpenCode) to branch independent follow-ups off one cached
547
+ prefix. `null` for a harness that emits no id headlessly (Goose, Copilot) — their
548
+ handle is caller-supplied, never scraped (see the support matrix).
549
+ - `failure_kind` / `failure_kind_source` — on a non-zero run, a coarse reason
550
+ (`auth`, `rate_limit`, `model_not_found`, `quota`) so a caller can tell a
551
+ retryable condition from a broken request. This is **distinct from `status`**,
552
+ which only records oneharness's relationship to the process.
553
+
554
+ Coverage is keyed off each harness's documented output shape — Claude Code's
555
+ `result` JSON, OpenCode's JSONL (`text` parts for the answer, `step_finish` for
556
+ usage), Cursor's `stream-json` — and widens as more shapes are sourced; an absent
557
+ signal is the honest answer, not an error. Consumers that need certainty should
558
+ parse `stdout` themselves.
559
+
560
+ ### Structured output
561
+
562
+ `run --schema <path>` constrains each harness's final answer to a [JSON
563
+ Schema](https://json-schema.org/) and validates it, so a programmatic consumer
564
+ gets a checked JSON value instead of prose to parse. The schema is delivered two
565
+ ways, chosen per harness:
566
+
567
+ - **Native** where the CLI supports it — Claude Code's `--json-schema` (with
568
+ `--output-format json`), which returns the conforming value in its result
569
+ document's `structured_output` field. `supports_native_schema` in `oneharness
570
+ list` flags these.
571
+ - **Prompt-based** for every other harness — the schema is appended to the
572
+ prompt as an instruction to emit only a conforming JSON value, which oneharness
573
+ then recovers from the final text (unwrapping a ```` ```json ```` fence or an
574
+ object embedded in prose).
575
+
576
+ Either way oneharness **validates the result itself** (with the
577
+ [`jsonschema`](https://crates.io/crates/jsonschema) crate), so a native flag the
578
+ harness ignores is still caught. On a validation failure it re-prompts the
579
+ harness with the prior answer and the exact errors, up to `--schema-max-retries`
580
+ times (default 2 — so at most `1 + N` invocations per harness). The loop runs
581
+ **per harness, in parallel**, so a `--schema` run across many harnesses is still
582
+ concurrent.
583
+
584
+ > Codex CLI also has a native `--output-schema`, but it takes a schema *file*
585
+ > and is [reportedly ignored once the agent uses tools](https://github.com/openai/codex/issues/15451),
586
+ > so oneharness uses the more reliable prompt-based path for it today. The
587
+ > registry's `native_schema` hook makes adding more native deliveries a
588
+ > one-line, well-tested change.
589
+
590
+ Each result gains four fields (all `null` when no `--schema` was given):
591
+
592
+ - `structured` — the JSON value extracted from the answer and validated. Carries
593
+ the **last-attempted** value even when it failed, so you can see what the
594
+ harness produced; `null` only when no JSON could be extracted at all (never
595
+ fabricated).
596
+ - `schema_valid` — `true`/`false` for the final attempt. A `false` here makes the
597
+ run a failure (exit `1`), so you can gate on "did I actually get conforming
598
+ output".
599
+ - `schema_attempts` — how many times the harness was invoked under the loop
600
+ (`1 + retries`).
601
+ - `schema_error` — the validation errors from the final attempt, joined for
602
+ display; `null` when valid.
603
+
604
+ The top-level report echoes the applied `schema` and `schema_max_retries`. Both
605
+ the schema path and the retry budget are also configurable
606
+ (`schema_file` / `schema_max_retries` in `oneharness.toml`).
607
+
608
+ ```console
609
+ oneharness run --harness claude-code --prompt "extract the person from auth.py" \
610
+ --schema person.json --compact | jq '.results[0].structured'
611
+ ```
612
+
613
+ **Windows note.** A JSON Schema is quote-heavy, and a harness installed as an npm
614
+ `.cmd` shim receives its arguments through cmd.exe's `%*` forwarding, which
615
+ mangles quote-containing arguments. So on Windows the native `--json-schema`
616
+ delivery (and a schema appended to the prompt) may not reach a `.cmd`-shim
617
+ harness intact — structured output is most reliable on Linux/macOS, or on Windows
618
+ against a real `.exe` harness. oneharness's own argv construction and validation
619
+ are exercised on Windows by the hermetic test suite regardless.
620
+
621
+ ### Batch runs (same-prefix prompt caching)
622
+
623
+ A common workload is **many prompts that share a prefix** — the same `--system`
624
+ context (a spec, a big reference doc, few-shot examples) with a different question
625
+ each time. Pass more than one prompt and `run` switches to a **batch**: it drives
626
+ **one** harness over each prompt and returns one report with a result per prompt
627
+ (in order), each tagged with its own `prompt`. The top-level report gains a
628
+ `batch` block (`{ "strategy", "prompt_count", "forked" }`); `results[].prompt` is
629
+ authoritative, and the top-level `prompt` repeats the first for back-compat.
630
+
631
+ ```console
632
+ # 3 questions over one shared context, warming it once then forking:
633
+ oneharness run --harness claude-code --system "$(cat reference.md)" \
634
+ --prompt "Summarize section 2" \
635
+ --prompt "List the open questions" \
636
+ --prompt "What changed since v1?" \
637
+ --batch-strategy min-tokens --compact | jq '.batch, .results[].usage'
638
+ ```
639
+
640
+ Two strategies:
641
+
642
+ - **`speed`** — **the default** — fire all prompts at once for minimum wall-clock.
643
+ Every call is independent; this optimizes latency, not tokens. It is the default
644
+ precisely because the token-saving alternative only helps one harness today (see
645
+ the support matrix below) and never *hurts* — `speed` is the safe choice for any
646
+ harness.
647
+ - **`min-tokens`** — minimize redundant token spend on the shared prefix. On a
648
+ harness whose fork **reuses the cache** (today Claude Code only; see the matrix
649
+ below) it runs the first prompt as a warm-up that establishes a session carrying
650
+ the shared `--system`, then **forks that session** for the remaining prompts, so
651
+ each fanned-out call *reuses* the warmed cached prefix instead of re-sending it.
652
+ The report sets `batch.forked: true`, and the fanned-out results report
653
+ `usage.cache_read_tokens > 0` with a lower `cache_write_tokens` than the warm-up.
654
+ oneharness never claims a saving it can't measure — read the counts. On every
655
+ other harness `min-tokens` falls back to order-only (no saving) with a stderr
656
+ warning, so it is never worse than `speed`.
657
+
658
+ Why fork rather than just repeating `--system`: provider prompt caching keys on
659
+ the harness's byte-exact request prefix, but these CLIs inject per-invocation
660
+ content (Claude Code, for instance, re-creates a user-supplied
661
+ `--append-system-prompt` on every separate `claude -p` process — only its *own*
662
+ global prefix gets cross-process cache reads). So a static `--system` repeated
663
+ across processes is **not** reused; the reliable cross-call reuse is a warmed
664
+ **session**, which is exactly what `--fork` branches from (see
665
+ [`--fork`](#usage)). `min-tokens` operationalizes that.
666
+
667
+ **Support matrix — where `min-tokens` reduces tokens.** The saving needs a
668
+ *cache-reusing fork* (`fork_reuses_cache` in `oneharness list`), which today is
669
+ **Claude Code only**:
670
+
671
+ | harness | token reduction | status |
672
+ | --- | --- | --- |
673
+ | **claude-code** | yes — warm-then-fork, cache reuse | ✅ **confirmed** (live-proven by `oh_batch_fork_enforce`; the underlying provider caching is itself best-effort — see *Caveats*) |
674
+ | opencode | no — its `--fork` re-sends the prefix cold (forking would *raise* tokens), so oneharness keeps it order-only | ⚠️ **known not to help** (measured live) |
675
+ | codex, goose, qwen, crush, copilot, cursor | no — no cache-reusing fork, and no cache-count reporting to even measure one | ⛔ **order-only** (no saving) |
676
+
677
+ So exactly one harness is confirmed to save tokens; every other harness runs
678
+ `min-tokens` as a plain scheduler (results are correct, just no token reduction)
679
+ and oneharness prints a stderr warning rather than implying a saving. Two findings
680
+ shape this (both measured live, not assumed):
681
+
682
+ - **A static `--system` is not reused across separate harness processes.** Even on
683
+ Claude Code (a *native* `--system` harness) a repeated `--append-system-prompt`
684
+ is re-created on every `claude -p` — only the harness's *own* global prefix gets
685
+ cross-process cache reads. The other five non-Goose harnesses merely *prepend*
686
+ `--system` (no cacheable breakpoint), and the six non-fork harnesses report no
687
+ cache counts at all (so a saving couldn't even be observed). So a system-prompt
688
+ approach saves nothing on them.
689
+ - **Only a *cache-reusing* fork helps.** Claude Code's `--fork-session` branches
690
+ from the warmed session and reuses its cached prefix (the fan-out reads it and
691
+ writes little). OpenCode's `--fork` instead re-sends the branched conversation
692
+ cold (the fan-out reads no cache and re-writes the whole prefix — so forking it
693
+ would *raise* tokens), so oneharness leaves OpenCode's `min-tokens` order-only.
694
+
695
+ On every order-only harness `min-tokens` just orders the calls, and oneharness
696
+ says so on stderr rather than implying a saving.
697
+
698
+ **Caveats.** A batch is **single-harness** by nature (a session/cache prefix is
699
+ per harness/model/tools) — selecting more than one harness (or `--all`), or
700
+ combining with `--resume`/`--fork`, is a usage error. The token saving needs a
701
+ harness with a **cache-reusing fork** (`fork_reuses_cache` in `oneharness list` —
702
+ today Claude Code only); on any other harness `min-tokens` only *orders* the calls
703
+ (no reuse) and oneharness says so on stderr. Note that where it does fork, this
704
+ changes the fan-out's semantics: because the fan-out branches from the warm-up's
705
+ turn, the later prompts share the first prompt's context (the fork model — "one
706
+ initial prompt seeds independent follow-ups"), rather than being fully independent
707
+ questions. Caching itself is best-effort and provider-side (a ~5-min TTL refreshed
708
+ on hit, a minimum prefix length, a byte-identical prefix), so the reuse only lands
709
+ when the warmed session's prefix clears the minimum and the fan-out runs within
710
+ its TTL. Use `speed` when you want N strictly-independent answers with no shared
711
+ context.
712
+
713
+ ### Safety note: bypass by default
714
+
715
+ A headless agent run hangs waiting for a human to approve tool calls. `run`'s
716
+ default mode (`default`) maps each harness to its cleanest *non-interactive*
717
+ variant — deny-and-continue, fail-closed, or auto-deny — so it neither hangs nor
718
+ blanket-approves; an agent in `default` mode can read and answer but is denied
719
+ the tools it would otherwise prompt for. To let it take real actions, pass
720
+ `--mode bypass` (or `--bypass`) — the "allow everything" mode — ideally against a
721
+ throwaway sandbox (see `--cwd`). `--mode` (below) selects any other point on the
722
+ spectrum.
723
+
724
+ ### Approval modes
725
+
726
+ Every harness has its own approval vocabulary (Claude Code's `--permission-mode`,
727
+ Codex's `--sandbox`, Qwen's `--approval-mode`, Goose's `GOOSE_MODE`, …).
728
+ `--mode <m>` is oneharness's single spectrum across all of them, from least to
729
+ most autonomy:
730
+
731
+ - **`read-only`** — no mutations; the agent may read but not edit files or run
732
+ commands. *No* plan workflow — it just does whatever read-only work the task
733
+ allows. Mapped to each harness's strongest per-run no-mutation enforcement.
734
+ - **`plan`** — like `read-only`, but additionally engages the harness's native
735
+ *plan* workflow (research the task, write a plan, don't act).
736
+ - **`default`** — the harness's ask flow, mapped to its cleanest non-interactive
737
+ variant.
738
+ - **`edit`** — auto-approve edits, gate commands.
739
+ - **`auto`** — auto-approve what the harness deems safe.
740
+ - **`bypass`** — approve everything (the default).
741
+
742
+ The default when nothing is passed is **`default`**. Each mode is mapped to the
743
+ harness's own mechanism; `oneharness list` shows the per-harness `modes` (each
744
+ tagged `clean` or `hangs`), and the report echoes `permission_mode`. A harness
745
+ that **can't express** a requested mode is a loud usage error *before* anything
746
+ spawns (there's no command to build). A mode that **may block on a prompt**
747
+ headlessly (a `hangs` tag) is warned about on stderr but still run, with the
748
+ `--timeout` as the backstop (a real hang becomes a `timeout` result, never an
749
+ infinite stall); `--permit-prompts` silences that warning once allow-rules are
750
+ synced so the prompt never fires.
751
+
752
+ | `--mode` | claude-code | codex | opencode | goose | qwen | crush | copilot | cursor |
753
+ |------------|:-----------:|:-----:|:--------:|:-----:|:----:|:-----:|:-------:|:------:|
754
+ | `read-only`| ✓ᵈ | ✓ˢ | ✓ᵖ | — | ✓ᵖ | — | ✓ᵈ | ✓ |
755
+ | `plan` | ✓ | ✓ⁱ | ✓ | — | ✓ | — | ✓ | ✓ |
756
+ | `default` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓¹ | ✓ | ⚠ |
757
+ | `edit` | ✓ | — | ✓ᵉ | — | ✓ | — | ✓ | — |
758
+ | `auto` | ✓ | ✓ | — | ✓ | ✓ | — | — | — |
759
+ | `bypass` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
760
+
761
+ ✓ supported & clean headless · ⚠ supported but may block on a prompt headlessly
762
+ (warns + runs; `--timeout` backstops, `--permit-prompts` silences the warning) ·
763
+ — unsupported (refused). `read-only` is **enforced** where marked — ˢ Codex's
764
+ read-only sandbox (OS-enforced), ᵈ deny rules (Claude's `--disallowedTools Bash
765
+ Edit Write NotebookEdit`, Copilot's `--deny-tool shell/write` — deny beats
766
+ allow) — and ᵖ behavioral where its only mechanism is the plan agent (OpenCode
767
+ `--agent plan`, Qwen `--approval-mode plan`, so `read-only` and `plan` coincide
768
+ there). Cursor's `read-only` is native `--mode ask`. Codex has no *native* plan
769
+ mode in `exec`, so `plan` (ⁱ) is synthesized — the read-only sandbox enforces
770
+ no-mutation and a plan instruction is prepended to the prompt, reproducing
771
+ Codex's own interactive Plan mode (= read-only sandbox + a plan template). Goose
772
+ has no plan workflow and its only no-mutation option (`chat`) disables reads too,
773
+ so it offers neither plan nor read-only (a plan *instruction* alone can't help —
774
+ it has no read-only *enforcement* to stop the agent acting); Crush's `run` can't
775
+ gate, so
776
+ it supports only `default`/`bypass` (¹ it auto-approves the whole session, so the
777
+ two are identical). Only **Cursor's `default`** can still block on a prompt (no
778
+ fail-fast deny) — every other harness's `default` is clean: it maps to that
779
+ harness's cleanest non-interactive variant — Claude Code's `dontAsk`
780
+ (deny-and-continue), Codex's read-only exec, Goose's fail-closed `approve`,
781
+ Copilot's auto-deny, and OpenCode/Qwen auto-*reject* gated tools and continue
782
+ rather than hang. Modes ride the argv except: Goose carries the whole spectrum in
783
+ `GOOSE_MODE`, and OpenCode's `edit` (ᵉ) rides the inline-config env var
784
+ `OPENCODE_CONFIG_CONTENT` (its per-tool `permission` map has no argv flag).
785
+ Copilot's `edit` is a composed `--allow-tool write --allow-tool read` list (shell
786
+ omitted → auto-denied); `edit`/`auto` for Cursor remain a `permission` config
787
+ concern (`oneharness sync`), not `--mode`.
788
+
789
+ Relatedly, a harness can carry a small **default environment** so headless runs
790
+ stay clean — e.g. oneharness sets `QWEN_CODE_SUPPRESS_YOLO_WARNING=1` for Qwen
791
+ Code so its `--yolo`/no-sandbox startup warning doesn't litter `stderr`. These
792
+ defaults are per-harness data in the registry, and an explicit `--env KEY=VALUE`
793
+ always overrides them.
794
+
795
+ ## Why it exists
796
+
797
+ [`nickderobertis/allowlister`](https://github.com/nickderobertis/allowlister)
798
+ verifies its policy engine against **every** real agent CLI. Each check had its
799
+ own bash `run_agent()` — Claude wants `-p … --permission-mode bypassPermissions
800
+ --output-format stream-json`, OpenCode wants `run --dangerously-skip-permissions
801
+ --format json`, Codex wants `exec --dangerously-bypass-approvals-and-sandbox`, and
802
+ so on — plus
803
+ its own timeout, output capture, and skip-if-missing logic.
804
+
805
+ `oneharness` collapses that to one call per check:
806
+
807
+ ```bash
808
+ # before: ~40 lines of harness-specific bash per agent
809
+ # after:
810
+ result="$(oneharness run --harness claude-code \
811
+ --prompt "$prompt" --cwd "$proj" --timeout 150 --compact)"
812
+ status="$(jq -r '.results[0].status' <<<"$result")"
813
+ ```
814
+
815
+ The same uniform interface is the intended driver for a future **cross-harness
816
+ skill-testing framework**: set up a sandbox, fire one prompt at every harness via
817
+ `oneharness run --all`, and assert on the JSON.
818
+
819
+ ## Development
820
+
821
+ ```console
822
+ just bootstrap # toolchain components + fetch (works from a clean clone)
823
+ just check # full gate: fmt-check, clippy -D warnings, shellcheck, tests, build, smoke
824
+ just test # tests only
825
+ just smoke # hermetic end-to-end smoke of the built binary
826
+ just run -- list # run the CLI through cargo
827
+ ```
828
+
829
+ The gate uses [`just`](https://github.com/casey/just) (pinned in `.tool-versions`
830
+ for asdf/mise users) and [`shellcheck`](https://github.com/koalaman/shellcheck)
831
+ for the shell scripts; CI installs both, so install `shellcheck`
832
+ (`apt-get`/`brew install shellcheck`) to run the full gate locally.
833
+
834
+ Tests are hermetic: the subprocess path is exercised against a mock harness
835
+ fixture (no network, no real CLI), and every adapter's command construction is
836
+ pinned with `--print-command` assertions. `just check` also runs
837
+ `scripts/smoke.sh`, an end-to-end smoke of the *built* binary, including a
838
+ local-release installer check that drives `scripts/install.sh` without network.
839
+ To exercise the real harnesses you have installed, run `just smoke-live` — it
840
+ makes real model calls, skips any harness that isn't installed, and is
841
+ intentionally never part of the gate or CI. See `AGENTS.md` and
842
+ `tests/AGENTS.md`.
843
+
844
+ ## Live end-to-end testing
845
+
846
+ `just smoke-live` is the quick "does any installed harness work" check. The
847
+ **per-harness** suite is the allowlister-style counterpart: each
848
+ `scripts/e2e-<harness>.sh` drives one *real* harness through `oneharness` with
849
+ that provider's model/auth and asserts the JSON contract end to end — it plants
850
+ a high-entropy marker, asks the harness (via `oneharness run`) to echo exactly
851
+ that marker, and asserts `status == ok`, `exit_code == 0`, and that the marker
852
+ surfaced. So a pass means the model genuinely ran, not just that the process
853
+ exited. A missing CLI or missing auth is a **skip**, never a failure.
854
+
855
+ For the sync-capable harnesses (Claude Code, OpenCode, Qwen, Crush, Cursor)
856
+ the live check also proves **sync enforcement** end to end: it syncs an
857
+ allow + deny policy into the harness's own config file, then drives the real
858
+ CLI with `--no-bypass` — the allowed `touch` must execute (the positive
859
+ control) and the denied one must not. This is the only tier that can prove a
860
+ synced file is *honored*, not merely written; it doubles as the drift alarm
861
+ for the encoded config formats.
862
+
863
+ The live check also proves **hook enforcement** the same way: it syncs a
864
+ `[[hooks]]` entry whose command is `oneharness gate <id>` into the harness's own
865
+ config, then drives the real CLI under bypass (so the hook is the sole decider)
866
+ through a marked command (the gate must block it) and an unmarked one (the gate
867
+ must let it run). For **Qwen** the gate is synced with `--global` — Qwen only
868
+ fires user-scoped hooks headlessly — which also exercises `sync --global` live.
869
+ Two harnesses are excluded by design: **Codex** (`oneharness run` drives `codex
870
+ exec`, which does not load hooks) and **Copilot** (its project hooks sit behind a
871
+ trusted-folder + prompt-mode setup that belongs in allowlister's adapter e2e);
872
+ both keep their hermetic install coverage.
873
+
874
+ Alongside the per-harness checks there is a **per-feature** one for structured
875
+ output: `scripts/e2e-schema.sh` (`just live-schema`) drives the real Claude Code
876
+ CLI through `oneharness run --schema` and asserts a schema-**valid** round-trip —
877
+ it plants a marker, asks for a conforming JSON object carrying it, and checks
878
+ `schema_valid == true` with the marker in `.structured`. claude-code is chosen
879
+ because it is the one with *native* delivery (`--json-schema` →
880
+ `structured_output`); this is the live drift alarm for that flag and field, which
881
+ the hermetic suite can only mock. (The portable prompt-based path is harness-
882
+ agnostic; any per-harness script can add a live leg by calling
883
+ `oh_schema_enforce <id>`.)
884
+
885
+ ```console
886
+ just live-claude # one harness (installs the release binary, runs the live check)
887
+ just live-schema # the structured-output feature (drives claude-code via --schema)
888
+ just live-all # every harness + feature in sequence; skips pass, only real failures fail
889
+ ```
890
+
891
+ Each harness needs its CLI installed and that provider's auth in the environment:
892
+
893
+ | harness | install | auth env var(s) |
894
+ |---------|---------|-----------------|
895
+ | `claude-code` | `npm i -g @anthropic-ai/claude-code` | `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) |
896
+ | `codex` | `npm i -g @openai/codex` | `OPENAI_API_KEY` |
897
+ | `opencode` | `npm i -g opencode-ai` | `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY`) |
898
+ | `goose` | [installer](https://block.github.io/goose/docs/getting-started/installation) | `OPENAI_API_KEY` + `GOOSE_PROVIDER`/`GOOSE_MODEL` |
899
+ | `qwen` | `npm i -g @qwen-code/qwen-code` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) |
900
+ | `crush` | `npm i -g @charmland/crush` | `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY`) |
901
+ | `copilot` | `npm i -g @github/copilot` | `COPILOT_GITHUB_TOKEN` |
902
+ | `cursor` | [installer](https://docs.cursor.com/en/cli/overview) | `CURSOR_API_KEY` |
903
+
904
+ Per-harness CI workflows (`.github/workflows/e2e-*.yml`) run the same checks,
905
+ each gated to the canonical repo and non-fork PRs so secrets are never exposed.
906
+ The structured-output feature has its own (`e2e-schema.yml`), reusing the Claude
907
+ auth secret. Locally a missing CLI or auth is a clean **skip**, but those
908
+ workflows set `OH_E2E_NO_SKIP=1`, which turns any skip into a hard **failure**:
909
+ in CI the harness is installed and auth verified up front, so a skip there can
910
+ only mean detection/install/spawn silently broke (classically an unresolved npm
911
+ `.cmd` shim on Windows) and the job would otherwise go green having run zero
912
+ model calls. A genuine per-platform gap is therefore expressed as a matrix
913
+ exclude or an `if`-guarded phase, never a runtime skip.
914
+ Every workflow runs a `fail-fast: false` matrix across **Linux, macOS, and
915
+ Windows** (`ubuntu-latest`, `macos-latest`, `windows-latest`), so the adapter
916
+ argv, JSON contract, and sync/hook enforcement are proven on each platform
917
+ independently — the scripts run under bash everywhere (Git Bash on Windows),
918
+ and the two `curl | bash` installers (cursor, goose) use their PowerShell
919
+ equivalents on Windows. A per-harness model can be overridden with
920
+ `<HARNESS>_E2E_MODEL` (e.g. `CLAUDE_E2E_MODEL`, `OPENCODE_E2E_MODEL`).
921
+
922
+ The one per-platform gap is **cursor hook enforcement on Windows**: cursor-agent
923
+ builds its hook command as a PowerShell wrapper but executes it through bash
924
+ (Git Bash on `PATH`), so the wrapper dies on a syntax error and cursor blocks
925
+ every command. This is an [acknowledged cursor-agent bug][cursor-shell-bug] with
926
+ no shell flag, config field, or env lever (`$SHELL` and `$COMSPEC` are ignored;
927
+ the only workaround is WSL), so that single phase is skipped on `windows-latest`.
928
+ Cursor's echo and sync enforcement still run on Windows, and hook enforcement is
929
+ still proven on Linux and macOS. Every other harness's hook enforcement runs on
930
+ all three platforms.
931
+
932
+ [cursor-shell-bug]: https://forum.cursor.com/t/agent-cli-on-windows-no-way-to-configure-shell-hardcoded-to-powershell-no-shell-flag-or-config-option/151858
933
+
934
+ ### Secrets
935
+
936
+ The auth above is managed with [`gh-secrets`](https://github.com/nickderobertis/github-secrets):
937
+ [`gh-secrets.json`](gh-secrets.json) is a committed manifest that pulls each
938
+ secret from Bitwarden (secure notes) and pushes it to two destinations — a local
939
+ `.env` (for `just live-*`) and the repo's GitHub Actions secrets (for the
940
+ workflows). `COPILOT_GITHUB_TOKEN` is sourced from the `GH_TOKEN` vault item.
941
+
942
+ ```console
943
+ just secrets-sync # gh-secrets manifest sync: Bitwarden -> .env + GitHub Actions
944
+ ```
945
+
946
+ The manifest names *which* secrets go *where*; the values never touch the repo.
947
+ `.env` and the sync-state file are gitignored.
948
+
949
+ ## Releasing
950
+
951
+ Releases are automated from [conventional commits](https://www.conventionalcommits.org)
952
+ by [release-plz](https://release-plz.dev) — do not hand-bump the version or
953
+ `CHANGELOG.md`. Land commits on `main` (`feat` → minor, `fix`/`perf` → patch,
954
+ `!`/`BREAKING` → major; `docs`/`test`/`chore`/`ci` do not release), and
955
+ release-plz opens a `release vX.Y.Z` PR that bumps `Cargo.toml`/`Cargo.lock` and
956
+ writes the changelog. That PR auto-merges once the gate is green, then release-plz:
957
+
958
+ 1. runs `cargo publish` for **both crates** — `oneharness-core` first, then the
959
+ `oneharness` binary that depends on it — so they land on
960
+ [crates.io](https://crates.io/crates/oneharness);
961
+ 2. tags `vX.Y.Z` and cuts the GitHub Release;
962
+ 3. that Release fires `.github/workflows/release.yml`, which re-runs the gate,
963
+ attaches archived, sha256-checksummed binaries for Linux, macOS, and Windows,
964
+ signs each archive with a keyless Sigstore build-provenance attestation and
965
+ publishes its `.sigstore.json` bundle (see
966
+ [Supply-chain verification](#supply-chain-verification)), builds per-platform
967
+ PyPI wheels with maturin, and publishes them to
968
+ [PyPI](https://pypi.org/project/oneharness-cli/) via Trusted Publishing.
969
+
970
+ So each release ships four ways: [PyPI](https://pypi.org/project/oneharness-cli/)
971
+ (`pip install oneharness-cli`), crates.io (`cargo install oneharness`), the
972
+ GitHub Release binaries, and `cargo install --git`. Only the binary gets a
973
+ `vX.Y.Z` tag and GitHub Release; `oneharness-core` is published to crates.io and
974
+ tagged in its own `oneharness-core-v*` namespace (no GitHub Release) so its
975
+ version never collides with the binary's `vX.Y.Z` tags.
976
+
977
+ PyPI publishing is keyless [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
978
+ (OIDC — no token secret), and stays dormant until the `PYPI_PUBLISH` repo
979
+ variable is set to `true`; the wheels still build on every release so a packaging
980
+ break surfaces early. Activating it requires the PyPI project `oneharness-cli` to
981
+ register this repo's `release.yml` as a Trusted Publisher (no GitHub Actions
982
+ environment).
983
+
984
+ Two repo secrets gate the automation (the workflow no-ops until both are set):
985
+ `RELEASE_PLZ_TOKEN` (a PAT with `contents: write` + `pull-requests: write`) and
986
+ `CARGO_REGISTRY_TOKEN` (a crates.io API token). Creating a GitHub Release by hand
987
+ (`gh release create vX.Y.Z`) is the supported fallback for the binaries if the
988
+ automation is wedged, but it does **not** publish to crates.io.
989
+
990
+ ## License
991
+
992
+ MIT — see [LICENSE](LICENSE).
993
+