@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
package/src/cli/index.js
CHANGED
|
@@ -7,8 +7,15 @@
|
|
|
7
7
|
// kit gates list
|
|
8
8
|
// kit forensics collect --project-root /path/to/project
|
|
9
9
|
// kit install dry-run claude-code
|
|
10
|
+
//
|
|
11
|
+
// Default output: human-readable colored panels + summaries.
|
|
12
|
+
// `--json` flag (global) restores the v1.0 JSON-to-stdout behavior for
|
|
13
|
+
// programmatic consumers.
|
|
10
14
|
|
|
11
15
|
import { Command } from 'commander';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import path from 'node:path';
|
|
12
19
|
import { listKit, searchKit, findItem } from '../core/kit.js';
|
|
13
20
|
import { listTargets } from '../core/registry.js';
|
|
14
21
|
import { syncTo, statusOf, removeFrom } from '../core/sync.js';
|
|
@@ -20,49 +27,176 @@ import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failu
|
|
|
20
27
|
import { reflect } from '../core/reflect.js';
|
|
21
28
|
import { listReplays, loadReplay } from '../core/replays.js';
|
|
22
29
|
import { installMcp, listInstallTargets } from '../mcp-server/install.js';
|
|
30
|
+
import * as render from './render.js';
|
|
31
|
+
import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
|
|
32
|
+
import { createServer } from '../ui/server.js';
|
|
33
|
+
import { readLock, lockPathFor } from '../ui/lockfile.js';
|
|
34
|
+
import { wrapProgressForUi } from '../ui/wrapper.js';
|
|
35
|
+
import { openBrowser } from '../ui/browser.js';
|
|
36
|
+
import http from 'node:http';
|
|
37
|
+
|
|
38
|
+
// Read package.json version at boot so `--version` is always accurate. Falls
|
|
39
|
+
// back to a string if the file lookup fails (e.g. unusual install layout).
|
|
40
|
+
function readPkgVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const pkgPath = path.resolve(here, '..', '..', 'package.json');
|
|
44
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
45
|
+
} catch {
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
23
49
|
|
|
24
50
|
const program = new Command()
|
|
25
51
|
.name('kit')
|
|
26
52
|
.description('Personal kit (agents/commands/skills) — CLI mirror of the kit-mcp server.')
|
|
27
|
-
.version(
|
|
28
|
-
.option('--kit-root <path>', 'Override the kit root (default: bundled example kit, or KIT_MCP_KIT_ROOT env)')
|
|
53
|
+
.version(readPkgVersion())
|
|
54
|
+
.option('--kit-root <path>', 'Override the kit root (default: bundled example kit, or KIT_MCP_KIT_ROOT env)')
|
|
55
|
+
.option('--json', 'Output JSON to stdout (machine-readable, restores pre-1.1 default)')
|
|
56
|
+
.option('--no-ui', 'Suppress sidecar event publishing for this run (default: auto-detect lockfile)');
|
|
29
57
|
|
|
30
|
-
// Apply --kit-root globally by setting the env so all helpers pick it up.
|
|
31
58
|
program.hook('preAction', (thisCommand, actionCommand) => {
|
|
32
59
|
const opts = program.opts();
|
|
33
60
|
if (opts.kitRoot) process.env.KIT_MCP_KIT_ROOT = opts.kitRoot;
|
|
34
61
|
});
|
|
35
62
|
|
|
63
|
+
// `out(value, humanRenderer)` — uses the human renderer unless --json is set.
|
|
64
|
+
function out(value, humanRenderer) {
|
|
65
|
+
const opts = program.opts();
|
|
66
|
+
if (opts.json) {
|
|
67
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
68
|
+
} else if (typeof humanRenderer === 'function') {
|
|
69
|
+
process.stdout.write(humanRenderer(value));
|
|
70
|
+
} else {
|
|
71
|
+
process.stdout.write(render.renderFallback(value));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// withSpinner wraps a short opaque op with a spinner; auto-disabled in --json mode.
|
|
76
|
+
async function withSpinner(text, fn) {
|
|
77
|
+
const opts = program.opts();
|
|
78
|
+
if (opts.json) return fn();
|
|
79
|
+
const sp = spinner({ text });
|
|
80
|
+
try {
|
|
81
|
+
const r = await fn();
|
|
82
|
+
sp.succeed();
|
|
83
|
+
return r;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
sp.fail(e.message);
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// withProgress wraps a long op; passes onProgress callback to the core fn.
|
|
91
|
+
// Also auto-detects a running sidecar (via lockfile) and multiplexes events to
|
|
92
|
+
// it when present. Opt-out via --no-ui or KIT_MCP_NO_UI=1.
|
|
93
|
+
async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
|
|
94
|
+
const opts = program.opts();
|
|
95
|
+
let onProgress;
|
|
96
|
+
let p = null;
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
onProgress = () => {};
|
|
99
|
+
} else {
|
|
100
|
+
p = progress({ total, label });
|
|
101
|
+
let last = '';
|
|
102
|
+
onProgress = ({ current, label }) => { last = label || last; p.tick({ label: last }); };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Auto-wrap if a sidecar is running for this projectRoot.
|
|
106
|
+
const wrapper = maybeWrapForUi(onProgress, { tool, projectRoot });
|
|
107
|
+
try {
|
|
108
|
+
const r = await fn(wrapper);
|
|
109
|
+
if (p) p.finish(label);
|
|
110
|
+
if (wrapper.done) wrapper.done({ ok: true });
|
|
111
|
+
return r;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (p) p.finish();
|
|
114
|
+
if (wrapper.error) wrapper.error(e);
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// maybeWrapForUi — returns the original callback unchanged when no sidecar is up
|
|
120
|
+
// or the user opted out. Otherwise returns a wrapped callback with .done/.error.
|
|
121
|
+
function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
|
|
122
|
+
const globalOpts = program.opts();
|
|
123
|
+
// commander stores `--no-ui` as opts.ui === false
|
|
124
|
+
if (globalOpts.ui === false || process.env.KIT_MCP_NO_UI === '1') {
|
|
125
|
+
return passthroughWrapper(onProgress);
|
|
126
|
+
}
|
|
127
|
+
const root = projectRoot || process.cwd();
|
|
128
|
+
if (!readLock(root)) {
|
|
129
|
+
return passthroughWrapper(onProgress);
|
|
130
|
+
}
|
|
131
|
+
return wrapProgressForUi(onProgress, { projectRoot: root, tool: tool ?? null });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function passthroughWrapper(onProgress) {
|
|
135
|
+
const cb = (p) => { if (typeof onProgress === 'function') onProgress(p); };
|
|
136
|
+
cb.done = () => {};
|
|
137
|
+
cb.error = () => {};
|
|
138
|
+
cb.emit = () => {};
|
|
139
|
+
return cb;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fail(msg) {
|
|
143
|
+
process.stderr.write(`${c.red(icons.cross)} ${msg}\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function slim(x) {
|
|
148
|
+
return { kind: x.kind, name: x.name, description: x.description, absPath: x.absPath };
|
|
149
|
+
}
|
|
150
|
+
|
|
36
151
|
// --- kit ---
|
|
37
152
|
const kit = program.command('kit').description('Browse the canonical kit.');
|
|
38
|
-
kit.command('list-agents').action(async () =>
|
|
39
|
-
|
|
153
|
+
kit.command('list-agents').action(async () => {
|
|
154
|
+
const k = await withSpinner('Loading kit...', () => listKit());
|
|
155
|
+
out(k.agents.map(slim), v => render.renderKitList(v, 'agent'));
|
|
156
|
+
});
|
|
157
|
+
kit.command('list-commands').action(async () => {
|
|
158
|
+
const k = await withSpinner('Loading kit...', () => listKit());
|
|
159
|
+
out(k.commands.map(slim), v => render.renderKitList(v, 'command'));
|
|
160
|
+
});
|
|
40
161
|
kit.command('list-skills').action(async () => {
|
|
41
|
-
const k = await listKit();
|
|
42
|
-
|
|
162
|
+
const k = await withSpinner('Loading kit...', () => listKit());
|
|
163
|
+
out([...k.skills, ...k.skillsExtras].map(slim), v => render.renderKitList(v, 'skill'));
|
|
43
164
|
});
|
|
44
165
|
kit.command('get <kind> <name>').action(async (kind, name) => {
|
|
45
166
|
const k = await listKit();
|
|
46
167
|
const item = findItem(k, kind, name);
|
|
47
168
|
if (!item) return fail(`Not found: ${kind}/${name}`);
|
|
169
|
+
// Always raw for `kit get` — it's intended to be cat-like
|
|
48
170
|
process.stdout.write(item.content ?? item.skillContent);
|
|
49
171
|
});
|
|
50
|
-
kit.command('search <query>').action(async (q) =>
|
|
172
|
+
kit.command('search <query>').action(async (q) => out(searchKit(await listKit(), q), render.renderKitSearch));
|
|
51
173
|
|
|
52
174
|
// --- sync ---
|
|
53
175
|
const sync = program.command('sync').description('Project the kit into an IDE.');
|
|
54
|
-
sync.command('targets').action(async () =>
|
|
176
|
+
sync.command('targets').action(async () => {
|
|
177
|
+
const targets = await withSpinner('Loading capability matrix...', async () => listTargets());
|
|
178
|
+
out(targets, render.renderSyncTargets);
|
|
179
|
+
});
|
|
55
180
|
sync.command('status <target>')
|
|
56
181
|
.option('--project-root <path>')
|
|
57
|
-
.action(async (target, opts) =>
|
|
58
|
-
sync.command('install
|
|
182
|
+
.action(async (target, opts) => out(await statusOf(target, { projectRoot: opts.projectRoot }), render.renderSyncStatus));
|
|
183
|
+
sync.command('install [target]')
|
|
59
184
|
.option('--project-root <path>')
|
|
60
185
|
.option('--mode <mode>', 'reference | copy', 'reference')
|
|
61
186
|
.option('--dry-run')
|
|
62
|
-
.action(async (target, opts) =>
|
|
187
|
+
.action(async (target, opts) => {
|
|
188
|
+
if (!target) target = await pickTarget(listTargets(), 'Which IDE do you want to sync the kit into?');
|
|
189
|
+
const result = await withProgress(
|
|
190
|
+
`Syncing kit → ${target}`,
|
|
191
|
+
300,
|
|
192
|
+
(onProgress) => syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun, onProgress }),
|
|
193
|
+
{ tool: 'sync.install', projectRoot: opts.projectRoot },
|
|
194
|
+
);
|
|
195
|
+
out(result, render.renderSyncInstall);
|
|
196
|
+
});
|
|
63
197
|
sync.command('remove <target>')
|
|
64
198
|
.option('--project-root <path>')
|
|
65
|
-
.action(async (target, opts) =>
|
|
199
|
+
.action(async (target, opts) => out(await removeFrom(target, { projectRoot: opts.projectRoot }), render.renderSyncRemove));
|
|
66
200
|
sync.command('watch [targets...]')
|
|
67
201
|
.description('Watch kit/ and re-sync to one or more IDEs on every change. Use --all to pick up every IDE that already has files in the project.')
|
|
68
202
|
.option('--project-root <path>')
|
|
@@ -93,50 +227,58 @@ sync.command('watch [targets...]')
|
|
|
93
227
|
const reverse = program.command('reverse-sync').description('Detect and apply edits made directly in an IDE back to the canonical kit/.');
|
|
94
228
|
reverse.command('detect <target>')
|
|
95
229
|
.option('--project-root <path>')
|
|
96
|
-
.action(async (target, opts) =>
|
|
230
|
+
.action(async (target, opts) => out(await detectReverse(target, { projectRoot: opts.projectRoot }), render.renderReverseDetect));
|
|
97
231
|
reverse.command('apply <target>')
|
|
98
232
|
.option('--project-root <path>')
|
|
99
233
|
.option('--strategy <s>', 'skip | overwrite | merge | rename', 'skip')
|
|
100
|
-
.option('--only <items...>', 'Limit to these kind/name pairs (e.g. agent/planner skill/paperclip)')
|
|
234
|
+
.option('--only <items...>', 'Limit to these kind/name pairs (e.g. agent/planner skill/paperclip framework/workflows/foo.md)')
|
|
101
235
|
.option('--dry-run')
|
|
102
|
-
.action(async (target, opts) =>
|
|
236
|
+
.action(async (target, opts) => {
|
|
237
|
+
const result = await withProgress(
|
|
238
|
+
`Applying reverse-sync (${opts.strategy})`,
|
|
239
|
+
50,
|
|
240
|
+
(onProgress) => applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun, onProgress }),
|
|
241
|
+
{ tool: 'reverse-sync.apply', projectRoot: opts.projectRoot },
|
|
242
|
+
);
|
|
243
|
+
out(result, render.renderReverseApply);
|
|
244
|
+
});
|
|
103
245
|
|
|
104
246
|
// --- gates ---
|
|
105
247
|
const gates = program.command('gates').description('Reusable workflow gates.');
|
|
106
|
-
gates.command('list').action(async () =>
|
|
248
|
+
gates.command('list').action(async () => out(await listGates(), render.renderGatesList));
|
|
107
249
|
gates.command('get <id>').action(async (id) => process.stdout.write((await getGate(id)).content));
|
|
108
|
-
gates.command('for-stage <stage>').action(async (stage) =>
|
|
250
|
+
gates.command('for-stage <stage>').action(async (stage) => out(await gatesForStage(stage), render.renderGatesList));
|
|
109
251
|
gates.command('run <id>')
|
|
110
252
|
.description('Execute a gate (with confirmation in interactive mode). Returns a structured verdict.')
|
|
111
253
|
.option('--project-root <path>')
|
|
112
254
|
.option('--yes', 'Skip confirmation (CI/non-interactive)')
|
|
113
255
|
.option('--no-interactive', 'Never prompt; manual gates return verdict=manual')
|
|
114
|
-
.action(async (id, opts) =>
|
|
256
|
+
.action(async (id, opts) => out(await runGate(id, {
|
|
115
257
|
projectRoot: opts.projectRoot,
|
|
116
258
|
yes: opts.yes,
|
|
117
259
|
interactive: opts.interactive !== false,
|
|
118
|
-
})));
|
|
260
|
+
}), render.renderGateRun));
|
|
119
261
|
|
|
120
262
|
// --- forensics ---
|
|
121
263
|
const forensics = program.command('forensics').description('Failure dataset & replays.');
|
|
122
264
|
forensics.command('collect')
|
|
123
265
|
.option('--project-root <path>')
|
|
124
|
-
.action(async (opts) =>
|
|
266
|
+
.action(async (opts) => out(await collectFailures({ projectRoot: opts.projectRoot }), render.renderForensicsCollect));
|
|
125
267
|
forensics.command('summarize')
|
|
126
268
|
.option('--project-root <path>')
|
|
127
269
|
.action(async (opts) => {
|
|
128
270
|
const f = await collectFailures({ projectRoot: opts.projectRoot });
|
|
129
|
-
|
|
271
|
+
out(await summarizeByAgent(f), render.renderForensicsSummarize);
|
|
130
272
|
});
|
|
131
273
|
forensics.command('write-learnings')
|
|
132
274
|
.option('--project-root <path>')
|
|
133
275
|
.action(async (opts) => {
|
|
134
276
|
const f = await collectFailures({ projectRoot: opts.projectRoot });
|
|
135
|
-
|
|
277
|
+
out(await writeLearnings(f, { projectRoot: opts.projectRoot }), render.renderFallback);
|
|
136
278
|
});
|
|
137
279
|
forensics.command('list-replays')
|
|
138
280
|
.option('--project-root <path>')
|
|
139
|
-
.action(async (opts) =>
|
|
281
|
+
.action(async (opts) => out(await listReplays({ projectRoot: opts.projectRoot }), render.renderListReplays));
|
|
140
282
|
forensics.command('reflect')
|
|
141
283
|
.description('LLM-pass: read learnings + current agent, propose minimal prompt edits, optionally apply.')
|
|
142
284
|
.requiredOption('--agent <name>', 'Agent name (matches kit/agents/<name>.md)')
|
|
@@ -144,39 +286,233 @@ forensics.command('reflect')
|
|
|
144
286
|
.option('--dry-run', 'Save the assembled prompt without calling the LLM')
|
|
145
287
|
.option('--apply', 'Skip confirmation; apply the proposal directly')
|
|
146
288
|
.option('--no-interactive', 'Save proposal but never prompt to apply')
|
|
147
|
-
.action(async (opts) =>
|
|
289
|
+
.action(async (opts) => out(await reflect({
|
|
148
290
|
agent: opts.agent,
|
|
149
291
|
projectRoot: opts.projectRoot,
|
|
150
292
|
dryRun: opts.dryRun,
|
|
151
293
|
apply: opts.apply,
|
|
152
294
|
interactive: opts.interactive !== false,
|
|
153
|
-
})));
|
|
295
|
+
}), render.renderFallback));
|
|
154
296
|
forensics.command('load-replay <id>')
|
|
155
297
|
.option('--project-root <path>')
|
|
156
|
-
.action(async (id, opts) =>
|
|
298
|
+
.action(async (id, opts) => out(await loadReplay(id, { projectRoot: opts.projectRoot }), render.renderFallback));
|
|
157
299
|
|
|
158
300
|
// --- install (the MCP server itself into an IDE) ---
|
|
159
301
|
const install = program.command('install').description('Register kit-mcp into an IDE\'s MCP config.');
|
|
160
|
-
install.command('targets').action(async () =>
|
|
302
|
+
install.command('targets').action(async () => out(listInstallTargets(), render.renderInstallTargets));
|
|
161
303
|
install.command('dry-run <target>')
|
|
162
304
|
.option('--scope <scope>', 'user | project', 'user')
|
|
163
305
|
.option('--name <name>', 'Server name in IDE config', 'kit')
|
|
164
306
|
.option('--via <via>', 'local | npx | global (how the IDE will invoke the server)', 'local')
|
|
165
307
|
.option('--pkg <name>', 'npm package name (only with --via npx)', '@luanpdd/kit-mcp')
|
|
166
308
|
.option('--project-root <path>')
|
|
167
|
-
.action(async (target, opts) =>
|
|
168
|
-
install.command('write
|
|
309
|
+
.action(async (target, opts) => out(await installMcp(target, { ...opts, dryRun: true }), render.renderInstallResult));
|
|
310
|
+
install.command('write [target]')
|
|
169
311
|
.option('--scope <scope>', 'user | project', 'user')
|
|
170
312
|
.option('--name <name>', 'Server name in IDE config', 'kit')
|
|
171
313
|
.option('--via <via>', 'local | npx | global', 'local')
|
|
172
314
|
.option('--pkg <name>', 'npm package name (only with --via npx)', '@luanpdd/kit-mcp')
|
|
173
315
|
.option('--force')
|
|
174
316
|
.option('--project-root <path>')
|
|
175
|
-
.
|
|
317
|
+
.option('--yes', 'Skip confirmation prompt (CI mode)')
|
|
318
|
+
.action(async (target, opts) => {
|
|
319
|
+
const globalOpts = program.opts();
|
|
320
|
+
if (!target) target = await pickTarget(listInstallTargets(), 'Where do you want to register kit-mcp?');
|
|
321
|
+
|
|
322
|
+
// Preview first (dry-run)
|
|
323
|
+
const preview = await installMcp(target, { ...opts, dryRun: true });
|
|
324
|
+
if (!preview.ok) {
|
|
325
|
+
out(preview, render.renderInstallResult);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Show the preview unless --json
|
|
330
|
+
if (!globalOpts.json) {
|
|
331
|
+
process.stdout.write(`\n${c.bold('Preview:')} ${c.dim(preview.configPath)}\n\n`);
|
|
332
|
+
if (preview.preview) {
|
|
333
|
+
process.stdout.write(c.dim(JSON.stringify(preview.preview, null, 2)) + '\n');
|
|
334
|
+
} else if (preview.snippet) {
|
|
335
|
+
process.stdout.write(c.dim(preview.snippet) + '\n');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Confirm unless --yes or --json (programmatic consumers must pass --yes)
|
|
340
|
+
if (!opts.yes && !globalOpts.json) {
|
|
341
|
+
let proceed;
|
|
342
|
+
try {
|
|
343
|
+
proceed = await confirm({ message: 'Apply these changes?', default: false });
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return fail(`${e.message} (use --yes to skip)`);
|
|
346
|
+
}
|
|
347
|
+
if (!proceed) {
|
|
348
|
+
process.stdout.write(`${c.yellow(icons.warn)} Aborted by user.\n`);
|
|
349
|
+
process.exit(0);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
out(await installMcp(target, opts), render.renderInstallResult);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// pickTarget — interactive selector for IDE targets, falls back to error in non-TTY/--json
|
|
357
|
+
async function pickTarget(targets, message) {
|
|
358
|
+
const globalOpts = program.opts();
|
|
359
|
+
if (globalOpts.json) {
|
|
360
|
+
return fail('--target is required when using --json mode');
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
return await select({
|
|
364
|
+
message,
|
|
365
|
+
choices: targets.map(t => ({
|
|
366
|
+
name: `${t.label.padEnd(22)} ${c.dim(`(${t.id})`)}`,
|
|
367
|
+
value: t.id,
|
|
368
|
+
})),
|
|
369
|
+
});
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return fail(`${e.message} (or pass <target> as argument)`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- ui (sidecar process viewer) ---
|
|
376
|
+
const ui = program.command('ui').description('Live process viewer in a localhost browser tab.');
|
|
377
|
+
|
|
378
|
+
ui.command('start')
|
|
379
|
+
.description('Start the sidecar HTTP server in foreground (Ctrl+C to stop). Prints URL on stderr.')
|
|
380
|
+
.option('--project-root <path>', 'Project root for lockfile keying (default: cwd)')
|
|
381
|
+
.option('--port <n>', 'Bind to a specific port (default: auto-pick 7100-7199)')
|
|
382
|
+
.option('--idle-ms <ms>', 'Idle shutdown timeout (default 30min; 0 = never)')
|
|
383
|
+
.option('--no-open', 'Skip auto-opening the browser')
|
|
384
|
+
.action(async (opts) => {
|
|
385
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
386
|
+
const port = opts.port ? Number(opts.port) : undefined;
|
|
387
|
+
const idleMs = opts.idleMs !== undefined ? Number(opts.idleMs) : undefined;
|
|
388
|
+
const srv = createServer({ projectRoot, idleMs });
|
|
389
|
+
try {
|
|
390
|
+
const { port: actualPort } = await srv.start({ port });
|
|
391
|
+
const url = `http://127.0.0.1:${actualPort}/`;
|
|
392
|
+
process.stderr.write(`${c.cyan(icons.info)} kit-mcp ui listening on ${url}\n`);
|
|
393
|
+
process.stderr.write(`${c.dim(` project: ${projectRoot}`)}\n`);
|
|
394
|
+
if (opts.open !== false) {
|
|
395
|
+
await openBrowser(url);
|
|
396
|
+
}
|
|
397
|
+
// The server's own SIGINT handler will perform shutdown + cleanup.
|
|
398
|
+
// We just stay alive — server is foreground.
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (err.code === 'ELIVE') {
|
|
401
|
+
process.stderr.write(`${c.yellow(icons.warn)} sidecar already running for this project\n`);
|
|
402
|
+
process.stderr.write(` pid: ${err.lock?.pid}, port: ${err.lock?.port}\n`);
|
|
403
|
+
process.stderr.write(` use 'kit ui status' or 'kit ui open' to inspect\n`);
|
|
404
|
+
process.exit(2);
|
|
405
|
+
}
|
|
406
|
+
fail(err.message);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
ui.command('stop')
|
|
411
|
+
.description('Stop the sidecar running for this project (POST /shutdown).')
|
|
412
|
+
.option('--project-root <path>')
|
|
413
|
+
.action(async (opts) => {
|
|
414
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
415
|
+
const lock = readLock(projectRoot);
|
|
416
|
+
if (!lock) return out({ ok: false, reason: 'no_sidecar' }, () => `${icons.warn} no sidecar running for this project\n`);
|
|
417
|
+
try {
|
|
418
|
+
await postShutdown(lock.port);
|
|
419
|
+
out({ ok: true, port: lock.port }, () => `${icons.check} sidecar at port ${lock.port} stopped\n`);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
fail(`could not stop sidecar at port ${lock.port}: ${err.message}`);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
ui.command('status')
|
|
426
|
+
.description('Show whether a sidecar is running for this project.')
|
|
427
|
+
.option('--project-root <path>')
|
|
428
|
+
.action(async (opts) => {
|
|
429
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
430
|
+
const lock = readLock(projectRoot);
|
|
431
|
+
if (!lock) {
|
|
432
|
+
out({ running: false, reason: 'no_lockfile' }, () => `${icons.warn} no sidecar running\n`);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const health = await getHealthz(lock.port);
|
|
437
|
+
out({ running: true, ...health, lockfile: lockPathFor(projectRoot) }, render.renderUiStatus ?? renderUiStatusFallback);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
out({ running: false, reason: 'unreachable', lockfile: lockPathFor(projectRoot), error: err.message },
|
|
440
|
+
() => `${icons.cross} lockfile present but sidecar unreachable: ${err.message}\n`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
ui.command('open')
|
|
446
|
+
.description('Open the running sidecar in a browser. Fails if no sidecar is up.')
|
|
447
|
+
.option('--project-root <path>')
|
|
448
|
+
.action(async (opts) => {
|
|
449
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
450
|
+
const lock = readLock(projectRoot);
|
|
451
|
+
if (!lock) return fail('no sidecar running — start one with `kit ui start`');
|
|
452
|
+
const url = `http://127.0.0.1:${lock.port}/`;
|
|
453
|
+
const r = await openBrowser(url, { force: true });
|
|
454
|
+
if (!r.opened) {
|
|
455
|
+
process.stderr.write(`${c.yellow(icons.warn)} could not open browser (${r.reason}); copy the URL above\n`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Helpers for kit ui (live in cli/ — stdout/console allowed here)
|
|
461
|
+
async function postShutdown(port) {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const req = http.request({
|
|
464
|
+
method: 'POST',
|
|
465
|
+
host: '127.0.0.1',
|
|
466
|
+
port,
|
|
467
|
+
path: '/shutdown',
|
|
468
|
+
agent: false,
|
|
469
|
+
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'content-length': 0, connection: 'close' },
|
|
470
|
+
}, (res) => {
|
|
471
|
+
res.resume();
|
|
472
|
+
res.on('end', () => res.statusCode < 400 ? resolve() : reject(new Error(`http_${res.statusCode}`)));
|
|
473
|
+
});
|
|
474
|
+
req.on('error', reject);
|
|
475
|
+
req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
|
|
476
|
+
req.end();
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function getHealthz(port) {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
const req = http.request({
|
|
483
|
+
method: 'GET',
|
|
484
|
+
host: '127.0.0.1',
|
|
485
|
+
port,
|
|
486
|
+
path: '/healthz',
|
|
487
|
+
agent: false,
|
|
488
|
+
headers: { host: `127.0.0.1:${port}`, connection: 'close' },
|
|
489
|
+
}, (res) => {
|
|
490
|
+
const chunks = [];
|
|
491
|
+
res.on('data', (c) => chunks.push(c));
|
|
492
|
+
res.on('end', () => {
|
|
493
|
+
if (res.statusCode >= 400) return reject(new Error(`http_${res.statusCode}`));
|
|
494
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
495
|
+
catch (e) { reject(e); }
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
req.on('error', reject);
|
|
499
|
+
req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
|
|
500
|
+
req.end();
|
|
501
|
+
});
|
|
502
|
+
}
|
|
176
503
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
504
|
+
function renderUiStatusFallback(v) {
|
|
505
|
+
if (!v.running) return `${icons.warn} not running\n`;
|
|
506
|
+
return [
|
|
507
|
+
`${icons.check} sidecar running`,
|
|
508
|
+
` port: ${v.port}`,
|
|
509
|
+
` pid (sdcr): ${v.lockfile ? readLock(process.cwd())?.pid : '?'}`,
|
|
510
|
+
` uptime: ${Math.round((v.uptime || 0) / 1000)}s`,
|
|
511
|
+
` events: ${v.eventsTotal}`,
|
|
512
|
+
` subscribers: ${v.subscribers}`,
|
|
513
|
+
` url: http://127.0.0.1:${v.port}/`,
|
|
514
|
+
'',
|
|
515
|
+
].join('\n');
|
|
516
|
+
}
|
|
181
517
|
|
|
182
518
|
program.parseAsync(process.argv);
|