buildline 0.3.0 → 0.4.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 +11 -8
- package/bin/buildline.js +3 -3
- package/package.json +9 -4
- package/src/cli.js +100 -31
- package/src/models.js +96 -11
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ independent verification, and a clear place for developers, designers and produc
|
|
|
20
20
|
npx buildline
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
[Quick start](#quick-start) · [How it works](#how-it-works) · [Models per station](#models-per-station) · [Dashboard](#the-dashboard)
|
|
23
|
+
**[Website & docs](https://julianoczkowski.github.io/buildline/)** · [Quick start](#quick-start) · [How it works](#how-it-works) · [Models per station](#models-per-station) · [Dashboard](#the-dashboard)
|
|
24
24
|
|
|
25
25
|
</div>
|
|
26
26
|
|
|
@@ -92,19 +92,20 @@ Role guides: [product managers](docs/for-product-managers.md) · [designers](doc
|
|
|
92
92
|
|
|
93
93
|
## Models per station
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Pick the model for each job: Fable to plan, Opus to build, Codex to review, or anything else your tools support. Buildline doesn't ship a model list. The picker reads what's available from your installed Claude Code and the Anthropic API, and you can always type a model in, so new models work on day one.
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
npx buildline models
|
|
99
|
-
npx buildline models set
|
|
100
|
-
npx buildline models
|
|
98
|
+
npx buildline models # pick by phase or per station
|
|
99
|
+
npx buildline models set planning fable # spec, design, plan
|
|
100
|
+
npx buildline models set building claude-opus-5-5 # build
|
|
101
|
+
npx buildline models check # what each choice resolves to today
|
|
101
102
|
```
|
|
102
103
|
|
|
103
104
|
```json
|
|
104
|
-
"models": { "
|
|
105
|
+
"models": { "plan": "fable", "build": "claude-opus-5-5", "review": "codex", "default": "sonnet" }
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
Claude Code skills and subagents switch model per station. A station
|
|
108
|
+
Claude Code skills and subagents switch model per station. A station set to Codex or Gemini is handed off automatically, and `buildline step <station> <id>` opens it in the right tool. [More on models →](docs/models.md)
|
|
108
109
|
|
|
109
110
|
## Guardrails that hold
|
|
110
111
|
|
|
@@ -181,7 +182,7 @@ npx buildline new "<title>" open a feature --type fix|feature|epic
|
|
|
181
182
|
npx buildline approve <id> <stage> record an approval (spec | design | plan)
|
|
182
183
|
npx buildline ui dashboard
|
|
183
184
|
npx buildline step <station> [id] open a station on its model --headless --model
|
|
184
|
-
npx buildline models models
|
|
185
|
+
npx buildline models choose models set <station|phase> <model> · check
|
|
185
186
|
npx buildline run <id> autopilot with Claude Code /goal --headless
|
|
186
187
|
npx buildline gates run quality gates
|
|
187
188
|
npx buildline doctor check the setup
|
|
@@ -194,6 +195,8 @@ Full reference: [docs/cli.md](docs/cli.md)
|
|
|
194
195
|
|
|
195
196
|
## Docs
|
|
196
197
|
|
|
198
|
+
Read them on the **[docs site](https://julianoczkowski.github.io/buildline/)**, or here on GitHub:
|
|
199
|
+
|
|
197
200
|
| | |
|
|
198
201
|
|---|---|
|
|
199
202
|
| [Getting started](docs/getting-started.md) | new and existing projects |
|
package/bin/buildline.js
CHANGED
|
@@ -20,7 +20,7 @@ ${pc.bold('Commands')}
|
|
|
20
20
|
${pc.cyan('move')} <id> <station> move a feature to a station ${pc.dim('--note "<why>"')}
|
|
21
21
|
${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open')}
|
|
22
22
|
${pc.cyan('step')} <station> [id] open a station in the CLI and model set for it ${pc.dim('--model <m> --headless --print')}
|
|
23
|
-
${pc.cyan('models')}
|
|
23
|
+
${pc.cyan('models')} choose models per station ${pc.dim('set <station|phase> <model> · check')}
|
|
24
24
|
${pc.cyan('run')} [id] autopilot: build → verify → review with Claude Code /goal ${pc.dim('--headless --print')}
|
|
25
25
|
${pc.cyan('gates')} [names...] run the quality gates
|
|
26
26
|
${pc.cyan('doctor')} check the setup
|
|
@@ -32,7 +32,7 @@ ${pc.bold('Init options')}
|
|
|
32
32
|
--agents <list> claude,codex,cursor,gemini,copilot
|
|
33
33
|
--autonomy <1-4> 1 assist · 2 guided · 3 autopilot · 4 dark factory
|
|
34
34
|
--team <list> developer,designer,pm
|
|
35
|
-
--models <
|
|
35
|
+
--models <list> e.g. planning=fable,building=claude-opus-5-5,checking=codex
|
|
36
36
|
--ci / --no-ci add the GitHub Actions workflow
|
|
37
37
|
--dry-run show what would change without writing
|
|
38
38
|
--force overwrite managed files even if you edited them
|
|
@@ -43,7 +43,7 @@ ${pc.bold('Examples')}
|
|
|
43
43
|
npx buildline init -y --autonomy 3 ${pc.dim('# non-interactive install')}
|
|
44
44
|
npx buildline new "Saved carts" ${pc.dim('# open a feature')}
|
|
45
45
|
npx buildline run 004 ${pc.dim('# unattended build loop')}
|
|
46
|
-
npx buildline models set
|
|
46
|
+
npx buildline models set planning fable ${pc.dim('# Fable specs, designs and plans')}
|
|
47
47
|
npx buildline step review 004 ${pc.dim('# run one station on its model')}
|
|
48
48
|
`;
|
|
49
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Install an AI software factory into any project. A spec-driven pipeline for Claude Code (and any AGENTS.md-compatible agent), for developers, designers and product managers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
"node": ">=20.12"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node --test test
|
|
21
|
-
"start": "node bin/buildline.js"
|
|
20
|
+
"test": "node --test test/buildline.test.js",
|
|
21
|
+
"start": "node bin/buildline.js",
|
|
22
|
+
"site": "node scripts/build-site.mjs",
|
|
23
|
+
"release": "node scripts/release.mjs"
|
|
22
24
|
},
|
|
23
25
|
"keywords": [
|
|
24
26
|
"claude-code",
|
|
@@ -37,10 +39,13 @@
|
|
|
37
39
|
"type": "git",
|
|
38
40
|
"url": "git+https://github.com/julianoczkowski/buildline.git"
|
|
39
41
|
},
|
|
40
|
-
"homepage": "https://github.
|
|
42
|
+
"homepage": "https://julianoczkowski.github.io/buildline/",
|
|
41
43
|
"bugs": "https://github.com/julianoczkowski/buildline/issues",
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@clack/prompts": "^1.8.1",
|
|
44
46
|
"picocolors": "^1.1.1"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"marked": "^15.0.12"
|
|
45
50
|
}
|
|
46
51
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import pc from 'picocolors';
|
|
|
6
6
|
import { detect, describeStack } from './detect.js';
|
|
7
7
|
import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRemove, defaultGates } from './install.js';
|
|
8
8
|
import { loadManifest } from './files.js';
|
|
9
|
-
import {
|
|
9
|
+
import { PHASES, MODEL_STATIONS, CLIS, modelFor, parseModel, parseModelList, describeModel, discoverModelOptions, checkModel, stationPrompt, launchArgs, externalClis } from './models.js';
|
|
10
10
|
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, STATIONS, TYPES } from './features.js';
|
|
11
11
|
|
|
12
12
|
const LOGO = [
|
|
@@ -95,7 +95,7 @@ export async function init(root, opts) {
|
|
|
95
95
|
team: parseList(opts.team) || ['developer', 'designer', 'pm'],
|
|
96
96
|
ci: opts.ci ?? (stack.git && fs.existsSync(path.join(root, '.github'))),
|
|
97
97
|
gates: defaultGates(stack),
|
|
98
|
-
models:
|
|
98
|
+
models: parseModelList(opts.models),
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
if (interactive) {
|
|
@@ -127,14 +127,7 @@ export async function init(root, opts) {
|
|
|
127
127
|
}),
|
|
128
128
|
);
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
await p.select({
|
|
132
|
-
message: 'Which models should each station use?',
|
|
133
|
-
options: Object.entries(PRESETS).map(([value, x]) => ({ value, label: x.label, hint: x.hint + (externalClis({ models: x.models }).some((c) => !hasCommand(c)) ? ' (needs ' + externalClis({ models: x.models }).join(', ') + ')' : '') })),
|
|
134
|
-
initialValue: 'session',
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
answers.models = PRESETS[preset].models;
|
|
130
|
+
answers.models = await pickModels({ models: answers.models });
|
|
138
131
|
|
|
139
132
|
const gateChoice = bail(
|
|
140
133
|
await p.select({
|
|
@@ -354,35 +347,109 @@ export async function step(root, station, ref, opts = {}) {
|
|
|
354
347
|
return launch(root, cmd, args);
|
|
355
348
|
}
|
|
356
349
|
|
|
357
|
-
/**
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
350
|
+
/** One model, chosen from what's available right now or typed in. */
|
|
351
|
+
async function pickOne(message, current, options) {
|
|
352
|
+
const initial = options.some((o) => o.value === (current || 'inherit')) ? current || 'inherit' : '__other';
|
|
353
|
+
const choice = bail(await p.select({ message, options, initialValue: initial, maxItems: 10 }));
|
|
354
|
+
if (choice !== '__other') return choice;
|
|
355
|
+
const typed = bail(
|
|
356
|
+
await p.text({
|
|
357
|
+
message: `${message}: model name or id`,
|
|
358
|
+
placeholder: 'e.g. claude-opus-5-5, fable, codex:gpt-5-codex, gemini:gemini-2.5-pro',
|
|
359
|
+
initialValue: initial === '__other' ? current || '' : '',
|
|
360
|
+
validate: (v) => (v?.trim() ? undefined : 'Type a model, or press Esc to go back'),
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
return typed.trim();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Interactive model routing: one model everywhere, per phase, or per station. Returns the new models map. */
|
|
367
|
+
export async function pickModels(config) {
|
|
368
|
+
const current = config.models || {};
|
|
369
|
+
const spin = p.spinner();
|
|
370
|
+
spin.start('Looking up available models');
|
|
371
|
+
const options = await discoverModelOptions(config);
|
|
372
|
+
spin.stop(`Models available: ${options.filter((o) => !['inherit', '__other'].includes(o.value)).map((o) => o.label).join(', ') || 'none found, type one in'}`);
|
|
373
|
+
const scope = bail(
|
|
374
|
+
await p.select({
|
|
375
|
+
message: 'Which models should the stations use?',
|
|
376
|
+
options: [
|
|
377
|
+
{ value: 'session', label: 'The session model everywhere', hint: 'whatever you start Claude Code (or another agent) with' },
|
|
378
|
+
{ value: 'phase', label: 'Choose for planning, building and checking', hint: 'e.g. Fable plans, Opus builds' },
|
|
379
|
+
{ value: 'station', label: 'Choose for every station' },
|
|
380
|
+
Object.keys(current).length && { value: 'keep', label: 'Keep the current setup' },
|
|
381
|
+
].filter(Boolean),
|
|
382
|
+
initialValue: Object.keys(current).length ? 'keep' : 'session',
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
if (scope === 'keep') return current;
|
|
386
|
+
if (scope === 'session') return {};
|
|
387
|
+
const models = {};
|
|
388
|
+
const set = (stations, value) => stations.forEach((st) => value !== 'inherit' && (models[st] = value));
|
|
389
|
+
if (scope === 'phase') {
|
|
390
|
+
for (const [key, phase] of Object.entries(PHASES)) {
|
|
391
|
+
const now = key === 'default' ? current.default : current[phase.stations[0]];
|
|
392
|
+
set(key === 'default' ? ['default'] : phase.stations, await pickOne(`${phase.label} ${pc.dim('(' + phase.hint + ')')}`, now, options));
|
|
371
393
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
console.log(`${pc.green('✔')} models updated; skills and subagents refreshed.`);
|
|
375
|
-
} else if (action) {
|
|
376
|
-
throw new Error('Usage: buildline models [set <station> <model> | preset <name>]');
|
|
394
|
+
} else {
|
|
395
|
+
for (const st of MODEL_STATIONS) set([st], await pickOne(st, current[st] ?? current.default, options));
|
|
377
396
|
}
|
|
378
|
-
|
|
397
|
+
const missing = externalClis({ models }).filter((c) => !hasCommand(c));
|
|
398
|
+
if (missing.length) p.log.warn(`Not installed: ${missing.map((c) => `${CLIS[c].label} (${CLIS[c].install})`).join(', ')}`);
|
|
399
|
+
return models;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function saveModels(root, config, models) {
|
|
403
|
+
config.models = models;
|
|
404
|
+
fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
|
|
405
|
+
apply(root, plan(root, config));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function printModels(config) {
|
|
379
409
|
console.log(`\n${pc.bold('Models per station')} ${pc.dim('(.buildline/buildline.json › models)')}\n`);
|
|
380
410
|
for (const s of MODEL_STATIONS) {
|
|
381
411
|
const m = modelFor(config, s);
|
|
382
412
|
const missing = m.cli !== 'claude' && !hasCommand(m.cli) ? pc.red(` ${m.cli} not installed`) : '';
|
|
383
413
|
console.log(` ${s.padEnd(9)} ${m.model || m.cli !== 'claude' ? pc.cyan(describeModel(m)) : pc.dim(describeModel(m))}${missing}`);
|
|
384
414
|
}
|
|
385
|
-
console.log(
|
|
415
|
+
console.log();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Show, pick, set or check per-station models. */
|
|
419
|
+
export async function models(root, args, opts) {
|
|
420
|
+
const config = loadConfig(root);
|
|
421
|
+
const [action, target, value] = args;
|
|
422
|
+
if (action === 'set') {
|
|
423
|
+
if (!target || !value) throw new Error(`Usage: buildline models set <station|phase> <model> stations: default, ${MODEL_STATIONS.join(', ')}; phases: planning, building, checking`);
|
|
424
|
+
const next = { ...(config.models || {}) };
|
|
425
|
+
const changes = parseModelList(`${target}=${value === 'inherit' ? 'x' : value}`);
|
|
426
|
+
for (const st of Object.keys(changes)) value === 'inherit' ? delete next[st] : (next[st] = value);
|
|
427
|
+
saveModels(root, config, next);
|
|
428
|
+
console.log(`${pc.green('✔')} ${Object.keys(changes).join(', ')} → ${value}. Skills and subagents refreshed.`);
|
|
429
|
+
} else if (action === 'check') {
|
|
430
|
+
const values = [...new Set(MODEL_STATIONS.map((s) => modelFor(config, s)).filter((m) => m.model || m.cli !== 'claude').map(describeModel))];
|
|
431
|
+
if (!values.length) return console.log('Every station uses the session model; nothing to check.');
|
|
432
|
+
console.log(`\nAsking each tool what these resolve to today ${pc.dim('(one tiny request per Claude model)')}\n`);
|
|
433
|
+
let bad = 0;
|
|
434
|
+
for (const v of values) {
|
|
435
|
+
const r = checkModel(v, { cwd: root });
|
|
436
|
+
if (!r.ok) bad++;
|
|
437
|
+
console.log(` ${r.ok ? pc.green('✔') : pc.red('✘')} ${v.padEnd(24)} ${r.ok ? pc.dim('→ ' + r.resolved + (r.note ? ` (${r.note})` : '')) : pc.red(r.error)}`);
|
|
438
|
+
}
|
|
439
|
+
console.log();
|
|
440
|
+
if (bad) process.exitCode = 1;
|
|
441
|
+
return;
|
|
442
|
+
} else if (action) {
|
|
443
|
+
throw new Error('Usage: buildline models [set <station|phase> <model> | check]');
|
|
444
|
+
} else if (!opts.json && process.stdout.isTTY && process.stdin.isTTY) {
|
|
445
|
+
printModels(config);
|
|
446
|
+
const next = await pickModels(config);
|
|
447
|
+
saveModels(root, config, next);
|
|
448
|
+
p.log.success('Saved. Skills and subagents refreshed.');
|
|
449
|
+
}
|
|
450
|
+
if (opts.json) return console.log(JSON.stringify(Object.fromEntries(MODEL_STATIONS.map((s) => [s, modelFor(config, s)])), null, 2));
|
|
451
|
+
printModels(config);
|
|
452
|
+
console.log(` ${pc.dim('Change:')} buildline models ${pc.dim('or')} buildline models set planning fable ${pc.dim('Verify:')} buildline models check\n`);
|
|
386
453
|
}
|
|
387
454
|
|
|
388
455
|
export async function run(root, ref, opts) {
|
|
@@ -485,6 +552,7 @@ export async function home(root) {
|
|
|
485
552
|
features.length && { value: 'open', label: 'Work on a feature', hint: 'approve, move, autopilot, open in Claude' },
|
|
486
553
|
{ value: 'ui', label: 'Open the dashboard', hint: 'browser UI for PMs and designers' },
|
|
487
554
|
hasCommand('claude') && { value: 'claude', label: 'Open Claude Code at the front door', hint: '/buildline' },
|
|
555
|
+
{ value: 'models', label: 'Choose models per station', hint: 'which model plans, builds and checks' },
|
|
488
556
|
{ value: 'gates', label: 'Run quality gates' },
|
|
489
557
|
{ value: 'doctor', label: 'Check setup (doctor)' },
|
|
490
558
|
{ value: 'update', label: 'Update Buildline files' },
|
|
@@ -496,6 +564,7 @@ export async function home(root) {
|
|
|
496
564
|
if (action === 'new') await newFeature(root, null, {});
|
|
497
565
|
if (action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
498
566
|
if (action === 'claude') return launchClaude(root, ['/buildline']);
|
|
567
|
+
if (action === 'models') saveModels(root, loadConfig(root), await pickModels(loadConfig(root)));
|
|
499
568
|
if (action === 'gates') gates(root, []);
|
|
500
569
|
if (action === 'doctor') doctor(root);
|
|
501
570
|
if (action === 'update') await update(root, {});
|
package/src/models.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Per-station model routing. `models` in buildline.json maps a station (or
|
|
2
|
-
// "default") to a Claude Code
|
|
3
|
-
// another CLI: "codex", "codex:<model>", "gemini", "gemini:<model>".
|
|
2
|
+
// "default") to whatever the user picked: a Claude Code alias (opus, fable, …)
|
|
3
|
+
// or full model id, or another CLI: "codex", "codex:<model>", "gemini", "gemini:<model>".
|
|
4
|
+
// Buildline never validates names against a list of its own: new models work the
|
|
5
|
+
// day the underlying CLI accepts them. Picker choices are discovered at runtime.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
4
7
|
|
|
5
8
|
export const MODEL_STATIONS = ['onboard', 'new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
6
9
|
|
|
10
|
+
// How the wizard groups stations when choosing by phase.
|
|
11
|
+
export const PHASES = {
|
|
12
|
+
planning: { label: 'Planning', hint: 'spec, design, plan', stations: ['spec', 'design', 'plan'] },
|
|
13
|
+
building: { label: 'Building', hint: 'build: writing the code', stations: ['build'] },
|
|
14
|
+
checking: { label: 'Checking', hint: 'verify, review', stations: ['verify', 'review'] },
|
|
15
|
+
default: { label: 'Everything else', hint: 'onboard, intake, ship, and anything unset', stations: [] },
|
|
16
|
+
};
|
|
17
|
+
|
|
7
18
|
export const CLIS = {
|
|
8
19
|
claude: { label: 'Claude Code', install: 'https://code.claude.com' },
|
|
9
20
|
codex: { label: 'Codex CLI', install: 'npm i -g @openai/codex' },
|
|
@@ -19,15 +30,6 @@ export const STATION_SUBAGENTS = {
|
|
|
19
30
|
review: 'buildline-reviewer',
|
|
20
31
|
};
|
|
21
32
|
|
|
22
|
-
const BALANCED = { spec: 'opus', design: 'opus', plan: 'opus', build: 'sonnet', verify: 'sonnet', review: 'opus' };
|
|
23
|
-
|
|
24
|
-
export const PRESETS = {
|
|
25
|
-
session: { label: 'Same model everywhere', hint: 'every station uses the model you start the agent with', models: {} },
|
|
26
|
-
balanced: { label: 'Balanced', hint: 'Opus for spec, design, plan and review; Sonnet builds and verifies', models: BALANCED },
|
|
27
|
-
budget: { label: 'Budget', hint: 'Sonnet everywhere, Haiku for intake', models: { default: 'sonnet', new: 'haiku' } },
|
|
28
|
-
second: { label: 'Second opinion', hint: 'Balanced, and Codex reviews every diff', models: { ...BALANCED, review: 'codex' } },
|
|
29
|
-
};
|
|
30
|
-
|
|
31
33
|
/** "opus" → { cli: 'claude', model: 'opus' }; "codex:gpt-5" → { cli: 'codex', model: 'gpt-5' }. */
|
|
32
34
|
export function parseModel(value) {
|
|
33
35
|
const v = String(value ?? '').trim();
|
|
@@ -44,6 +46,89 @@ export function modelFor(config, station) {
|
|
|
44
46
|
|
|
45
47
|
export const describeModel = ({ cli, model }) => (cli === 'claude' ? model || 'session model' : `${cli}${model ? ':' + model : ''}`);
|
|
46
48
|
|
|
49
|
+
/** "planning=fable,build=claude-opus-5-5" → { spec, design, plan: 'fable', build: '…' }. Phases expand to their stations. */
|
|
50
|
+
export function parseModelList(text) {
|
|
51
|
+
const models = {};
|
|
52
|
+
for (const pair of String(text || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
53
|
+
const [key, ...rest] = pair.split('=');
|
|
54
|
+
const value = rest.join('=').trim();
|
|
55
|
+
if (!value) throw new Error(`"${pair}": use station=model, e.g. plan=fable`);
|
|
56
|
+
const targets = PHASES[key] && key !== 'default' ? PHASES[key].stations : [key];
|
|
57
|
+
for (const t of targets) {
|
|
58
|
+
if (t !== 'default' && !MODEL_STATIONS.includes(t)) throw new Error(`Unknown station or phase "${key}". Use ${[...Object.keys(PHASES), ...MODEL_STATIONS].join(', ')}.`);
|
|
59
|
+
if (value !== 'inherit') models[t] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return models;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const has = (cmd) => spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' }).status === 0;
|
|
66
|
+
|
|
67
|
+
/** Model aliases the installed Claude Code advertises in `claude --help` (each tracks the newest model in its family). */
|
|
68
|
+
export function claudeAliases() {
|
|
69
|
+
const res = spawnSync('claude', ['--help'], { encoding: 'utf8', timeout: 10000, shell: process.platform === 'win32' });
|
|
70
|
+
const help = (res.stdout || '').replace(/\s+/g, ' ');
|
|
71
|
+
const desc = help.match(/--model <model>(.*?)(?= --[a-z])/)?.[1] || '';
|
|
72
|
+
return [...new Set([...desc.matchAll(/'([a-z][\w.-]*)'/g)].map((m) => m[1]))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Current Claude models from the Anthropic API, when an API key is available. */
|
|
76
|
+
export async function anthropicModels() {
|
|
77
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
78
|
+
if (!key) return [];
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
|
|
81
|
+
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
82
|
+
signal: AbortSignal.timeout(4000),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) return [];
|
|
85
|
+
return ((await res.json()).data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Everything the picker can offer right now: aliases from the installed Claude
|
|
93
|
+
* Code, models from the API, other installed CLIs, and values already in the
|
|
94
|
+
* config. Each option is { value, label, hint }; "__other" means type one in.
|
|
95
|
+
*/
|
|
96
|
+
export async function discoverModelOptions(config = {}) {
|
|
97
|
+
const options = [{ value: 'inherit', label: 'Session model', hint: 'whatever model the agent was started with' }];
|
|
98
|
+
const seen = new Set(['inherit']);
|
|
99
|
+
const push = (o) => {
|
|
100
|
+
if (!seen.has(o.value)) seen.add(o.value), options.push(o);
|
|
101
|
+
};
|
|
102
|
+
const aliases = has('claude') ? claudeAliases() : [];
|
|
103
|
+
// If this Claude Code doesn't list aliases in --help, fall back to the long-standing ones.
|
|
104
|
+
for (const a of aliases.length ? aliases : ['opus', 'sonnet', 'haiku']) push({ value: a, label: a, hint: 'newest model in this family, follows Claude Code updates' });
|
|
105
|
+
for (const m of await anthropicModels()) push({ value: m.id, label: m.id, hint: m.name });
|
|
106
|
+
for (const v of Object.values(config.models || {})) push({ value: v, label: v, hint: 'already in your config' });
|
|
107
|
+
for (const cli of ['codex', 'gemini']) if (has(cli)) push({ value: cli, label: cli, hint: `${CLIS[cli].label}, its default model (or type ${cli}:<model>)` });
|
|
108
|
+
options.push({ value: '__other', label: 'Other…', hint: 'type any model name or id, e.g. claude-opus-5-5 or codex:gpt-5-codex' });
|
|
109
|
+
return options;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ask the CLI which model a name resolves to today. Claude: one tiny headless
|
|
114
|
+
* request, reporting the model that actually answered. Others: installed or not.
|
|
115
|
+
*/
|
|
116
|
+
export function checkModel(value, { cwd } = {}) {
|
|
117
|
+
const m = parseModel(value);
|
|
118
|
+
if (m.cli !== 'claude') return has(m.cli) ? { ok: true, resolved: describeModel(m), note: 'installed, not called' } : { ok: false, error: `${CLIS[m.cli].label} not installed: ${CLIS[m.cli].install}` };
|
|
119
|
+
if (!has('claude')) return { ok: false, error: 'Claude Code not installed' };
|
|
120
|
+
const args = ['-p', 'Reply with OK.', '--output-format', 'json', '--max-turns', '1', ...(m.model ? ['--model', m.model] : [])];
|
|
121
|
+
const res = spawnSync('claude', args, { cwd, encoding: 'utf8', timeout: 120000, shell: process.platform === 'win32' });
|
|
122
|
+
try {
|
|
123
|
+
const out = JSON.parse(res.stdout);
|
|
124
|
+
const used = Object.keys(out.modelUsage || {});
|
|
125
|
+
if (out.is_error || !used.length) return { ok: false, error: String(out.result || 'no response').slice(0, 160) };
|
|
126
|
+
return { ok: true, resolved: used.join(', ') };
|
|
127
|
+
} catch {
|
|
128
|
+
return { ok: false, error: (res.stderr || res.stdout || 'no output').trim().split('\n').pop().slice(0, 160) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
47
132
|
/** Prompt that starts a station in the given CLI. */
|
|
48
133
|
export function stationPrompt(cli, station, id) {
|
|
49
134
|
const arg = id ? ` ${id}` : '';
|