opencode-turnstile 0.1.1 → 0.2.0

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.
package/README.md CHANGED
@@ -65,7 +65,7 @@ turnstile unchanged unless a gate applies. Other adjacent approaches
65
65
  gate on written artifacts, like spec workflows, or on a single
66
66
  human-approval point; turnstile gates the machine steps around those.
67
67
  The bundled planner/executor/test-runner/reviewer agents in
68
- `.opencode/agents/` are the reference pipeline, not a requirement; the
68
+ `agents/` are the reference pipeline, not a requirement; the
69
69
  gates key off agent names and markers, and other suites' agents can
70
70
  carry their own markers.
71
71
 
@@ -211,19 +211,93 @@ to touch.
211
211
 
212
212
  ### Set the agents' models
213
213
 
214
- The six agent definitions in `.opencode/agents/` ship with `model:`
215
- frontmatter pointing at the author's locally served models (`oMLX/...`).
216
- You almost certainly do not have those models, and dispatches to the
217
- pipeline agents will fail or fall back until you set your own.
214
+ The six agent definitions in `agents/` ship **without** a `model:`
215
+ frontmatter key. Each subagent therefore runs on the model of the session
216
+ that dispatched it — OpenCode v2's documented inheritance rule — so there
217
+ is nothing to configure for the common case.
218
+
219
+ To pin a specific model per agent, paste a block into
220
+ `~/.config/opencode/opencode.json` (**plural** `agents` is the v2 key;
221
+ v1 used singular `agent`):
222
+
223
+ ```jsonc
224
+ {
225
+ "$schema": "https://opencode.ai/config.json",
226
+ "agents": {
227
+ "planner": { "model": "anthropic/claude-haiku-4-5" },
228
+ "plan-reviewer": { "model": "anthropic/claude-haiku-4-5" },
229
+ "executor": { "model": "anthropic/claude-sonnet-4-5" },
230
+ "test-runner": { "model": "anthropic/claude-haiku-4-5" },
231
+ "reviewer": { "model": "anthropic/claude-sonnet-4-5" },
232
+ "debugger": { "model": "anthropic/claude-sonnet-4-5" },
233
+ },
234
+ }
235
+ ```
236
+
237
+ Model IDs come from `opencode models` and take the form
238
+ `provider/modelID`, with an optional `#variant`. Restarts pick the change
239
+ up. Because the shipped definitions are silent on `model:`, a pin you add
240
+ is the only definition of that model — there is no merge precedence to
241
+ reason about.
242
+
243
+ Copy-mode users: after a `git pull` that changes these definitions,
244
+ re-run `npm run install:copy` to pick up the new frontmatter. `npm run
245
+ install:check` will report `stale` (not `conflict`) until you do.
246
+
247
+ ### Printing a model config
248
+
249
+ To generate a `{"agents": {…}}` snippet instead of copying the six agent
250
+ names by hand, run the printer. It writes nothing — it short-circuits before
251
+ any install work, so the ledger, links, and copies are untouched:
252
+
253
+ ```sh
254
+ npm run init -- --print-model-config
255
+ ```
256
+
257
+ That prints one entry per agent, each defaulting to a placeholder:
258
+
259
+ ```json
260
+ {
261
+ "agents": {
262
+ "planner": "<provider>/<model-id>",
263
+ "plan-reviewer": "<provider>/<model-id>",
264
+ "executor": "<provider>/<model-id>",
265
+ "test-runner": "<provider>/<model-id>",
266
+ "reviewer": "<provider>/<model-id>",
267
+ "debugger": "<provider>/<model-id>"
268
+ }
269
+ }
270
+ ```
271
+
272
+ Seed individual agents with repeatable `--model` seeds. A bare
273
+ `provider/model` (optionally `#variant`) seeds all six; `name=` seeds one.
274
+ Model ids may be multi-segment — providers require interior slashes, e.g.
275
+ `oMLX/oQ/GLM-5.3-Flash-oQ4-mtp` — so the model body is everything after the
276
+ first slash.
277
+
278
+ ```sh
279
+ npm run init -- --print-model-config \
280
+ --model planner=anthropic/claude-haiku-4-5 \
281
+ --model executor=oMLX/oQ/GLM-5.3-Flash-oQ4-mtp
282
+ ```
283
+
284
+ Add `--validate` to cross-check every concrete seed against
285
+ `opencode models` and exit 1 naming a model it does not list; placeholder
286
+ seeds are skipped. `--model` and `--validate` are only valid with
287
+ `--print-model-config`: given without it, install.mjs prints an actionable
288
+ message and exits 2.
289
+
290
+ ```sh
291
+ npm run init -- --print-model-config --model planner=anthropic/claude-haiku-4-5 --validate
292
+ ```
218
293
 
219
- Since installs share the repo's files (symlink mode) or conflict on
220
- divergence (copy mode), set your models **in the clone**, not in
221
- `~/.config/opencode/agents/`: edit the `model:` line of each agent file
222
- (`planner`, `plan-reviewer`, `executor`, `test-runner`, `reviewer`,
223
- `debugger`) to a model
224
- you have, in `providerID/modelID` form, then install or re-sync per the
225
- mode table above. Model overrides in installed copies are lost on the next
226
- sync; the repo is the source of truth.
294
+ Paste the output into `~/.config/opencode/opencode.json` (see the
295
+ [Set the agents' models](#set-the-agents-models) section).
296
+ `opencode-turnstile init -- --print-model-config` routes the same flags
297
+ through `scripts/install.mjs` and adds nothing of its own: `install.mjs`
298
+ recognizes `--print-model-config` and prints before writing, while the router
299
+ still idempotently installs the contract block in
300
+ `~/.config/opencode/AGENTS.md` in the same run.
227
301
 
228
302
  ### Customizing the pipeline agents
229
303
 
@@ -244,7 +318,7 @@ Two sanctioned ways to change the pipeline:
244
318
  hold to the marker contract. Your agents get the same gates the shipped
245
319
  ones do.
246
320
  - **Fork the repo.** To change the pipeline content itself, fork and edit
247
- `.opencode/agents/*.md` there; your installer serves your fork.
321
+ `agents/*.md` there; your installer serves your fork.
248
322
 
249
323
  ### Updating
250
324
 
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Investigates failures, identifies root causes, implements targeted fixes, and verifies the result.
3
3
  mode: subagent
4
- model: oMLX/oQ/GLM-5.3-Flash-oQ4-mtp
5
4
  # Runaway-loop backstop for the Horizon rule below: near the cap OpenCode
6
5
  # drops tools and asks for a summary. Tune to your workflow.
7
6
  steps: 50
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Implements an approved plan, validates changes, and fixes test failures
3
3
  mode: subagent
4
- model: oMLX/oQ/Ornith-1.5-35B-A3B-oQ8-mtp
5
4
  # Runaway-loop backstop for the Horizon rule below: near the cap OpenCode
6
5
  # drops tools and asks for a summary. Tune to your workflow.
7
6
  steps: 50
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Adversarially reviews an implementation plan for requirement coverage, AC traceability, feasibility, and scope before any code is written
3
3
  mode: subagent
4
- model: oMLX/oQ/GLM-5.3-Flash-oQ4-mtp
5
4
  permissions:
6
5
  - { action: edit, resource: "*", effect: deny }
7
6
  - { action: shell, resource: "*", effect: allow }
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Analyzes requirements, explores the repository, designs implementation approaches by evaluating tradeoffs, and creates implementation plans without modifying source code
3
3
  mode: subagent
4
- model: oMLX/oQ/Qwen3.8-Flash-Next-oQ4-mtp
5
4
  permissions:
6
5
  - { action: edit, resource: "*", effect: deny }
7
6
  - { action: edit, resource: ".opencode/plans/**", effect: allow }
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Independently reviews an implementation for correctness, regressions, missing requirements, and inadequate tests
3
3
  mode: subagent
4
- model: oMLX/oQ/GLM-5.3-Flash-oQ4-mtp
5
4
  permissions:
6
5
  - { action: edit, resource: "*", effect: deny }
7
6
  - { action: shell, resource: "*", effect: allow }
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  description: Runs tests and verification commands, analyzes failures, and applies scoped fixes to test or environment issues only.
3
3
  mode: subagent
4
- model: oMLX/oQ/Ornith-1.5-35B-A3B-oQ8-mtp
5
4
  permissions:
6
5
  # Mechanical backstop for the Constraints section: default-deny edits,
7
6
  # allow the conventional test layout. Widen or narrow per repo layout.
package/bin/cli.mjs CHANGED
@@ -30,6 +30,14 @@ Commands:
30
30
  uninstall Remove installed artifacts (agents + block)
31
31
  version Print the installed package version
32
32
 
33
+ init also accepts convenience flags (passthrough to scripts/install.mjs); separate them from the subcommand with a leading "--":
34
+ init -- --print-model-config [--model [name=]provider/model[#variant]] [--validate]
35
+
36
+ --print-model-config Print a {"agents": {...}} model snippet, write nothing
37
+ --model Seed an agent (repeatable); ids may be multi-segment, e.g. oMLX/oQ/GLM-5.3-Flash-oQ4-mtp
38
+ --validate Check concrete specs against "opencode models"
39
+ (--model/--validate require --print-model-config, else exit 2)
40
+
33
41
  A git checkout installs by linking (live edits); a package install copies.`
34
42
 
35
43
  /**
@@ -59,7 +67,19 @@ async function main() {
59
67
  // managed contract block in ~/.config/opencode/AGENTS.md, mirroring
60
68
  // uninstall's two-step shape in reverse. Passthrough flags go to the
61
69
  // manifest installer; the agents installer needs none of them.
62
- const byInstall = await spawnInstaller(installScript, passthrough)
70
+ //
71
+ // --print-model-config is passthrough too: install.mjs prints the
72
+ // snippet and short-circuits before any manifest work, but the router
73
+ // still runs BOTH steps, so the contract block is idempotently
74
+ // installed as usual. Print mode writes nothing; the two-step install
75
+ // is the router's idempotent contract-block behavior, which is out of
76
+ // scope for the write-free printer flag.
77
+ //
78
+ // A leading "--" separates the subcommand from its flags
79
+ // (`init -- --print-model-config`); install.mjs rejects a literal "--"
80
+ // as an unknown argument, so drop it before forwarding.
81
+ const initPassthrough = passthrough[0] === "--" ? passthrough.slice(1) : passthrough
82
+ const byInstall = await spawnInstaller(installScript, initPassthrough)
63
83
  const byAgents = await spawnInstaller(agentsScript, [])
64
84
  return process.exit(byInstall || byAgents ? 1 : 0)
65
85
  }
package/install.json CHANGED
@@ -6,27 +6,27 @@
6
6
  "dest": "~/.config/opencode/plugins/turnstile.ts"
7
7
  },
8
8
  {
9
- "src": ".opencode/agents/plan-reviewer.md",
9
+ "src": "agents/plan-reviewer.md",
10
10
  "dest": "~/.config/opencode/agents/plan-reviewer.md"
11
11
  },
12
12
  {
13
- "src": ".opencode/agents/planner.md",
13
+ "src": "agents/planner.md",
14
14
  "dest": "~/.config/opencode/agents/planner.md"
15
15
  },
16
16
  {
17
- "src": ".opencode/agents/executor.md",
17
+ "src": "agents/executor.md",
18
18
  "dest": "~/.config/opencode/agents/executor.md"
19
19
  },
20
20
  {
21
- "src": ".opencode/agents/test-runner.md",
21
+ "src": "agents/test-runner.md",
22
22
  "dest": "~/.config/opencode/agents/test-runner.md"
23
23
  },
24
24
  {
25
- "src": ".opencode/agents/reviewer.md",
25
+ "src": "agents/reviewer.md",
26
26
  "dest": "~/.config/opencode/agents/reviewer.md"
27
27
  },
28
28
  {
29
- "src": ".opencode/agents/debugger.md",
29
+ "src": "agents/debugger.md",
30
30
  "dest": "~/.config/opencode/agents/debugger.md"
31
31
  },
32
32
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-turnstile",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode plugin that mechanically enforces agent pipeline gates (planner → executor → test-runner → reviewer)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  "install.json",
16
16
  "templates",
17
17
  "skills",
18
- ".opencode/agents",
18
+ "agents",
19
19
  "README.md",
20
20
  "LICENSE"
21
21
  ],
@@ -42,10 +42,10 @@ for (const line of lines) {
42
42
  }
43
43
 
44
44
  // Agent templates: the backticked GATE:/VERDICT: examples in
45
- // .opencode/agents/*.md are the prompt-side half of the marker contract.
45
+ // agents/*.md are the prompt-side half of the marker contract.
46
46
  // Templates spell the numeric fields <n>; they must parse once the
47
47
  // placeholder stands in for digits.
48
- const agentsDir = join(root, ".opencode", "agents")
48
+ const agentsDir = join(root, "agents")
49
49
  let agentTemplates = 0
50
50
  for (const f of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
51
51
  const text = readFileSync(join(agentsDir, f), "utf8")
@@ -60,7 +60,7 @@ for (const f of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
60
60
  }
61
61
  }
62
62
  if (agentTemplates === 0) {
63
- console.error("check-markers: no marker templates found in .opencode/agents")
63
+ console.error("check-markers: no marker templates found in agents/")
64
64
  failed++
65
65
  }
66
66
 
@@ -18,6 +18,17 @@
18
18
  // --link/--copy Override the mode. Resolution: CLI flag > manifest "mode"
19
19
  // field > auto-detect (.git present => link, else copy)
20
20
  //
21
+ // --print-model-config Print a ready-to-paste {"agents": {...}} model
22
+ // snippet to stdout and write nothing (short-circuits before
23
+ // any manifest/install work, like --check). Repeatable
24
+ // --model specs seed individual agents:
25
+ // --model [name=]provider/model[#variant] (bare => all six;
26
+ // name= sets one; last spec wins per agent). Model ids may be
27
+ // multi-segment (e.g. oMLX/oQ/GLM-5.3-Flash-oQ4-mtp).
28
+ // --validate cross-checks concrete specs against
29
+ // `opencode models` (exit 1 naming a model it does not list).
30
+ // --model/--validate without --print-model-config exit 2.
31
+ //
21
32
  // Ownership model: symlink targets are repo-owned iff the link resolves
22
33
  // inside this checkout; copied files are repo-owned iff the ledger records
23
34
  // them. Unowned files are never read, modified, or removed. Conflicts
@@ -36,6 +47,7 @@ import {
36
47
  lstat,
37
48
  realpath,
38
49
  } from "node:fs"
50
+ import { spawnSync } from "node:child_process"
39
51
  import { createHash } from "node:crypto"
40
52
  import { homedir } from "node:os"
41
53
  import { dirname, isAbsolute, join, relative, resolve } from "node:path"
@@ -66,7 +78,7 @@ try {
66
78
  /**
67
79
  * Parses command-line arguments.
68
80
  * @param {string[]} argv Raw argv (process.argv).
69
- * @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null}} Parsed arguments.
81
+ * @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null, model: string[], printModelConfig: boolean, validate: boolean, modelSpecs: Record<string,string>}} Parsed arguments. A `--model`/`--validate` without `--print-model-config` is an error and exits 2 (checked before any parsing or I/O). Model specs are validated here only when the printer flag is set; a malformed or unknown-name spec prints an actionable message and exits 2 (matching the unknown-arg contract), and the parsed map is stashed as `modelSpecs` for `main`.
70
82
  */
71
83
  function parseArgs(argv) {
72
84
  const args = {
@@ -75,6 +87,10 @@ function parseArgs(argv) {
75
87
  force: false,
76
88
  uninstall: false,
77
89
  mode: null,
90
+ model: [],
91
+ printModelConfig: false,
92
+ validate: false,
93
+ modelSpecs: {},
78
94
  }
79
95
  for (let i = 2; i < argv.length; i++) {
80
96
  if (argv[i] === "--manifest") args.manifest = argv[++i]
@@ -83,14 +99,170 @@ function parseArgs(argv) {
83
99
  else if (argv[i] === "--uninstall") args.uninstall = true
84
100
  else if (argv[i] === "--link") args.mode = "link"
85
101
  else if (argv[i] === "--copy") args.mode = "copy"
102
+ else if (argv[i] === "--model") args.model.push(argv[++i])
103
+ else if (argv[i] === "--print-model-config") args.printModelConfig = true
104
+ else if (argv[i] === "--validate") args.validate = true
86
105
  else {
87
106
  console.error(`install: unknown argument ${argv[i]}`)
88
107
  process.exit(2)
89
108
  }
90
109
  }
110
+ // --model/--validate only mean something for the printer: given without
111
+ // --print-model-config they have no effect, so reject them loudly (exit 2,
112
+ // like an unknown argument) instead of silently falling through to a real
113
+ // install. Checked first, before any parsing or manifest I/O below.
114
+ if (!args.printModelConfig && (args.model.length > 0 || args.validate)) {
115
+ console.error("install: --validate/--model require --print-model-config")
116
+ process.exit(2)
117
+ }
118
+ // A --model spec only matters for the printer; validate it up front (so a
119
+ // bad one exits 2 like an unknown argument) and stash the parsed map for
120
+ // `main`.
121
+ if (args.printModelConfig) {
122
+ try {
123
+ args.modelSpecs = parseModelSpecs(args.model)
124
+ } catch (error) {
125
+ console.error(`install: ${error.message}`)
126
+ process.exit(2)
127
+ }
128
+ }
91
129
  return args
92
130
  }
93
131
 
132
+ // The six pipeline agents, in canonical order. This is the single source of
133
+ // truth for both the printer (formatModelConfig) and the validator
134
+ // (findMissingModels), so the JSON block and the --validate pass can never
135
+ // disagree on which agents exist.
136
+ const AGENT_NAMES = ["planner", "plan-reviewer", "executor", "test-runner", "reviewer", "debugger"]
137
+
138
+ /** The placeholder used by `formatModelConfig` for any agent left unset. */
139
+ const MODEL_PLACEHOLDER = "<provider>/<model-id>"
140
+
141
+ /**
142
+ * Human-readable model-spec grammar shown in error messages — never the raw
143
+ * regex source. The name binds at the `=`, the provider to the first segment,
144
+ * and a `#variant` to the tail; a model id may itself be multi-segment
145
+ * (e.g. `oMLX/oQ/GLM-5.3-Flash-oQ4-mtp`), so "model" spans interior slashes.
146
+ */
147
+ const SPEC_GRAMMAR = "[name=]provider/model[#variant] (model ids may be multi-segment)"
148
+
149
+ /**
150
+ * Grammar for one model spec: an optional `<name>=` built from AGENT_NAMES
151
+ * (a single source of truth), then provider (segment 1) and a model id (the
152
+ * remainder, slashes included) so real multi-segment ids parse, then an
153
+ * optional `#variant` bound to the tail. Split on the FIRST `/`.
154
+ */
155
+ const NAME_ALT = AGENT_NAMES.join("|")
156
+ const SPEC_RE = new RegExp(`^(?:(${NAME_ALT})=)?([\\w.-]+)/([\\w.:/-]+)(?:#([\\w.-]+))?$`)
157
+
158
+ /**
159
+ * Parses model specs into an agent→spec map, in canonical order. A bare spec
160
+ * (no `<name>=` prefix) seeds all six agents; `name=` seeds exactly one; the
161
+ * last spec to name an agent wins. Pure: no filesystem, no spawn.
162
+ * @param {string[]} specs The raw --model spec strings (possibly empty).
163
+ * @returns {Record<string, string>} Map of agent name to spec.
164
+ * @throws {Error} On an unknown agent name (lists the six) or a malformed spec
165
+ * (shows the grammar).
166
+ */
167
+ function parseModelSpecs(specs) {
168
+ const result = {}
169
+ for (const spec of specs) {
170
+ if (typeof spec !== "string")
171
+ throw new Error(`invalid --model spec "${spec}"; expected ${SPEC_GRAMMAR}`)
172
+ const eq = spec.indexOf("=")
173
+ let name = null
174
+ let body = spec
175
+ if (eq !== -1) {
176
+ name = spec.slice(0, eq)
177
+ body = spec.slice(eq + 1)
178
+ if (!AGENT_NAMES.includes(name))
179
+ throw new Error(
180
+ `unknown agent "${name}" in --model spec; expected one of ${AGENT_NAMES.join(", ")}`,
181
+ )
182
+ }
183
+ if (!body.match(SPEC_RE))
184
+ throw new Error(`invalid --model spec "${spec}"; expected ${SPEC_GRAMMAR}`)
185
+ const targets = name ? [name] : AGENT_NAMES
186
+ for (const agent of targets) result[agent] = body
187
+ }
188
+ return result
189
+ }
190
+
191
+ /**
192
+ * Formats an agent→spec map into a JSON `{"agents": {...}}` snippet, one entry
193
+ * per agent in canonical order. Unset agents fall back to a placeholder so the
194
+ * output is always a complete, paste-ready block. Pure.
195
+ * @param {Record<string, string>} specs Map of agent name to spec.
196
+ * @returns {string} The two-space-indented JSON snippet.
197
+ */
198
+ function formatModelConfig(specs) {
199
+ const agents = {}
200
+ for (const agent of AGENT_NAMES) {
201
+ agents[agent] = specs[agent] ?? MODEL_PLACEHOLDER
202
+ }
203
+ return JSON.stringify({ agents }, null, 2)
204
+ }
205
+
206
+ /**
207
+ * Finds the concrete specs a user actually named that are not present in
208
+ * `available` (the parsed lines of `opencode models`). Placeholder specs and
209
+ * agents the user never named are skipped — only seeded, concrete models are
210
+ * checked. A `#variant` suffix is stripped before matching (host model lists
211
+ * the base id) but the spec is reported as named on a miss. Pure: no spawn.
212
+ * @param {Record<string, string>} specs Map of agent name to spec.
213
+ * @param {string[]} available Model identifiers as listed by `opencode models`.
214
+ * @returns {string[]} The named-but-unknown specs, in canonical agent order.
215
+ */
216
+ function findMissingModels(specs, available) {
217
+ const availableSet = new Set(available)
218
+ const missing = []
219
+ for (const agent of AGENT_NAMES) {
220
+ const spec = specs[agent]
221
+ if (!spec || spec === MODEL_PLACEHOLDER) continue
222
+ if (!availableSet.has(spec.split("#")[0])) missing.push(spec)
223
+ }
224
+ return missing
225
+ }
226
+
227
+ /**
228
+ * The command runner used by --validate. A thin seam around `spawnSync` so
229
+ * tests can inject canned `opencode models` output via setCommandRunner
230
+ * (ESM named imports are read-only, so the runner is overridden through the
231
+ * setter, not by reassigning the import).
232
+ */
233
+ let commandRunner = (command, args, options) => spawnSync(command, args, options)
234
+
235
+ /**
236
+ * Overrides the --validate command runner (see commandRunner).
237
+ * @param {(command: string, args: string[], options?: object) => {stdout?: string}} fn New runner.
238
+ * @returns {void}
239
+ */
240
+ function setCommandRunner(fn) {
241
+ commandRunner = fn
242
+ }
243
+
244
+ /**
245
+ * Lists the model identifiers the host knows about, by running
246
+ * `opencode models` through the injectable commandRunner seam. Lines are
247
+ * split on newlines for `findMissingModels`. A failed spawn throws
248
+ * `could not run opencode models (<reason>)` rather than reporting every spec
249
+ * as unknown; `main` consumes that error and returns false (mapped to exit 1),
250
+ * so this tested path never calls process.exit directly.
251
+ * @returns {Promise<string[]>} The available model identifiers.
252
+ * @throws {Error} When the spawn fails, naming it so `main` can report it.
253
+ */
254
+ async function availableModels() {
255
+ const res = commandRunner("opencode", ["models"], { encoding: "utf8" })
256
+ if (res.error || res.status !== 0) {
257
+ // A failed spawn must not masquerade as "every spec is unknown"; report it
258
+ // and stop. This runs through the injectable commandRunner seam, so tests
259
+ // can drive the branch without a live host `opencode models`.
260
+ const reason = res.error?.message ?? `exit code ${res.status}`
261
+ throw new Error(`could not run opencode models (${reason})`)
262
+ }
263
+ return (res.stdout ?? "").split(/\r?\n/)
264
+ }
265
+
94
266
  /**
95
267
  * Auto-detects the install mode from the filesystem: a git checkout links
96
268
  * (working tree is the source of truth, edits are live); an extracted
@@ -531,8 +703,34 @@ async function check(manifest, version) {
531
703
  return ok
532
704
  }
533
705
 
534
- async function main() {
535
- const args = parseArgs(process.argv)
706
+ async function main(argv = process.argv) {
707
+ const args = parseArgs(argv)
708
+
709
+ // --print-model-config writes nothing: it parses the requested specs,
710
+ // optionally validates them against `opencode models`, prints a ready-to
711
+ // paste snippet, and returns. This short-circuits before the manifest is
712
+ // even read (like --check), so no link/copy target or ledger entry is
713
+ // touched. The router still idempotently installs the contract block
714
+ // separately; that is out of scope for this write-free flag.
715
+ if (args.printModelConfig) {
716
+ if (args.validate) {
717
+ let available
718
+ try {
719
+ available = await availableModels()
720
+ } catch (error) {
721
+ console.error(`install: ${error instanceof Error ? error.message : String(error)}`)
722
+ return false
723
+ }
724
+ const missing = findMissingModels(args.modelSpecs, available)
725
+ if (missing.length) {
726
+ for (const model of missing) console.error(`validate: unknown model ${model}`)
727
+ return false
728
+ }
729
+ }
730
+ console.log(formatModelConfig(args.modelSpecs))
731
+ return true
732
+ }
733
+
536
734
  const manifest = readManifest(args.manifest, args.mode)
537
735
  console.log(
538
736
  `install: mode=${manifest.mode} (${manifest.mode === "link" ? "checkout" : "package"})`,
@@ -555,5 +753,19 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
555
753
  }
556
754
 
557
755
  // Pure, side-effect-free helpers exported for unit tests: tests can
558
- // import these directly without spawning the CLI.
559
- export { detectMode, resolveMode, normalizeLedger, removeEntry }
756
+ // import these directly without spawning the CLI. `setCommandRunner` is the
757
+ // seam for faking the `opencode models` spawn (ESM named imports are
758
+ // read-only, so the runner is overridden through this setter); `main` is
759
+ // exported so the write-free print/validate path can be driven in-process
760
+ // with a fake runner (no child spawn, no process.exit in the tested path).
761
+ export {
762
+ detectMode,
763
+ resolveMode,
764
+ normalizeLedger,
765
+ removeEntry,
766
+ parseModelSpecs,
767
+ formatModelConfig,
768
+ findMissingModels,
769
+ setCommandRunner,
770
+ main,
771
+ }