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.
- oneharness_cli-0.3.5.data/scripts/oneharness.exe +0 -0
- oneharness_cli-0.3.5.dist-info/METADATA +993 -0
- oneharness_cli-0.3.5.dist-info/RECORD +6 -0
- oneharness_cli-0.3.5.dist-info/WHEEL +4 -0
- oneharness_cli-0.3.5.dist-info/licenses/LICENSE +21 -0
- oneharness_cli-0.3.5.dist-info/sboms/oneharness.cyclonedx.json +4098 -0
|
@@ -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
|
+
|