@luanpdd/kit-mcp 1.0.0 → 1.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.
@@ -0,0 +1,187 @@
1
+ // Human-readable renderers for each CLI subcommand. The CLI default switched
2
+ // from JSON-to-stdout to these in v1.1; --json restores the old behavior
3
+ // (still useful for piping to jq, MCP-like consumers, etc.).
4
+ //
5
+ // Conventions:
6
+ // - Render functions write to process.stdout (no trailing newline beyond
7
+ // what the formatted output naturally has).
8
+ // - They never throw on missing fields — the result objects come from
9
+ // core/ which already shape them.
10
+ // - Cores happen via src/core/ui.js (which already disables in NO_COLOR
11
+ // or when --no-tty etc.).
12
+
13
+ import path from 'node:path';
14
+ import { c, icons, summary } from '../core/ui.js';
15
+
16
+ // --- generic helpers ---
17
+
18
+ function table(rows, headers) {
19
+ if (rows.length === 0) {
20
+ return `${c.dim('(empty)')}\n`;
21
+ }
22
+ const cols = headers.length;
23
+ const widths = new Array(cols).fill(0);
24
+ for (let i = 0; i < cols; i++) widths[i] = Math.max(headers[i].length, ...rows.map(r => String(r[i] ?? '').length));
25
+ const out = [];
26
+ out.push(headers.map((h, i) => c.bold(h.padEnd(widths[i]))).join(' '));
27
+ out.push(headers.map((_, i) => c.dim('─'.repeat(widths[i]))).join(' '));
28
+ for (const r of rows) {
29
+ out.push(r.map((v, i) => String(v ?? '').padEnd(widths[i])).join(' '));
30
+ }
31
+ return out.join('\n') + '\n';
32
+ }
33
+
34
+ // --- kit ---
35
+
36
+ export function renderKitList(items, kind) {
37
+ if (items.length === 0) {
38
+ return `${c.dim(`No ${kind}s in kit.`)}\n`;
39
+ }
40
+ const rows = items.map(x => [x.name, (x.description ?? '').slice(0, 80)]);
41
+ return table(rows, ['name', 'description']);
42
+ }
43
+
44
+ export function renderKitSearch(results) {
45
+ if (results.length === 0) {
46
+ return `${c.dim('No matches.')}\n`;
47
+ }
48
+ const rows = results.map(x => [x.kind, x.name, (x.description ?? '').slice(0, 70)]);
49
+ return table(rows, ['kind', 'name', 'description']);
50
+ }
51
+
52
+ // --- sync ---
53
+
54
+ export function renderSyncTargets(targets) {
55
+ const rows = targets.map(t => [
56
+ t.id,
57
+ t.label,
58
+ Object.entries(t.capabilities).filter(([, v]) => v).map(([k]) => k).join(', '),
59
+ ]);
60
+ return table(rows, ['id', 'label', 'capabilities']);
61
+ }
62
+
63
+ export function renderSyncStatus(result) {
64
+ const rows = result.checks.map(c => [c.capability, c.path, c.exists ? '✓' : '—']);
65
+ return `${c.bold(`Status: ${result.target}`)} ${c.dim(result.projectRoot)}\n` + table(rows, ['cap', 'path', 'present']);
66
+ }
67
+
68
+ export function renderSyncInstall(result) {
69
+ // Tally written paths by capability prefix
70
+ const counts = {};
71
+ for (const p of result.written) {
72
+ const rel = path.relative(result.projectRoot, p).replace(/\\/g, '/');
73
+ // Hide internal markers from the user-facing tally (they're a kit-mcp impl detail)
74
+ if (rel.endsWith('/.kit-mcp-managed')) continue;
75
+ let cap = 'rules';
76
+ if (rel.includes('.claude/agents/')) cap = 'agents';
77
+ else if (rel.includes('.claude/commands/')) cap = 'commands';
78
+ else if (rel.includes('.claude/skills/')) cap = 'skills';
79
+ else if (rel.includes('.claude/framework/')) cap = 'framework';
80
+ else if (rel.includes('.claude/hooks/')) cap = 'hooks';
81
+ counts[cap] = (counts[cap] ?? 0) + 1;
82
+ }
83
+ const rows = [];
84
+ for (const cap of ['rules', 'agents', 'commands', 'skills', 'framework', 'hooks']) {
85
+ if (counts[cap] !== undefined) rows.push([cap, counts[cap]]);
86
+ }
87
+ const visibleTotal = Object.values(counts).reduce((a, b) => a + b, 0);
88
+ return summary({
89
+ title: `Synced kit → ${result.target}${result.dryRun ? ' (dry-run)' : ''}`,
90
+ rows,
91
+ total: visibleTotal,
92
+ hint: c.dim(result.projectRoot),
93
+ }) + '\n';
94
+ }
95
+
96
+ export function renderSyncRemove(result) {
97
+ return summary({
98
+ title: `Removed kit-mcp stubs from ${result.target}`,
99
+ rows: [['Files removed', result.removed.length]],
100
+ total: result.removed.length,
101
+ hint: c.dim(result.projectRoot),
102
+ }) + '\n';
103
+ }
104
+
105
+ // --- reverse-sync ---
106
+
107
+ export function renderReverseDetect(result) {
108
+ if (result.candidates.length === 0) {
109
+ return `${c.green(icons.check)} No edits to bring back. Canonical kit and ${result.target} are in sync.\n`;
110
+ }
111
+ const rows = result.candidates.map(x => [x.kind, x.name, x.reason, x.diffSummary ?? '']);
112
+ return `${c.bold(`Candidates: ${result.candidates.length}`)} ${c.dim(`(${result.target})`)}\n` +
113
+ table(rows, ['kind', 'name', 'reason', 'diff']);
114
+ }
115
+
116
+ export function renderReverseApply(result) {
117
+ const rows = result.results.map(x => [
118
+ x.kind,
119
+ x.name,
120
+ x.action.startsWith('overwrit') || x.action.startsWith('merge') || x.action.startsWith('renamed')
121
+ ? c.green(x.action)
122
+ : x.action.startsWith('skipped') ? c.dim(x.action) : c.yellow(x.action),
123
+ ]);
124
+ return `${c.bold(`Applied (strategy=${result.strategy})`)}\n` + table(rows, ['kind', 'name', 'action']);
125
+ }
126
+
127
+ // --- gates ---
128
+
129
+ export function renderGatesList(items) {
130
+ const rows = items.map(g => [g.id, g.stage, g.blocking ? c.red('blocking') : c.dim('warn-only'), g.description]);
131
+ return table(rows, ['id', 'stage', 'mode', 'description']);
132
+ }
133
+
134
+ export function renderGateRun(result) {
135
+ const verdictColor = result.verdict === 'passed' ? c.green
136
+ : result.verdict === 'block' ? c.red
137
+ : result.verdict === 'warn' ? c.yellow
138
+ : c.dim;
139
+ return `${c.bold(`Gate ${result.id}`)}: ${verdictColor(result.verdict)} ${result.exitCode !== undefined ? c.dim(`(exit ${result.exitCode})`) : ''}\n`;
140
+ }
141
+
142
+ // --- forensics ---
143
+
144
+ export function renderForensicsCollect(items) {
145
+ if (items.length === 0) return `${c.dim('No failures collected.')}\n`;
146
+ const rows = items.map(x => [x.agent ?? '?', x.kind ?? '?', x.absPath ?? x.path ?? '']);
147
+ return table(rows, ['agent', 'kind', 'path']);
148
+ }
149
+
150
+ export function renderForensicsSummarize(byAgent) {
151
+ const entries = Object.entries(byAgent ?? {});
152
+ if (entries.length === 0) return `${c.dim('No failures.')}\n`;
153
+ const rows = entries.map(([agent, items]) => [agent, Array.isArray(items) ? items.length : '?']);
154
+ return table(rows, ['agent', 'failures']);
155
+ }
156
+
157
+ export function renderListReplays(items) {
158
+ if (!Array.isArray(items) || items.length === 0) return `${c.dim('No replays recorded.')}\n`;
159
+ const rows = items.map(r => [r.id ?? '?', r.agent ?? '?', r.timestamp ?? '?']);
160
+ return table(rows, ['id', 'agent', 'recorded']);
161
+ }
162
+
163
+ // --- install ---
164
+
165
+ export function renderInstallTargets(targets) {
166
+ const rows = targets.map(t => [t.id, t.label, t.scopes?.join(', ') ?? '?']);
167
+ return table(rows, ['id', 'label', 'scopes']);
168
+ }
169
+
170
+ export function renderInstallResult(result) {
171
+ return summary({
172
+ title: result.dryRun ? `Install preview (${result.target}, scope=${result.scope})` : `Registered kit-mcp → ${result.target} (scope=${result.scope})`,
173
+ rows: [
174
+ ['Path', result.path ?? '?'],
175
+ ['Name', result.name ?? 'kit'],
176
+ ['Via', result.via ?? '?'],
177
+ ].map(([k, v]) => [k, v ?? '—']),
178
+ hint: result.dryRun ? c.dim('No file written (dry-run)') : undefined,
179
+ }) + '\n';
180
+ }
181
+
182
+ // --- generic fallback ---
183
+
184
+ export function renderFallback(value) {
185
+ // Used when we don't have a custom renderer yet.
186
+ return JSON.stringify(value, null, 2) + '\n';
187
+ }
@@ -171,17 +171,21 @@ async function walkRel(root) {
171
171
 
172
172
  export async function applyReverse(targetId, opts = {}) {
173
173
  const strategy = opts.strategy ?? 'skip';
174
+ const onProgress = opts.onProgress ?? (() => {});
174
175
  const { candidates } = await detectReverse(targetId, opts);
175
176
  const results = [];
176
177
 
177
- for (const c of candidates) {
178
+ for (let i = 0; i < candidates.length; i++) {
179
+ const c = candidates[i];
178
180
  if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) {
179
181
  results.push({ ...c, action: 'skipped (filter)' });
182
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
180
183
  continue;
181
184
  }
182
185
 
183
186
  const action = await applyOne(c, strategy, opts);
184
187
  results.push({ ...c, action });
188
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
185
189
  }
186
190
 
187
191
  return { target: targetId, strategy, results };
package/src/core/sync.js CHANGED
@@ -24,6 +24,7 @@ export async function syncTo(targetId, opts = {}) {
24
24
  const kitRoot = resolveKitRoot(opts.kitRoot);
25
25
  const mode = opts.mode ?? 'reference';
26
26
  const dryRun = !!opts.dryRun;
27
+ const onProgress = opts.onProgress ?? (() => {});
27
28
 
28
29
  const kit = await listKit(kitRoot);
29
30
  const ops = [];
@@ -82,6 +83,7 @@ export async function syncTo(targetId, opts = {}) {
82
83
  }
83
84
 
84
85
  if (!dryRun) {
86
+ let i = 0;
85
87
  for (const op of ops) {
86
88
  await fs.mkdir(path.dirname(op.path), { recursive: true });
87
89
  if (op.treeCopy) {
@@ -89,6 +91,8 @@ export async function syncTo(targetId, opts = {}) {
89
91
  } else {
90
92
  await fs.writeFile(op.path, op.content, 'utf8');
91
93
  }
94
+ i++;
95
+ onProgress({ phase: op.kind, current: i, total: ops.length, label: path.basename(op.path) });
92
96
  }
93
97
  }
94
98
 
package/src/core/ui.js ADDED
@@ -0,0 +1,167 @@
1
+ // UI primitives for the CLI: colors, icons, spinner, progress bar,
2
+ // interactive select/confirm prompts, and a summary panel.
3
+ //
4
+ // Design rules:
5
+ // - Respect process.stdout.isTTY: animations only when interactive.
6
+ // In pipes/CI, fall back to linear status text.
7
+ // - Respect NO_COLOR (https://no-color.org) and FORCE_COLOR=1.
8
+ // - Animations write to stderr to keep stdout clean for `--json` mode
9
+ // (the user can still pipe machine-readable output even with spinners).
10
+ // - Zero hidden globals — every primitive is a plain function/class.
11
+
12
+ import pc from 'picocolors';
13
+ import { select as inqSelect, confirm as inqConfirm } from '@inquirer/prompts';
14
+
15
+ // --- color helpers ---
16
+
17
+ const NO_COLOR = process.env.NO_COLOR && process.env.NO_COLOR !== '0';
18
+ const FORCE = process.env.FORCE_COLOR === '1';
19
+ const COLOR_ON = FORCE || (!NO_COLOR && process.stdout.isTTY);
20
+
21
+ function id(s) { return String(s); }
22
+ export const c = COLOR_ON
23
+ ? {
24
+ green: pc.green, red: pc.red, yellow: pc.yellow, cyan: pc.cyan,
25
+ magenta: pc.magenta, blue: pc.blue, dim: pc.dim, bold: pc.bold,
26
+ gray: pc.gray, underline: pc.underline,
27
+ }
28
+ : {
29
+ green: id, red: id, yellow: id, cyan: id, magenta: id, blue: id,
30
+ dim: id, bold: id, gray: id, underline: id,
31
+ };
32
+
33
+ export const icons = {
34
+ check: '✓',
35
+ cross: '✗',
36
+ warn: '⚠',
37
+ dot: '•',
38
+ arrow: '→',
39
+ spinner: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],
40
+ };
41
+
42
+ // --- spinner ---
43
+
44
+ export function spinner({ text = '' } = {}) {
45
+ const tty = process.stderr.isTTY;
46
+ let i = 0;
47
+ let current = text;
48
+ let timer = null;
49
+
50
+ function render() {
51
+ process.stderr.write(`\r${c.cyan(icons.spinner[i])} ${current}\x1b[K`);
52
+ i = (i + 1) % icons.spinner.length;
53
+ }
54
+
55
+ if (tty) {
56
+ timer = setInterval(render, 80);
57
+ render();
58
+ } else {
59
+ process.stderr.write(`${icons.dot} ${current}\n`);
60
+ }
61
+
62
+ function clearLine() {
63
+ if (tty) process.stderr.write('\r\x1b[K');
64
+ }
65
+
66
+ return {
67
+ update(t) { current = t; if (!tty) process.stderr.write(`${icons.dot} ${t}\n`); },
68
+ succeed(t) {
69
+ if (timer) clearInterval(timer);
70
+ clearLine();
71
+ process.stderr.write(`${c.green(icons.check)} ${t ?? current}\n`);
72
+ },
73
+ fail(t) {
74
+ if (timer) clearInterval(timer);
75
+ clearLine();
76
+ process.stderr.write(`${c.red(icons.cross)} ${t ?? current}\n`);
77
+ },
78
+ stop() {
79
+ if (timer) clearInterval(timer);
80
+ clearLine();
81
+ },
82
+ };
83
+ }
84
+
85
+ // --- progress bar ---
86
+
87
+ export function progress({ total, label = '' } = {}) {
88
+ const tty = process.stderr.isTTY;
89
+ const width = 24;
90
+ let current = 0;
91
+ let lastLabel = label;
92
+
93
+ function render() {
94
+ const pct = total === 0 ? 100 : Math.min(100, Math.round((current / total) * 100));
95
+ const filled = Math.round((width * pct) / 100);
96
+ const bar = '━'.repeat(filled) + c.dim('━'.repeat(width - filled));
97
+ const line = `${c.cyan(bar)} ${pct.toString().padStart(3)}% ${c.dim(`(${current}/${total})`)} ${lastLabel}`;
98
+ process.stderr.write(`\r${line}\x1b[K`);
99
+ }
100
+
101
+ function tick({ label } = {}) {
102
+ current++;
103
+ if (label !== undefined) lastLabel = label;
104
+ if (tty) {
105
+ render();
106
+ } else if (current === total || current % Math.max(1, Math.floor(total / 10)) === 0) {
107
+ // Every ~10% in non-TTY mode
108
+ const pct = total === 0 ? 100 : Math.round((current / total) * 100);
109
+ process.stderr.write(` ${pct}% ${lastLabel}\n`);
110
+ }
111
+ }
112
+
113
+ function finish(text) {
114
+ if (tty) {
115
+ process.stderr.write('\r\x1b[K');
116
+ }
117
+ if (text) process.stderr.write(`${c.green(icons.check)} ${text}\n`);
118
+ }
119
+
120
+ if (tty) render();
121
+ return { tick, finish };
122
+ }
123
+
124
+ // --- interactive prompts ---
125
+
126
+ export async function select(opts) {
127
+ if (!process.stdin.isTTY) {
128
+ throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass the value as a flag instead.');
129
+ }
130
+ return inqSelect(opts);
131
+ }
132
+
133
+ export async function confirm(opts) {
134
+ if (!process.stdin.isTTY) {
135
+ throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass --yes to skip confirmation.');
136
+ }
137
+ return inqConfirm(opts);
138
+ }
139
+
140
+ // --- summary panel ---
141
+
142
+ export function summary({ title, rows = [], total, hint }) {
143
+ const lines = [];
144
+ lines.push(`${c.green(icons.check)} ${c.bold(title)}`);
145
+ lines.push('');
146
+
147
+ // Compute label column width
148
+ const w = Math.max(...rows.map(r => String(r[0]).length), 0);
149
+ for (const [label, count, status] of rows) {
150
+ const cnt = count > 0 ? c.green(String(count).padStart(4)) : c.dim(String(count).padStart(4));
151
+ const tail = status === 'fail' ? c.red(icons.cross) : c.green(icons.check);
152
+ lines.push(` ${label.padEnd(w)} ${cnt} ${tail}`);
153
+ }
154
+
155
+ if (total !== undefined || hint) {
156
+ lines.push('');
157
+ const totalStr = total !== undefined ? `Total: ${c.bold(total)}` : '';
158
+ const hintStr = hint ? c.dim(`· ${hint}`) : '';
159
+ lines.push(` ${totalStr}${totalStr && hintStr ? ' ' : ''}${hintStr}`);
160
+ }
161
+
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // --- helpers exposed for tests ---
166
+
167
+ export const _internal = { COLOR_ON };
@@ -17,10 +17,13 @@ import { listTargets } from '../core/registry.js';
17
17
  import { syncTo, statusOf, removeFrom } from '../core/sync.js';
18
18
  import { detectReverse, applyReverse } from '../core/reverse-sync.js';
19
19
  import { listGates, getGate, gatesForStage } from '../core/gates.js';
20
+ import { runGate } from '../core/gate-runner.js';
20
21
  import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failures.js';
21
22
  import { reflect } from '../core/reflect.js';
22
23
  import { recordReplay, listReplays, loadReplay, annotateReplay } from '../core/replays.js';
23
24
  import { installMcp, listInstallTargets } from './install.js';
25
+ import { ensureSidecar } from '../ui/auto-spawn.js';
26
+ import { wrapProgressForUi } from '../ui/wrapper.js';
24
27
 
25
28
  const TOOLS = [
26
29
  {
@@ -48,6 +51,7 @@ const TOOLS = [
48
51
  projectRoot: { type: 'string', description: 'Defaults to cwd' },
49
52
  mode: { type: 'string', enum: ['reference', 'copy'], description: 'Default: reference' },
50
53
  dryRun: { type: 'boolean' },
54
+ autoSpawn: { type: 'boolean', description: 'On action=install: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
51
55
  },
52
56
  required: ['action'],
53
57
  },
@@ -64,19 +68,22 @@ const TOOLS = [
64
68
  strategy: { type: 'string', enum: ['skip', 'overwrite', 'merge', 'rename'], description: 'For action=apply' },
65
69
  only: { type: 'array', items: { type: 'string' }, description: 'For action=apply: limit to these kind/name pairs' },
66
70
  dryRun: { type: 'boolean' },
71
+ autoSpawn: { type: 'boolean', description: 'On action=apply: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
67
72
  },
68
73
  required: ['action', 'target'],
69
74
  },
70
75
  },
71
76
  {
72
77
  name: 'gates',
73
- description: 'List or fetch reusable workflow gates (regression, confidence, etc).',
78
+ description: 'List, fetch, or execute reusable workflow gates (regression, confidence, etc).',
74
79
  inputSchema: {
75
80
  type: 'object',
76
81
  properties: {
77
- action: { type: 'string', enum: ['list', 'get', 'for-stage'] },
78
- id: { type: 'string', description: 'For action=get' },
79
- stage: { type: 'string', enum: ['pre-plan', 'pre-execute', 'pre-verify', 'post-verify', 'any'], description: 'For action=for-stage' },
82
+ action: { type: 'string', enum: ['list', 'get', 'for-stage', 'run'] },
83
+ id: { type: 'string', description: 'For action=get or action=run' },
84
+ stage: { type: 'string', enum: ['pre-plan', 'pre-execute', 'pre-verify', 'post-verify', 'any'], description: 'For action=for-stage' },
85
+ projectRoot: { type: 'string', description: 'For action=run' },
86
+ autoSpawn: { type: 'boolean', description: 'On action=run: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
80
87
  },
81
88
  required: ['action'],
82
89
  },
@@ -136,11 +143,38 @@ async function handleKit(args) {
136
143
  }
137
144
  }
138
145
 
146
+ // withAutoSpawn — if args.autoSpawn is set, ensure the sidecar is up and wrap
147
+ // the user-supplied onProgress so events flow there. Otherwise pass-through.
148
+ async function withAutoSpawn(args, tool, run) {
149
+ const projectRoot = args.projectRoot || process.cwd();
150
+ let wrapped = null;
151
+ let sidecarInfo = null;
152
+
153
+ if (args.autoSpawn) {
154
+ sidecarInfo = await ensureSidecar({ projectRoot, openBrowserOnSpawn: true });
155
+ if (sidecarInfo?.ready) {
156
+ wrapped = wrapProgressForUi(null, { projectRoot, tool });
157
+ }
158
+ }
159
+
160
+ // run(onProgress) — pass our wrapped callback (or undefined to no-op)
161
+ try {
162
+ const result = await run(wrapped);
163
+ if (wrapped?.done) wrapped.done({ ok: true });
164
+ return sidecarInfo ? { ...result, _sidecar: sidecarInfo } : result;
165
+ } catch (err) {
166
+ if (wrapped?.error) wrapped.error(err);
167
+ throw err;
168
+ }
169
+ }
170
+
139
171
  async function handleSync(args) {
140
172
  switch (args.action) {
141
173
  case 'targets': return listTargets();
142
174
  case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
143
- case 'install': return syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun });
175
+ case 'install':
176
+ return withAutoSpawn(args, 'sync.install', (onProgress) =>
177
+ syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
144
178
  case 'remove': return removeFrom(args.target, { projectRoot: args.projectRoot });
145
179
  default: return { error: `Unknown action: ${args.action}` };
146
180
  }
@@ -149,7 +183,13 @@ async function handleSync(args) {
149
183
  async function handleReverseSync(args) {
150
184
  switch (args.action) {
151
185
  case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
152
- case 'apply': return applyReverse(args.target, { projectRoot: args.projectRoot, strategy: args.strategy, only: args.only, dryRun: args.dryRun });
186
+ case 'apply':
187
+ return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) =>
188
+ applyReverse(args.target, {
189
+ projectRoot: args.projectRoot,
190
+ strategy: args.strategy, only: args.only, dryRun: args.dryRun,
191
+ onProgress,
192
+ }));
153
193
  default: return { error: `Unknown action: ${args.action}` };
154
194
  }
155
195
  }
@@ -159,6 +199,13 @@ async function handleGates(args) {
159
199
  case 'list': return listGates();
160
200
  case 'get': return getGate(args.id);
161
201
  case 'for-stage': return gatesForStage(args.stage);
202
+ case 'run':
203
+ return withAutoSpawn(args, 'gates.run', () =>
204
+ runGate(args.id, {
205
+ projectRoot: args.projectRoot,
206
+ yes: true, // MCP context: never prompt
207
+ interactive: false, // MCP never prompts
208
+ }));
162
209
  default: return { error: `Unknown action: ${args.action}` };
163
210
  }
164
211
  }
@@ -0,0 +1,108 @@
1
+ // src/ui/auto-spawn.js
2
+ // Spawn the sidecar in a detached subprocess and wait until it's healthy.
3
+ // Used by MCP tool handlers when the caller passes `autoSpawn: true` and no
4
+ // sidecar lockfile is present for the project.
5
+ //
6
+ // Discipline: stderr/file logging only. Audit gate enforced.
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import http from 'node:http';
10
+ import path from 'node:path';
11
+ import process from 'node:process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { readLock } from './lockfile.js';
15
+ import { openBrowser } from './browser.js';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ // src/ui → src → repo root → bin/ui.js
19
+ const UI_BIN = path.resolve(HERE, '..', '..', 'bin', 'ui.js');
20
+
21
+ const POLL_INTERVAL_MS = 100;
22
+ const POLL_TIMEOUT_MS = 5000;
23
+
24
+ // healthzOk returns true if GET /healthz on this port responds 200 within 1s.
25
+ function healthzOk(port) {
26
+ return new Promise((resolve) => {
27
+ const req = http.request({
28
+ method: 'GET',
29
+ host: '127.0.0.1',
30
+ port,
31
+ path: '/healthz',
32
+ agent: false,
33
+ headers: { host: `127.0.0.1:${port}`, connection: 'close' },
34
+ }, (res) => {
35
+ res.resume();
36
+ res.on('end', () => resolve(res.statusCode === 200));
37
+ });
38
+ req.on('error', () => resolve(false));
39
+ req.setTimeout(800, () => { try { req.destroy(); } catch { /* noop */ } resolve(false); });
40
+ req.end();
41
+ });
42
+ }
43
+
44
+ async function waitForHealth(projectRoot, deadline) {
45
+ // Poll for lockfile + healthz until deadline.
46
+ while (Date.now() < deadline) {
47
+ const lock = readLock(projectRoot);
48
+ if (lock?.port) {
49
+ // eslint-disable-next-line no-await-in-loop
50
+ if (await healthzOk(lock.port)) return lock;
51
+ }
52
+ // eslint-disable-next-line no-await-in-loop
53
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ensureSidecar({projectRoot, openBrowser?}): if a sidecar is already running
59
+ // for this projectRoot, returns immediately with its lock metadata. Otherwise
60
+ // spawns bin/ui.js detached and waits for it to come online, then optionally
61
+ // opens the browser. Resolves to:
62
+ // { ready: true, port, spawned: bool, opened: bool } on success
63
+ // { ready: false, reason } on timeout/spawn-fail
64
+ export async function ensureSidecar({ projectRoot, openBrowserOnSpawn = true } = {}) {
65
+ if (!projectRoot) return { ready: false, reason: 'no_project_root' };
66
+
67
+ // Already running?
68
+ const existing = readLock(projectRoot);
69
+ if (existing?.port) {
70
+ if (await healthzOk(existing.port)) {
71
+ return { ready: true, port: existing.port, spawned: false, opened: false };
72
+ }
73
+ // Stale lockfile — let the spawn step reclaim it.
74
+ }
75
+
76
+ // Spawn detached. Inherits stderr only — stdout is closed so a buggy child
77
+ // can never poison parent's stdout (e.g. when the parent is the MCP server
78
+ // running on stdio).
79
+ let child;
80
+ try {
81
+ child = spawn(process.execPath, [UI_BIN, '--project-root', projectRoot], {
82
+ detached: true,
83
+ stdio: ['ignore', 'ignore', 'inherit'],
84
+ windowsHide: true,
85
+ });
86
+ child.unref();
87
+ } catch (err) {
88
+ return { ready: false, reason: `spawn_failed: ${err.message}` };
89
+ }
90
+
91
+ // Wait for it to come online.
92
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
93
+ const lock = await waitForHealth(projectRoot, deadline);
94
+ if (!lock) {
95
+ return { ready: false, reason: 'healthz_timeout' };
96
+ }
97
+
98
+ let opened = false;
99
+ if (openBrowserOnSpawn) {
100
+ const url = `http://127.0.0.1:${lock.port}/`;
101
+ const r = await openBrowser(url);
102
+ opened = r.opened === true;
103
+ }
104
+
105
+ return { ready: true, port: lock.port, spawned: true, opened };
106
+ }
107
+
108
+ export const __test = { healthzOk, UI_BIN, POLL_INTERVAL_MS, POLL_TIMEOUT_MS };