opencode-turnstile 0.1.1 → 0.2.1
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 +88 -14
- package/{.opencode/agents → agents}/debugger.md +0 -1
- package/{.opencode/agents → agents}/executor.md +0 -1
- package/{.opencode/agents → agents}/plan-reviewer.md +0 -1
- package/{.opencode/agents → agents}/planner.md +0 -1
- package/{.opencode/agents → agents}/reviewer.md +0 -1
- package/{.opencode/agents → agents}/test-runner.md +0 -1
- package/bin/cli.mjs +21 -1
- package/install.json +6 -6
- package/package.json +3 -3
- package/scripts/check-markers.mjs +3 -3
- package/scripts/install.mjs +217 -5
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
|
-
|
|
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
|
|
215
|
-
frontmatter
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
9
|
+
"src": "agents/plan-reviewer.md",
|
|
10
10
|
"dest": "~/.config/opencode/agents/plan-reviewer.md"
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
|
-
"src": "
|
|
13
|
+
"src": "agents/planner.md",
|
|
14
14
|
"dest": "~/.config/opencode/agents/planner.md"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
"src": "
|
|
17
|
+
"src": "agents/executor.md",
|
|
18
18
|
"dest": "~/.config/opencode/agents/executor.md"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
"src": "
|
|
21
|
+
"src": "agents/test-runner.md",
|
|
22
22
|
"dest": "~/.config/opencode/agents/test-runner.md"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
"src": "
|
|
25
|
+
"src": "agents/reviewer.md",
|
|
26
26
|
"dest": "~/.config/opencode/agents/reviewer.md"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
|
-
"src": "
|
|
29
|
+
"src": "agents/debugger.md",
|
|
30
30
|
"dest": "~/.config/opencode/agents/debugger.md"
|
|
31
31
|
},
|
|
32
32
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-turnstile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenCode plugin that mechanically enforces agent pipeline
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "OpenCode plugin that mechanically enforces an agent pipeline (planner → plan-reviewer → executor → test-runner → reviewer) for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"install.json",
|
|
16
16
|
"templates",
|
|
17
17
|
"skills",
|
|
18
|
-
"
|
|
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
|
-
//
|
|
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, "
|
|
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
|
|
63
|
+
console.error("check-markers: no marker templates found in agents/")
|
|
64
64
|
failed++
|
|
65
65
|
}
|
|
66
66
|
|
package/scripts/install.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
}
|