@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/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('0.2.0')
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 () => print((await listKit()).agents.map(slim)));
39
- kit.command('list-commands').action(async () => print((await listKit()).commands.map(slim)));
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
- print([...k.skills, ...k.skillsExtras].map(slim));
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) => print(searchKit(await listKit(), 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 () => print(listTargets()));
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) => print(await statusOf(target, { projectRoot: opts.projectRoot })));
58
- sync.command('install <target>')
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) => print(await syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun })));
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) => print(await removeFrom(target, { projectRoot: opts.projectRoot })));
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) => print(await detectReverse(target, { projectRoot: opts.projectRoot })));
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) => print(await applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun })));
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 () => print(await listGates()));
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) => print(await gatesForStage(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) => print(await runGate(id, {
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) => print(await collectFailures({ projectRoot: opts.projectRoot })));
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
- print(await summarizeByAgent(f));
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
- print(await writeLearnings(f, { projectRoot: opts.projectRoot }));
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) => print(await listReplays({ projectRoot: opts.projectRoot })));
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) => print(await reflect({
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) => print(await loadReplay(id, { projectRoot: opts.projectRoot })));
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 () => print(listInstallTargets()));
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) => print(await installMcp(target, { ...opts, dryRun: true })));
168
- install.command('write <target>')
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
- .action(async (target, opts) => print(await installMcp(target, opts)));
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
- // --- helpers ---
178
- function print(x) { process.stdout.write(JSON.stringify(x, null, 2) + '\n'); }
179
- function fail(msg) { process.stderr.write(msg + '\n'); process.exit(1); }
180
- function slim(x) { return { kind: x.kind, name: x.name, description: x.description, absPath: x.absPath }; }
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);