buildline 0.3.1 → 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 CHANGED
@@ -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
- Use the strongest model where judgement matters and a fast one where the work is well specified. You can also have a second vendor review the diff.
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 preset balanced # Opus specs, designs, plans, reviews · Sonnet builds and verifies
99
- npx buildline models set review codex # Codex reviews every diff as a second opinion
100
- npx buildline models # see what each station uses
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": { "default": "sonnet", "plan": "opus", "review": "codex:gpt-5-codex", "verify": "gemini" }
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 routed 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
+ 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 per station set <station> <model> · preset <name>
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
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')} show models per station ${pc.dim('set <station> <model> · preset <name>')}
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 <preset> session · balanced · budget · second
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 review codex ${pc.dim('# Codex reviews every diff')}
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.1",
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": {
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 { PRESETS, MODEL_STATIONS, CLIS, modelFor, parseModel, describeModel, stationPrompt, launchArgs, externalClis } from './models.js';
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: PRESETS[opts.models]?.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
- const preset = bail(
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
- /** Show or change per-station models. */
358
- export async function models(root, args, opts) {
359
- const config = loadConfig(root);
360
- const [action, station, value] = args;
361
- if (action === 'set' || action === 'preset') {
362
- if (action === 'preset') {
363
- if (!PRESETS[station]) throw new Error(`Unknown preset "${station}". Use one of: ${Object.keys(PRESETS).join(', ')}.`);
364
- config.models = { ...PRESETS[station].models };
365
- } else {
366
- if (!station || !['default', ...MODEL_STATIONS].includes(station)) throw new Error(`Usage: buildline models set <default|${MODEL_STATIONS.join('|')}> <model|inherit>`);
367
- if (!value) throw new Error('Give a model: opus, sonnet, haiku, a model id, codex[:model], gemini[:model], or inherit.');
368
- config.models = { ...(config.models || {}) };
369
- if (value === 'inherit') delete config.models[station];
370
- else config.models[station] = value;
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
- fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
373
- apply(root, plan(root, config));
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
- if (opts.json) return console.log(JSON.stringify(Object.fromEntries(MODEL_STATIONS.map((s) => [s, modelFor(config, s)])), null, 2));
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(`\n ${pc.dim('Change one:')} buildline models set review codex ${pc.dim('Presets:')} ${Object.keys(PRESETS).join(', ')}\n`);
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 model (opus, sonnet, haiku, a full model id) or to
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}` : '';