@luanpdd/kit-mcp 1.0.0 → 1.1.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 CHANGED
@@ -6,6 +6,54 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.1.0] - 2026-05-03
10
+
11
+ **Visual feedback in the terminal.** Running `kit ...` now prints colored tables, progress bars, summary panels and interactive selectors instead of the raw JSON-to-stdout default of v1.0. Programmatic consumers add `--json` to restore the previous behavior.
12
+
13
+ ### Added — Phase 6: UI primitives
14
+ - `src/core/ui.js` — single module exposing `c` (color helpers), `icons`, `spinner`, `progress`, `select`, `confirm`, `summary`. Respects `NO_COLOR`, `FORCE_COLOR`, `process.stdout.isTTY`. Animations write to stderr so stdout stays clean for `--json` piping.
15
+ - Deps: `picocolors` (~3KB, zero subdeps) and `@inquirer/prompts` (modular — only `select`+`confirm` imported).
16
+
17
+ ### Added — Phase 7: `--json` flag, default human
18
+ - `--json` global flag preserves v1.0's JSON-to-stdout behavior for programmatic consumers.
19
+ - Without `--json`: every subcommand renders a human-readable table or summary panel via `src/cli/render.js`.
20
+ - `kit get` is unchanged (still raw, cat-like).
21
+
22
+ ### Added — Phase 8: Progress + spinner
23
+ - `syncTo` and `applyReverse` accept an `opts.onProgress({ phase, current, total, label })` callback. Default no-op preserves backward compat.
24
+ - CLI wraps long ops in `withProgress(label, total, fn)` and short ops in `withSpinner(text, fn)`. TTY animates; pipes/CI emit linear status text (`10%, 20%, ...`).
25
+
26
+ ### Added — Phase 9: Interactive selectors + diff confirm
27
+ - `install write [target]` and `sync install [target]` — when target argument is omitted in TTY mode, opens a select prompt listing all 8 IDEs with labels.
28
+ - `install write` always previews the JSON/TOML to be written and asks `Apply these changes? (y/N)` before applying. `--yes` or `--json` bypasses the prompt for CI/programmatic use.
29
+ - In non-TTY mode without target: exits with a helpful message ("pass the value as a flag instead").
30
+
31
+ ### Stable API additions (1.x compatible)
32
+
33
+ The 1.0 commitment is unchanged. These additions become part of the contract:
34
+
35
+ - **`--json` global flag.** Behavior locked: JSON-to-stdout, no ANSI codes, no progress on stderr, prompts replaced by descriptive errors.
36
+ - **`onProgress` callback signature** on `syncTo` and `applyReverse`: `({ phase, current, total, label }) => void`. Adding optional fields is non-breaking.
37
+ - **Interactive selectors fall back to errors in non-TTY**, not to defaults — programs MUST pass the target as argument or use `--json`.
38
+
39
+ ### Migration
40
+
41
+ Programs and scripts that piped `kit ... | jq` need to add `--json` explicitly:
42
+ ```bash
43
+ # Before (v1.0):
44
+ kit list-agents | jq '.[].name'
45
+
46
+ # After (v1.1):
47
+ kit list-agents --json | jq '.[].name'
48
+ ```
49
+
50
+ Interactive shell users get the new visual output automatically — no flags needed.
51
+
52
+ ### Tests
53
+ - `test/unit/ui.test.js` — 6 new tests covering `summary` rendering, `NO_COLOR` honored, icons set.
54
+ - `test/integration/cli-roundtrip.test.js` — 4 new tests covering `--json` opt-in, default human output, selector fallback in non-TTY for `install write` / `sync install`.
55
+ - Total: 49 unit + 9 integration = **58 tests** in ~4s. CI verde 6/6 (Ubuntu/macOS/Windows × Node 20/22).
56
+
9
57
  ## [1.0.0] - 2026-05-03
10
58
 
11
59
  **First stable release.** kit-mcp now commits to backwards compatibility on the surfaces listed under "Stable API" below; breaking changes there require a 2.0.0 bump.
@@ -219,7 +267,8 @@ npx -y @luanpdd/kit-mcp sync install claude-code --project-root .
219
267
  - CLI mirror of all MCP tools.
220
268
  - `install` command that registers kit-mcp into an IDE's MCP config (JSON for Claude/Cursor/Gemini/Windsurf, TOML for Codex).
221
269
 
222
- [Unreleased]: https://github.com/luanpdd/kit-mcp/compare/v1.0.0...HEAD
270
+ [Unreleased]: https://github.com/luanpdd/kit-mcp/compare/v1.1.0...HEAD
271
+ [1.1.0]: https://github.com/luanpdd/kit-mcp/compare/v1.0.0...v1.1.0
223
272
  [1.0.0]: https://github.com/luanpdd/kit-mcp/compare/v0.5.0...v1.0.0
224
273
  [0.5.0]: https://github.com/luanpdd/kit-mcp/compare/v0.4.1...v0.5.0
225
274
  [0.4.1]: https://github.com/luanpdd/kit-mcp/compare/v0.4.0...v0.4.1
package/README.md CHANGED
@@ -55,7 +55,7 @@ kit-mcp/
55
55
  └── README.md ← you are here
56
56
  ```
57
57
 
58
- **Lines of source code:** ~1100. **Runtime dependencies:** 3 (`@modelcontextprotocol/sdk`, `commander`, `chokidar`). **Build step:** none — plain ESM Node.js 20+.
58
+ **Lines of source code:** ~1300. **Runtime dependencies:** 5 (`@modelcontextprotocol/sdk`, `commander`, `chokidar`, `picocolors`, `@inquirer/prompts`). **Build step:** none — plain ESM Node.js 20+.
59
59
 
60
60
  ### About the bundled workflow
61
61
 
@@ -128,7 +128,17 @@ For other IDEs, swap `claude-code` for `cursor`, `codex`, `gemini-cli`, `windsur
128
128
 
129
129
  ## CLI reference
130
130
 
131
- The CLI mirrors the MCP tools 1:1. Output is always JSON to stdout. The global `--kit-root` flag overrides the kit source for any subcommand.
131
+ The CLI mirrors the MCP tools 1:1. **By default the CLI prints colored, human-readable tables and summary panels.** Add `--json` to restore raw JSON-to-stdout (machine-readable, the default in v1.0). The global `--kit-root` flag overrides the kit source for any subcommand.
132
+
133
+ ```bash
134
+ kit list-agents # human: colored table, name + description
135
+ kit list-agents --json # machine: JSON array
136
+
137
+ kit sync install claude-code # human: progress bar + summary panel
138
+ kit sync install claude-code --json # machine: full result object
139
+ ```
140
+
141
+ In non-TTY mode (pipes, CI), animations degrade to linear status lines automatically. `NO_COLOR=1` disables colors entirely; `FORCE_COLOR=1` forces them on even in pipes.
132
142
 
133
143
  ### `kit kit ...` — browse the kit
134
144
 
@@ -182,8 +192,12 @@ kit install dry-run claude-code --scope user --via npx # preview the JSON
182
192
  kit install write claude-code --scope user --via npx # portable: uses `npx @luanpdd/kit-mcp`
183
193
  kit install write claude-code --scope project --via local # local clone: uses ./bin/mcp.js absolute path
184
194
  kit install write claude-code --scope user --via global # assumes `npm install -g @luanpdd/kit-mcp`
195
+ kit install write # no target: opens an interactive selector (TTY)
196
+ kit install write claude-code --yes # CI: skip the confirm prompt
185
197
  ```
186
198
 
199
+ Since v1.1, `install write` always **previews** the JSON/TOML it's about to write and asks you to confirm. Pass `--yes` (CI mode) or `--json` to bypass the prompt. Without a target argument in TTY mode, you get an arrow-key selector listing all 8 IDEs.
200
+
187
201
  `--via` decides how the IDE will invoke the server:
188
202
 
189
203
  | Mode | Command in IDE config | When to use |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,8 +47,10 @@
47
47
  "test:all": "node test/run.mjs test"
48
48
  },
49
49
  "dependencies": {
50
+ "@inquirer/prompts": "^8.4.2",
50
51
  "@modelcontextprotocol/sdk": "^1.0.0",
51
52
  "chokidar": "^5.0.0",
52
- "commander": "^12.1.0"
53
+ "commander": "^12.1.0",
54
+ "picocolors": "^1.1.1"
53
55
  }
54
56
  }
package/src/cli/index.js CHANGED
@@ -7,6 +7,10 @@
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';
12
16
  import { listKit, searchKit, findItem } from '../core/kit.js';
@@ -20,49 +24,122 @@ import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failu
20
24
  import { reflect } from '../core/reflect.js';
21
25
  import { listReplays, loadReplay } from '../core/replays.js';
22
26
  import { installMcp, listInstallTargets } from '../mcp-server/install.js';
27
+ import * as render from './render.js';
28
+ import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
23
29
 
24
30
  const program = new Command()
25
31
  .name('kit')
26
32
  .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)');
33
+ .version('1.0.0')
34
+ .option('--kit-root <path>', 'Override the kit root (default: bundled example kit, or KIT_MCP_KIT_ROOT env)')
35
+ .option('--json', 'Output JSON to stdout (machine-readable, restores pre-1.1 default)');
29
36
 
30
- // Apply --kit-root globally by setting the env so all helpers pick it up.
31
37
  program.hook('preAction', (thisCommand, actionCommand) => {
32
38
  const opts = program.opts();
33
39
  if (opts.kitRoot) process.env.KIT_MCP_KIT_ROOT = opts.kitRoot;
34
40
  });
35
41
 
42
+ // `out(value, humanRenderer)` — uses the human renderer unless --json is set.
43
+ function out(value, humanRenderer) {
44
+ const opts = program.opts();
45
+ if (opts.json) {
46
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
47
+ } else if (typeof humanRenderer === 'function') {
48
+ process.stdout.write(humanRenderer(value));
49
+ } else {
50
+ process.stdout.write(render.renderFallback(value));
51
+ }
52
+ }
53
+
54
+ // withSpinner wraps a short opaque op with a spinner; auto-disabled in --json mode.
55
+ async function withSpinner(text, fn) {
56
+ const opts = program.opts();
57
+ if (opts.json) return fn();
58
+ const sp = spinner({ text });
59
+ try {
60
+ const r = await fn();
61
+ sp.succeed();
62
+ return r;
63
+ } catch (e) {
64
+ sp.fail(e.message);
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ // withProgress wraps a long op; passes onProgress callback to the core fn.
70
+ async function withProgress(label, total, fn) {
71
+ const opts = program.opts();
72
+ if (opts.json) return fn(() => {});
73
+ const p = progress({ total, label });
74
+ let last = '';
75
+ const onProgress = ({ current, label }) => { last = label || last; p.tick({ label: last }); };
76
+ try {
77
+ const r = await fn(onProgress);
78
+ p.finish(label);
79
+ return r;
80
+ } catch (e) {
81
+ p.finish();
82
+ throw e;
83
+ }
84
+ }
85
+
86
+ function fail(msg) {
87
+ process.stderr.write(`${c.red(icons.cross)} ${msg}\n`);
88
+ process.exit(1);
89
+ }
90
+
91
+ function slim(x) {
92
+ return { kind: x.kind, name: x.name, description: x.description, absPath: x.absPath };
93
+ }
94
+
36
95
  // --- kit ---
37
96
  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)));
97
+ kit.command('list-agents').action(async () => {
98
+ const k = await withSpinner('Loading kit...', () => listKit());
99
+ out(k.agents.map(slim), v => render.renderKitList(v, 'agent'));
100
+ });
101
+ kit.command('list-commands').action(async () => {
102
+ const k = await withSpinner('Loading kit...', () => listKit());
103
+ out(k.commands.map(slim), v => render.renderKitList(v, 'command'));
104
+ });
40
105
  kit.command('list-skills').action(async () => {
41
- const k = await listKit();
42
- print([...k.skills, ...k.skillsExtras].map(slim));
106
+ const k = await withSpinner('Loading kit...', () => listKit());
107
+ out([...k.skills, ...k.skillsExtras].map(slim), v => render.renderKitList(v, 'skill'));
43
108
  });
44
109
  kit.command('get <kind> <name>').action(async (kind, name) => {
45
110
  const k = await listKit();
46
111
  const item = findItem(k, kind, name);
47
112
  if (!item) return fail(`Not found: ${kind}/${name}`);
113
+ // Always raw for `kit get` — it's intended to be cat-like
48
114
  process.stdout.write(item.content ?? item.skillContent);
49
115
  });
50
- kit.command('search <query>').action(async (q) => print(searchKit(await listKit(), q)));
116
+ kit.command('search <query>').action(async (q) => out(searchKit(await listKit(), q), render.renderKitSearch));
51
117
 
52
118
  // --- sync ---
53
119
  const sync = program.command('sync').description('Project the kit into an IDE.');
54
- sync.command('targets').action(async () => print(listTargets()));
120
+ sync.command('targets').action(async () => {
121
+ const targets = await withSpinner('Loading capability matrix...', async () => listTargets());
122
+ out(targets, render.renderSyncTargets);
123
+ });
55
124
  sync.command('status <target>')
56
125
  .option('--project-root <path>')
57
- .action(async (target, opts) => print(await statusOf(target, { projectRoot: opts.projectRoot })));
58
- sync.command('install <target>')
126
+ .action(async (target, opts) => out(await statusOf(target, { projectRoot: opts.projectRoot }), render.renderSyncStatus));
127
+ sync.command('install [target]')
59
128
  .option('--project-root <path>')
60
129
  .option('--mode <mode>', 'reference | copy', 'reference')
61
130
  .option('--dry-run')
62
- .action(async (target, opts) => print(await syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun })));
131
+ .action(async (target, opts) => {
132
+ if (!target) target = await pickTarget(listTargets(), 'Which IDE do you want to sync the kit into?');
133
+ const result = await withProgress(
134
+ `Syncing kit → ${target}`,
135
+ 300,
136
+ (onProgress) => syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun, onProgress }),
137
+ );
138
+ out(result, render.renderSyncInstall);
139
+ });
63
140
  sync.command('remove <target>')
64
141
  .option('--project-root <path>')
65
- .action(async (target, opts) => print(await removeFrom(target, { projectRoot: opts.projectRoot })));
142
+ .action(async (target, opts) => out(await removeFrom(target, { projectRoot: opts.projectRoot }), render.renderSyncRemove));
66
143
  sync.command('watch [targets...]')
67
144
  .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
145
  .option('--project-root <path>')
@@ -93,50 +170,57 @@ sync.command('watch [targets...]')
93
170
  const reverse = program.command('reverse-sync').description('Detect and apply edits made directly in an IDE back to the canonical kit/.');
94
171
  reverse.command('detect <target>')
95
172
  .option('--project-root <path>')
96
- .action(async (target, opts) => print(await detectReverse(target, { projectRoot: opts.projectRoot })));
173
+ .action(async (target, opts) => out(await detectReverse(target, { projectRoot: opts.projectRoot }), render.renderReverseDetect));
97
174
  reverse.command('apply <target>')
98
175
  .option('--project-root <path>')
99
176
  .option('--strategy <s>', 'skip | overwrite | merge | rename', 'skip')
100
- .option('--only <items...>', 'Limit to these kind/name pairs (e.g. agent/planner skill/paperclip)')
177
+ .option('--only <items...>', 'Limit to these kind/name pairs (e.g. agent/planner skill/paperclip framework/workflows/foo.md)')
101
178
  .option('--dry-run')
102
- .action(async (target, opts) => print(await applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun })));
179
+ .action(async (target, opts) => {
180
+ const result = await withProgress(
181
+ `Applying reverse-sync (${opts.strategy})`,
182
+ 50,
183
+ (onProgress) => applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun, onProgress }),
184
+ );
185
+ out(result, render.renderReverseApply);
186
+ });
103
187
 
104
188
  // --- gates ---
105
189
  const gates = program.command('gates').description('Reusable workflow gates.');
106
- gates.command('list').action(async () => print(await listGates()));
190
+ gates.command('list').action(async () => out(await listGates(), render.renderGatesList));
107
191
  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)));
192
+ gates.command('for-stage <stage>').action(async (stage) => out(await gatesForStage(stage), render.renderGatesList));
109
193
  gates.command('run <id>')
110
194
  .description('Execute a gate (with confirmation in interactive mode). Returns a structured verdict.')
111
195
  .option('--project-root <path>')
112
196
  .option('--yes', 'Skip confirmation (CI/non-interactive)')
113
197
  .option('--no-interactive', 'Never prompt; manual gates return verdict=manual')
114
- .action(async (id, opts) => print(await runGate(id, {
198
+ .action(async (id, opts) => out(await runGate(id, {
115
199
  projectRoot: opts.projectRoot,
116
200
  yes: opts.yes,
117
201
  interactive: opts.interactive !== false,
118
- })));
202
+ }), render.renderGateRun));
119
203
 
120
204
  // --- forensics ---
121
205
  const forensics = program.command('forensics').description('Failure dataset & replays.');
122
206
  forensics.command('collect')
123
207
  .option('--project-root <path>')
124
- .action(async (opts) => print(await collectFailures({ projectRoot: opts.projectRoot })));
208
+ .action(async (opts) => out(await collectFailures({ projectRoot: opts.projectRoot }), render.renderForensicsCollect));
125
209
  forensics.command('summarize')
126
210
  .option('--project-root <path>')
127
211
  .action(async (opts) => {
128
212
  const f = await collectFailures({ projectRoot: opts.projectRoot });
129
- print(await summarizeByAgent(f));
213
+ out(await summarizeByAgent(f), render.renderForensicsSummarize);
130
214
  });
131
215
  forensics.command('write-learnings')
132
216
  .option('--project-root <path>')
133
217
  .action(async (opts) => {
134
218
  const f = await collectFailures({ projectRoot: opts.projectRoot });
135
- print(await writeLearnings(f, { projectRoot: opts.projectRoot }));
219
+ out(await writeLearnings(f, { projectRoot: opts.projectRoot }), render.renderFallback);
136
220
  });
137
221
  forensics.command('list-replays')
138
222
  .option('--project-root <path>')
139
- .action(async (opts) => print(await listReplays({ projectRoot: opts.projectRoot })));
223
+ .action(async (opts) => out(await listReplays({ projectRoot: opts.projectRoot }), render.renderListReplays));
140
224
  forensics.command('reflect')
141
225
  .description('LLM-pass: read learnings + current agent, propose minimal prompt edits, optionally apply.')
142
226
  .requiredOption('--agent <name>', 'Agent name (matches kit/agents/<name>.md)')
@@ -144,39 +228,90 @@ forensics.command('reflect')
144
228
  .option('--dry-run', 'Save the assembled prompt without calling the LLM')
145
229
  .option('--apply', 'Skip confirmation; apply the proposal directly')
146
230
  .option('--no-interactive', 'Save proposal but never prompt to apply')
147
- .action(async (opts) => print(await reflect({
231
+ .action(async (opts) => out(await reflect({
148
232
  agent: opts.agent,
149
233
  projectRoot: opts.projectRoot,
150
234
  dryRun: opts.dryRun,
151
235
  apply: opts.apply,
152
236
  interactive: opts.interactive !== false,
153
- })));
237
+ }), render.renderFallback));
154
238
  forensics.command('load-replay <id>')
155
239
  .option('--project-root <path>')
156
- .action(async (id, opts) => print(await loadReplay(id, { projectRoot: opts.projectRoot })));
240
+ .action(async (id, opts) => out(await loadReplay(id, { projectRoot: opts.projectRoot }), render.renderFallback));
157
241
 
158
242
  // --- install (the MCP server itself into an IDE) ---
159
243
  const install = program.command('install').description('Register kit-mcp into an IDE\'s MCP config.');
160
- install.command('targets').action(async () => print(listInstallTargets()));
244
+ install.command('targets').action(async () => out(listInstallTargets(), render.renderInstallTargets));
161
245
  install.command('dry-run <target>')
162
246
  .option('--scope <scope>', 'user | project', 'user')
163
247
  .option('--name <name>', 'Server name in IDE config', 'kit')
164
248
  .option('--via <via>', 'local | npx | global (how the IDE will invoke the server)', 'local')
165
249
  .option('--pkg <name>', 'npm package name (only with --via npx)', '@luanpdd/kit-mcp')
166
250
  .option('--project-root <path>')
167
- .action(async (target, opts) => print(await installMcp(target, { ...opts, dryRun: true })));
168
- install.command('write <target>')
251
+ .action(async (target, opts) => out(await installMcp(target, { ...opts, dryRun: true }), render.renderInstallResult));
252
+ install.command('write [target]')
169
253
  .option('--scope <scope>', 'user | project', 'user')
170
254
  .option('--name <name>', 'Server name in IDE config', 'kit')
171
255
  .option('--via <via>', 'local | npx | global', 'local')
172
256
  .option('--pkg <name>', 'npm package name (only with --via npx)', '@luanpdd/kit-mcp')
173
257
  .option('--force')
174
258
  .option('--project-root <path>')
175
- .action(async (target, opts) => print(await installMcp(target, opts)));
259
+ .option('--yes', 'Skip confirmation prompt (CI mode)')
260
+ .action(async (target, opts) => {
261
+ const globalOpts = program.opts();
262
+ if (!target) target = await pickTarget(listInstallTargets(), 'Where do you want to register kit-mcp?');
263
+
264
+ // Preview first (dry-run)
265
+ const preview = await installMcp(target, { ...opts, dryRun: true });
266
+ if (!preview.ok) {
267
+ out(preview, render.renderInstallResult);
268
+ process.exit(1);
269
+ }
270
+
271
+ // Show the preview unless --json
272
+ if (!globalOpts.json) {
273
+ process.stdout.write(`\n${c.bold('Preview:')} ${c.dim(preview.configPath)}\n\n`);
274
+ if (preview.preview) {
275
+ process.stdout.write(c.dim(JSON.stringify(preview.preview, null, 2)) + '\n');
276
+ } else if (preview.snippet) {
277
+ process.stdout.write(c.dim(preview.snippet) + '\n');
278
+ }
279
+ }
280
+
281
+ // Confirm unless --yes or --json (programmatic consumers must pass --yes)
282
+ if (!opts.yes && !globalOpts.json) {
283
+ let proceed;
284
+ try {
285
+ proceed = await confirm({ message: 'Apply these changes?', default: false });
286
+ } catch (e) {
287
+ return fail(`${e.message} (use --yes to skip)`);
288
+ }
289
+ if (!proceed) {
290
+ process.stdout.write(`${c.yellow(icons.warn)} Aborted by user.\n`);
291
+ process.exit(0);
292
+ }
293
+ }
176
294
 
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 }; }
295
+ out(await installMcp(target, opts), render.renderInstallResult);
296
+ });
297
+
298
+ // pickTarget interactive selector for IDE targets, falls back to error in non-TTY/--json
299
+ async function pickTarget(targets, message) {
300
+ const globalOpts = program.opts();
301
+ if (globalOpts.json) {
302
+ return fail('--target is required when using --json mode');
303
+ }
304
+ try {
305
+ return await select({
306
+ message,
307
+ choices: targets.map(t => ({
308
+ name: `${t.label.padEnd(22)} ${c.dim(`(${t.id})`)}`,
309
+ value: t.id,
310
+ })),
311
+ });
312
+ } catch (e) {
313
+ return fail(`${e.message} (or pass <target> as argument)`);
314
+ }
315
+ }
181
316
 
182
317
  program.parseAsync(process.argv);
@@ -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 };