@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.
- package/CHANGELOG.md +150 -1
- package/README.md +48 -2
- package/bin/ui.js +74 -0
- package/package.json +5 -2
- package/src/cli/index.js +371 -35
- package/src/cli/render.js +187 -0
- package/src/core/reverse-sync.js +5 -1
- package/src/core/sync.js +4 -0
- package/src/core/ui.js +167 -0
- package/src/mcp-server/index.js +53 -6
- package/src/ui/auto-spawn.js +108 -0
- package/src/ui/browser.js +78 -0
- package/src/ui/client.js +115 -0
- package/src/ui/events.js +65 -0
- package/src/ui/lockfile.js +147 -0
- package/src/ui/port.js +67 -0
- package/src/ui/server.js +432 -0
- package/src/ui/static/index.html +609 -0
- package/src/ui/wrapper.js +119 -0
|
@@ -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
|
+
}
|
package/src/core/reverse-sync.js
CHANGED
|
@@ -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 (
|
|
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 };
|
package/src/mcp-server/index.js
CHANGED
|
@@ -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
|
|
78
|
+
description: 'List, fetch, or execute reusable workflow gates (regression, confidence, etc).',
|
|
74
79
|
inputSchema: {
|
|
75
80
|
type: 'object',
|
|
76
81
|
properties: {
|
|
77
|
-
action:
|
|
78
|
-
id:
|
|
79
|
-
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':
|
|
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':
|
|
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 };
|