@luanpdd/kit-mcp 0.5.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,99 @@ 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
+
57
+ ## [1.0.0] - 2026-05-03
58
+
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.
60
+
61
+ ### Added — Phase 1: Tooling debt
62
+ - `.github/dependabot.yml` — weekly grouped npm + github-actions updates.
63
+ - GitHub Release object created for v0.5.0 (was stuck on v0.2.0 "cleanup" as Latest).
64
+ - `.github/workflows/publish.yml` now creates a GitHub Release object automatically on every `v*` tag push, with notes extracted from this CHANGELOG. Closes the gap permanently.
65
+
66
+ ### Fixed — Phase 2: Slash-command parser
67
+ - `src/core/sync.js` — `renderReference` reorders the stub body so the first non-blank line is the H1 + description blockquote, not the `<!-- kit-mcp:reference -->` marker. Strict downstream parsers (notably Claude Desktop's skill listing) now surface the real description.
68
+ - `src/core/kit.js` — `firstNonEmptyLine` skips lines starting with `<!--` as a defensive fallback when the canonical has no frontmatter description.
69
+ - `kit/commands/*` — 8 commands (`adicionar-backlog`, `adicionar-fase`, `adicionar-tarefa`, `concluir-marco`, `definir-perfil`, `depurar`, `fio`, `inserir-fase`) had unquoted angle-bracket `argument-hint` values that strict YAML parsers misinterpreted as flow-style flags. Now consistently quoted.
70
+
71
+ ### Added — Phase 3: Reverse-sync for mirror-tree caps
72
+ - `detectReverse` now walks `.claude/framework/` and `.claude/hooks/` and reports any byte-for-byte difference vs `kit/<source>/<rel>`. The `.kit-mcp-managed` marker is automatically excluded from candidates.
73
+ - `applyReverse` adds `applyMirrorTreeOne` for `framework`/`hooks` candidates: `skip`, `overwrite`, `merge` (degenerates to overwrite — no frontmatter to preserve), `rename` (writes to `kit/<source>/<rel>.from-<tag>.<ext>` preserving the original).
74
+ - `--only framework/<rel>` / `--only hooks/<file>` filters narrow apply to one file.
75
+ - README "kit reverse-sync" section updated with the new examples.
76
+
77
+ ### Added — Phase 4: Test infrastructure
78
+ - `node:test`-based runner — zero dependencies. `test/run.mjs` walks for `*.test.js` files (works on Node 20+ where `--test` glob support is partial).
79
+ - 37 unit tests across `kit`, `sync`, `reverse-sync`, `gates`, `gate-runner`, `registry`.
80
+ - 5 integration tests spawning `bin/cli.js` end-to-end (incl. MCP server boot smoke).
81
+ - `test/fixtures/sample-kit/` minimal fixture (1 of each kind + framework template + hook + frontmatter-less command for fallback test).
82
+ - CI runs `npm test` + `npm run test:integration` before existing smoke + MCP boot, on Ubuntu / macOS / Windows × Node 20 / 22 (6/6 combinations).
83
+ - `package.json` scripts: `test`, `test:integration`, `test:all`.
84
+
85
+ ### Stable API (commitments locked at 1.0.0)
86
+
87
+ The following surfaces are covered by SemVer — breaking changes require a 2.0.0 release:
88
+
89
+ - **`src/core/registry.js` TARGETS table format.** Adding capabilities, IDEs, or new modes is non-breaking. Renaming or removing existing capability keys (`rules`, `agents`, `commands`, `skills`, `framework`, `hooks`, `mcpConfig`) is breaking.
90
+ - **MCP tool action signatures.** Tool names (`kit`, `sync`, `reverse-sync`, `gates`, `forensics`, `install`) and their action-dispatch contracts are stable. New actions are non-breaking; renaming or removing existing actions is breaking.
91
+ - **CLI subcommand surface.** Top-level commands (`kit`, `sync`, `reverse-sync`, `gates`, `forensics`, `install`) and their action sub-commands are stable. New flags are non-breaking; renaming or removing existing ones is breaking.
92
+ - **`src/core/*.js` named exports.** Functions consumed programmatically (`listKit`, `searchKit`, `findItem`, `resolveKitRoot`, `BUNDLED_KIT_ROOT`, `syncTo`, `statusOf`, `removeFrom`, `detectReverse`, `applyReverse`, `listGates`, `getGate`, `gatesForStage`, `runGate`, `listTargets`, `getTarget`, `TARGETS`) keep their signatures. Adding new exports is non-breaking; signature changes are breaking.
93
+ - **Stub format.** Files written by sync `--mode reference` keep the `<!-- kit-mcp:reference -->` marker somewhere in the body so `sync remove` and `reverse-sync detect` continue to identify them. Position within the body may change; presence is the contract.
94
+ - **`.kit-mcp-managed` marker semantics.** Mirror-tree directories (`framework/`, `hooks/`) are managed only when the marker is present at the root. Without it, `sync remove` never deletes the tree.
95
+
96
+ ### Migration
97
+
98
+ No code changes required for users on 0.5.0 — `npm install @luanpdd/kit-mcp@latest` brings in 1.0.0 with the same behavior plus the parser fixes, reverse-sync expansions, and test coverage.
99
+
100
+ If you were on 0.4.0 (deprecated) or earlier, upgrade to skip the import-time crash and missing-framework regression entirely.
101
+
9
102
  ## [0.5.0] - 2026-05-03
10
103
 
11
104
  ### Added
@@ -174,7 +267,9 @@ npx -y @luanpdd/kit-mcp sync install claude-code --project-root .
174
267
  - CLI mirror of all MCP tools.
175
268
  - `install` command that registers kit-mcp into an IDE's MCP config (JSON for Claude/Cursor/Gemini/Windsurf, TOML for Codex).
176
269
 
177
- [Unreleased]: https://github.com/luanpdd/kit-mcp/compare/v0.5.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
272
+ [1.0.0]: https://github.com/luanpdd/kit-mcp/compare/v0.5.0...v1.0.0
178
273
  [0.5.0]: https://github.com/luanpdd/kit-mcp/compare/v0.4.1...v0.5.0
179
274
  [0.4.1]: https://github.com/luanpdd/kit-mcp/compare/v0.4.0...v0.4.1
180
275
  [0.4.0]: https://github.com/luanpdd/kit-mcp/compare/v0.3.0...v0.4.0
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 |
@@ -194,16 +208,19 @@ kit install write claude-code --scope user --via global # assumes `npm ins
194
208
 
195
209
  ### `kit reverse-sync ...` — bring IDE edits back to the canonical kit
196
210
 
197
- If you edited an agent/command/skill **directly inside the IDE's folder** (`.claude/agents/foo.md`, `.cursor/agents/bar.md`, …) instead of in your kit, this brings those edits back so the canonical absorbs them.
211
+ If you edited an agent/command/skill/framework/hook **directly inside the IDE's folder** (`.claude/agents/foo.md`, `.claude/framework/workflows/bar.md`, `.claude/hooks/baz.js`, …) instead of in your kit, this brings those edits back so the canonical absorbs them.
198
212
 
199
213
  ```bash
200
214
  kit reverse-sync detect claude-code --project-root .
201
215
  kit reverse-sync apply claude-code --project-root . --strategy merge --dry-run
202
216
  kit reverse-sync apply claude-code --project-root . --strategy merge
203
217
  kit reverse-sync apply claude-code --project-root . --strategy overwrite --only agent/foo
218
+ kit reverse-sync apply claude-code --project-root . --strategy overwrite --only framework/workflows/new-milestone.md
204
219
  ```
205
220
 
206
- **Strategies:** `skip` (list-only), `merge` (canonical frontmatter + edited body), `overwrite`, `rename` (preserve both as `-from-{ide}.md`).
221
+ **Strategies:** `skip` (list-only), `merge` (canonical frontmatter + edited body — for agents/commands/skills), `overwrite`, `rename` (preserve both as `-from-{ide}.md`).
222
+
223
+ **Mirror-tree caps (`framework`, `hooks`):** files have no frontmatter, so `merge` degenerates to `overwrite` (with a note). The `.kit-mcp-managed` marker is automatically excluded from candidates. Filter individual files with `--only framework/<rel>` or `--only hooks/<file>`.
207
224
 
208
225
  ### `kit gates ...` — reusable workflow gates
209
226
 
@@ -469,16 +486,27 @@ PRs welcome.
469
486
 
470
487
  ---
471
488
 
472
- ## Smoke tests
489
+ ## Tests
490
+
491
+ Built on `node:test` (zero dependencies). Two suites:
492
+
493
+ ```bash
494
+ npm test # unit — kit parser, sync (all modes), reverse-sync, gates, registry
495
+ npm run test:integration # integration — spawns bin/cli.js end-to-end on a temp project
496
+ npm run test:all # both
497
+ ```
498
+
499
+ Plus the original quick smokes:
473
500
 
474
501
  ```bash
475
502
  node bin/cli.js kit list-agents | head -5 # 19 bundled agents
476
503
  node bin/cli.js sync targets # 8 IDEs
477
504
  node bin/cli.js gates list # 5 gates
478
505
  node bin/cli.js install dry-run claude-code --via npx
479
- node bin/mcp.js < /dev/null & sleep 1; kill %1 # MCP server boots and waits on stdio
480
506
  ```
481
507
 
508
+ CI runs unit + integration + smoke + MCP boot on Ubuntu / macOS / Windows × Node 20 / 22 on every push and PR.
509
+
482
510
  ---
483
511
 
484
512
  ## License
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: adicionar-backlog
3
3
  description: Adiciona uma ideia ao estacionamento de backlog (numeração 999.x)
4
- argument-hint: <description>
4
+ argument-hint: "<description>"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: adicionar-fase
3
3
  description: Adiciona fase ao final do milestone atual no roadmap
4
- argument-hint: <description>
4
+ argument-hint: "<description>"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: adicionar-tarefa
3
3
  description: Captura ideia ou tarefa como todo a partir do contexto da conversa atual
4
- argument-hint: [descrição opcional]
4
+ argument-hint: "[descrição opcional]"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -2,7 +2,7 @@
2
2
  type: prompt
3
3
  name: concluir-marco
4
4
  description: Arquiva milestone concluído e prepara para próxima versão
5
- argument-hint: <version>
5
+ argument-hint: "<version>"
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Write
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: definir-perfil
3
3
  description: Altera o perfil de modelo para os agentes framework (quality/balanced/budget/inherit)
4
- argument-hint: <perfil (quality|balanced|budget|inherit)>
4
+ argument-hint: "<perfil (quality|balanced|budget|inherit)>"
5
5
  model: haiku
6
6
  allowed-tools:
7
7
  - Bash
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: depurar
3
3
  description: Depuração sistemática com estado persistente entre resets de contexto
4
- argument-hint: [descrição do problema]
4
+ argument-hint: "[descrição do problema]"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Bash
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: fio
3
3
  description: Gerencia threads de contexto persistentes para trabalho entre sessões
4
- argument-hint: [nome | descrição]
4
+ argument-hint: "[nome | descrição]"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: inserir-fase
3
3
  description: Insere trabalho urgente como fase decimal (ex: 72.1) entre fases existentes
4
- argument-hint: <after> <description>
4
+ argument-hint: "<after> <description>"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "0.5.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": {
@@ -41,11 +41,16 @@
41
41
  "scripts": {
42
42
  "start": "node bin/mcp.js",
43
43
  "cli": "node bin/cli.js",
44
- "smoke": "node bin/cli.js kit list-agents | head -5"
44
+ "smoke": "node bin/cli.js kit list-agents | head -5",
45
+ "test": "node test/run.mjs test/unit",
46
+ "test:integration": "node test/run.mjs test/integration",
47
+ "test:all": "node test/run.mjs test"
45
48
  },
46
49
  "dependencies": {
50
+ "@inquirer/prompts": "^8.4.2",
47
51
  "@modelcontextprotocol/sdk": "^1.0.0",
48
52
  "chokidar": "^5.0.0",
49
- "commander": "^12.1.0"
53
+ "commander": "^12.1.0",
54
+ "picocolors": "^1.1.1"
50
55
  }
51
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
+ }
package/src/core/kit.js CHANGED
@@ -135,7 +135,10 @@ function parseLooseYaml(text) {
135
135
  function firstNonEmptyLine(body) {
136
136
  for (const line of body.split(/\r?\n/)) {
137
137
  const t = line.trim();
138
- if (t && !t.startsWith('#')) return t.slice(0, 200);
138
+ if (!t) continue; // blank
139
+ if (t.startsWith('#')) continue; // markdown heading
140
+ if (t.startsWith('<!--')) continue; // HTML comment (e.g. STUB_MARKER)
141
+ return t.slice(0, 200);
139
142
  }
140
143
  return '';
141
144
  }
@@ -35,6 +35,11 @@ export async function detectReverse(targetId, opts = {}) {
35
35
  if (target.agents) await scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot);
36
36
  if (target.commands) await scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot);
37
37
  if (target.skills) await scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot);
38
+ for (const cap of ['framework', 'hooks']) {
39
+ const spec = target[cap];
40
+ if (!spec || spec.mode !== 'mirror-tree') continue;
41
+ await scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot);
42
+ }
38
43
 
39
44
  return { target: targetId, projectRoot, kitRoot, candidates };
40
45
  }
@@ -117,21 +122,70 @@ async function scanSkills(candidates, capCfg, projectRoot, kitSkills, kitRoot) {
117
122
  }
118
123
  }
119
124
 
125
+ async function scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot) {
126
+ const dstRoot = path.join(projectRoot, spec.path);
127
+ const srcRoot = path.join(kitRoot, spec.source);
128
+ const files = await walkRel(dstRoot);
129
+ for (const rel of files) {
130
+ if (rel === '.kit-mcp-managed' || path.basename(rel) === '.kit-mcp-managed') continue;
131
+ const dstPath = path.join(dstRoot, rel);
132
+ const srcPath = path.join(srcRoot, rel);
133
+ let dstBuf, srcBuf;
134
+ try { dstBuf = await fs.readFile(dstPath); } catch { continue; }
135
+ try { srcBuf = await fs.readFile(srcPath); } catch { srcBuf = null; }
136
+ if (!srcBuf) {
137
+ candidates.push({
138
+ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
139
+ reason: 'new-in-ide',
140
+ diffSummary: `+${dstBuf.length} bytes (no kit source)`,
141
+ });
142
+ continue;
143
+ }
144
+ if (dstBuf.equals(srcBuf)) continue;
145
+ candidates.push({
146
+ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
147
+ reason: 'modified-in-ide',
148
+ diffSummary: `${dstBuf.length} bytes vs ${srcBuf.length} canonical (${dstBuf.length - srcBuf.length >= 0 ? '+' : ''}${dstBuf.length - srcBuf.length})`,
149
+ });
150
+ }
151
+ }
152
+
153
+ async function walkRel(root) {
154
+ const out = [];
155
+ async function visit(current, prefix) {
156
+ let entries;
157
+ try { entries = await fs.readdir(current, { withFileTypes: true }); }
158
+ catch { return; }
159
+ for (const e of entries) {
160
+ const abs = path.join(current, e.name);
161
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
162
+ if (e.isDirectory()) await visit(abs, rel);
163
+ else if (e.isFile()) out.push(rel);
164
+ }
165
+ }
166
+ await visit(root, '');
167
+ return out;
168
+ }
169
+
120
170
  // --- apply ---
121
171
 
122
172
  export async function applyReverse(targetId, opts = {}) {
123
173
  const strategy = opts.strategy ?? 'skip';
174
+ const onProgress = opts.onProgress ?? (() => {});
124
175
  const { candidates } = await detectReverse(targetId, opts);
125
176
  const results = [];
126
177
 
127
- for (const c of candidates) {
178
+ for (let i = 0; i < candidates.length; i++) {
179
+ const c = candidates[i];
128
180
  if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) {
129
181
  results.push({ ...c, action: 'skipped (filter)' });
182
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
130
183
  continue;
131
184
  }
132
185
 
133
186
  const action = await applyOne(c, strategy, opts);
134
187
  results.push({ ...c, action });
188
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
135
189
  }
136
190
 
137
191
  return { target: targetId, strategy, results };
@@ -139,6 +193,13 @@ export async function applyReverse(targetId, opts = {}) {
139
193
 
140
194
  async function applyOne(c, strategy, opts) {
141
195
  const dryRun = !!opts.dryRun;
196
+ const isMirrorTree = c.kind === 'framework' || c.kind === 'hooks';
197
+
198
+ // Mirror-tree files don't have stub boilerplate — copy bytes verbatim.
199
+ if (isMirrorTree) {
200
+ return applyMirrorTreeOne(c, strategy, dryRun);
201
+ }
202
+
142
203
  const destContent = await fs.readFile(c.destPath, 'utf8');
143
204
  const stripped = stripStubBoilerplate(destContent);
144
205
 
@@ -147,7 +208,6 @@ async function applyOne(c, strategy, opts) {
147
208
  return 'skipped';
148
209
 
149
210
  case 'overwrite': {
150
- // Replace canonical with the destination content (stripped of stub boilerplate)
151
211
  if (!dryRun) {
152
212
  await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
153
213
  await fs.writeFile(c.kitPath, stripped, 'utf8');
@@ -156,8 +216,6 @@ async function applyOne(c, strategy, opts) {
156
216
  }
157
217
 
158
218
  case 'merge': {
159
- // Frontmatter from canonical (if present), body from destination.
160
- // If canonical doesn't exist (new-in-ide), this degenerates to overwrite.
161
219
  let merged = stripped;
162
220
  if (c.reason === 'modified-in-ide') {
163
221
  try {
@@ -173,7 +231,6 @@ async function applyOne(c, strategy, opts) {
173
231
  }
174
232
 
175
233
  case 'rename': {
176
- // Write next to canonical with a -from-<targetfolder>.md suffix
177
234
  const base = c.kitPath.replace(/\.md$/, '');
178
235
  const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '');
179
236
  const out = `${base}-from-${tag || 'ide'}.md`;
@@ -189,6 +246,42 @@ async function applyOne(c, strategy, opts) {
189
246
  }
190
247
  }
191
248
 
249
+ async function applyMirrorTreeOne(c, strategy, dryRun) {
250
+ switch (strategy) {
251
+ case 'skip':
252
+ return 'skipped';
253
+
254
+ case 'overwrite':
255
+ case 'merge': {
256
+ // For framework/hooks files there's no frontmatter to preserve,
257
+ // so 'merge' degenerates to overwrite. Returning a verb that
258
+ // signals the degradation.
259
+ const verb = strategy === 'merge' ? 'merged (overwrite, no frontmatter)' : 'overwritten';
260
+ if (!dryRun) {
261
+ await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
262
+ await fs.copyFile(c.destPath, c.kitPath);
263
+ }
264
+ return dryRun ? `${strategy} (dry-run)` : verb;
265
+ }
266
+
267
+ case 'rename': {
268
+ // Write to kit/<source>/<rel>.from-<tag> preserving extension after the tag.
269
+ const ext = path.extname(c.kitPath);
270
+ const stem = c.kitPath.slice(0, c.kitPath.length - ext.length);
271
+ const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '') || 'ide';
272
+ const out = `${stem}.from-${tag}${ext}`;
273
+ if (!dryRun) {
274
+ await fs.mkdir(path.dirname(out), { recursive: true });
275
+ await fs.copyFile(c.destPath, out);
276
+ }
277
+ return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`;
278
+ }
279
+
280
+ default:
281
+ return `unknown strategy: ${strategy}`;
282
+ }
283
+ }
284
+
192
285
  // --- helpers ---
193
286
 
194
287
  function isCleanStub(content) {
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
 
@@ -196,15 +200,19 @@ function renderReference(item, kitRoot, outPath, isSkill) {
196
200
  ? item.frontmatterRaw
197
201
  : synthFrontmatter(item);
198
202
 
199
- const desc = item.description ? `\n> ${item.description}\n` : '';
200
- // Blank line between frontmatter and the stub marker so YAML parsers don't choke.
201
- return `${fm}\n${STUB_MARKER}
203
+ // Body must NOT start with the STUB_MARKER comment — IDE listings (e.g. Claude Desktop)
204
+ // that take the first non-blank body line as the visible description would surface
205
+ // "<!-- kit-mcp:reference -->" instead of the real description. So we open with the
206
+ // H1 + description blockquote, and tuck the marker at the end as a trailing comment.
207
+ const descLine = item.description ? `\n> ${item.description}\n` : '';
208
+ return `${fm}
202
209
  # ${item.name}
203
-
210
+ ${descLine}
204
211
  > Canonical source: [\`${rel}\`](${rel})
205
- ${desc}
206
- > Generated by kit-mcp at ${new Date().toISOString()}.
207
212
  > Edit the source file in the kit, not this stub.
213
+ > Generated by kit-mcp at ${new Date().toISOString()}.
214
+
215
+ ${STUB_MARKER}
208
216
  `;
209
217
  }
210
218
 
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 };