@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 +50 -1
- package/README.md +16 -2
- package/package.json +4 -2
- package/src/cli/index.js +170 -35
- package/src/cli/render.js +187 -0
- package/src/core/reverse-sync.js +5 -1
- package/src/core/sync.js +4 -0
- package/src/core/ui.js +167 -0
package/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.
|
|
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:** ~
|
|
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.
|
|
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.
|
|
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.
|
|
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 () =>
|
|
39
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 () =>
|
|
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) =>
|
|
58
|
-
sync.command('install
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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 () =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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 () =>
|
|
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) =>
|
|
168
|
-
install.command('write
|
|
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
|
-
.
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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/reverse-sync.js
CHANGED
|
@@ -171,17 +171,21 @@ async function walkRel(root) {
|
|
|
171
171
|
|
|
172
172
|
export async function applyReverse(targetId, opts = {}) {
|
|
173
173
|
const strategy = opts.strategy ?? 'skip';
|
|
174
|
+
const onProgress = opts.onProgress ?? (() => {});
|
|
174
175
|
const { candidates } = await detectReverse(targetId, opts);
|
|
175
176
|
const results = [];
|
|
176
177
|
|
|
177
|
-
for (
|
|
178
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
179
|
+
const c = candidates[i];
|
|
178
180
|
if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) {
|
|
179
181
|
results.push({ ...c, action: 'skipped (filter)' });
|
|
182
|
+
onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
|
|
180
183
|
continue;
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
const action = await applyOne(c, strategy, opts);
|
|
184
187
|
results.push({ ...c, action });
|
|
188
|
+
onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
return { target: targetId, strategy, results };
|
package/src/core/sync.js
CHANGED
|
@@ -24,6 +24,7 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
24
24
|
const kitRoot = resolveKitRoot(opts.kitRoot);
|
|
25
25
|
const mode = opts.mode ?? 'reference';
|
|
26
26
|
const dryRun = !!opts.dryRun;
|
|
27
|
+
const onProgress = opts.onProgress ?? (() => {});
|
|
27
28
|
|
|
28
29
|
const kit = await listKit(kitRoot);
|
|
29
30
|
const ops = [];
|
|
@@ -82,6 +83,7 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
if (!dryRun) {
|
|
86
|
+
let i = 0;
|
|
85
87
|
for (const op of ops) {
|
|
86
88
|
await fs.mkdir(path.dirname(op.path), { recursive: true });
|
|
87
89
|
if (op.treeCopy) {
|
|
@@ -89,6 +91,8 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
89
91
|
} else {
|
|
90
92
|
await fs.writeFile(op.path, op.content, 'utf8');
|
|
91
93
|
}
|
|
94
|
+
i++;
|
|
95
|
+
onProgress({ phase: op.kind, current: i, total: ops.length, label: path.basename(op.path) });
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
package/src/core/ui.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// UI primitives for the CLI: colors, icons, spinner, progress bar,
|
|
2
|
+
// interactive select/confirm prompts, and a summary panel.
|
|
3
|
+
//
|
|
4
|
+
// Design rules:
|
|
5
|
+
// - Respect process.stdout.isTTY: animations only when interactive.
|
|
6
|
+
// In pipes/CI, fall back to linear status text.
|
|
7
|
+
// - Respect NO_COLOR (https://no-color.org) and FORCE_COLOR=1.
|
|
8
|
+
// - Animations write to stderr to keep stdout clean for `--json` mode
|
|
9
|
+
// (the user can still pipe machine-readable output even with spinners).
|
|
10
|
+
// - Zero hidden globals — every primitive is a plain function/class.
|
|
11
|
+
|
|
12
|
+
import pc from 'picocolors';
|
|
13
|
+
import { select as inqSelect, confirm as inqConfirm } from '@inquirer/prompts';
|
|
14
|
+
|
|
15
|
+
// --- color helpers ---
|
|
16
|
+
|
|
17
|
+
const NO_COLOR = process.env.NO_COLOR && process.env.NO_COLOR !== '0';
|
|
18
|
+
const FORCE = process.env.FORCE_COLOR === '1';
|
|
19
|
+
const COLOR_ON = FORCE || (!NO_COLOR && process.stdout.isTTY);
|
|
20
|
+
|
|
21
|
+
function id(s) { return String(s); }
|
|
22
|
+
export const c = COLOR_ON
|
|
23
|
+
? {
|
|
24
|
+
green: pc.green, red: pc.red, yellow: pc.yellow, cyan: pc.cyan,
|
|
25
|
+
magenta: pc.magenta, blue: pc.blue, dim: pc.dim, bold: pc.bold,
|
|
26
|
+
gray: pc.gray, underline: pc.underline,
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
green: id, red: id, yellow: id, cyan: id, magenta: id, blue: id,
|
|
30
|
+
dim: id, bold: id, gray: id, underline: id,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const icons = {
|
|
34
|
+
check: '✓',
|
|
35
|
+
cross: '✗',
|
|
36
|
+
warn: '⚠',
|
|
37
|
+
dot: '•',
|
|
38
|
+
arrow: '→',
|
|
39
|
+
spinner: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// --- spinner ---
|
|
43
|
+
|
|
44
|
+
export function spinner({ text = '' } = {}) {
|
|
45
|
+
const tty = process.stderr.isTTY;
|
|
46
|
+
let i = 0;
|
|
47
|
+
let current = text;
|
|
48
|
+
let timer = null;
|
|
49
|
+
|
|
50
|
+
function render() {
|
|
51
|
+
process.stderr.write(`\r${c.cyan(icons.spinner[i])} ${current}\x1b[K`);
|
|
52
|
+
i = (i + 1) % icons.spinner.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tty) {
|
|
56
|
+
timer = setInterval(render, 80);
|
|
57
|
+
render();
|
|
58
|
+
} else {
|
|
59
|
+
process.stderr.write(`${icons.dot} ${current}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearLine() {
|
|
63
|
+
if (tty) process.stderr.write('\r\x1b[K');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
update(t) { current = t; if (!tty) process.stderr.write(`${icons.dot} ${t}\n`); },
|
|
68
|
+
succeed(t) {
|
|
69
|
+
if (timer) clearInterval(timer);
|
|
70
|
+
clearLine();
|
|
71
|
+
process.stderr.write(`${c.green(icons.check)} ${t ?? current}\n`);
|
|
72
|
+
},
|
|
73
|
+
fail(t) {
|
|
74
|
+
if (timer) clearInterval(timer);
|
|
75
|
+
clearLine();
|
|
76
|
+
process.stderr.write(`${c.red(icons.cross)} ${t ?? current}\n`);
|
|
77
|
+
},
|
|
78
|
+
stop() {
|
|
79
|
+
if (timer) clearInterval(timer);
|
|
80
|
+
clearLine();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- progress bar ---
|
|
86
|
+
|
|
87
|
+
export function progress({ total, label = '' } = {}) {
|
|
88
|
+
const tty = process.stderr.isTTY;
|
|
89
|
+
const width = 24;
|
|
90
|
+
let current = 0;
|
|
91
|
+
let lastLabel = label;
|
|
92
|
+
|
|
93
|
+
function render() {
|
|
94
|
+
const pct = total === 0 ? 100 : Math.min(100, Math.round((current / total) * 100));
|
|
95
|
+
const filled = Math.round((width * pct) / 100);
|
|
96
|
+
const bar = '━'.repeat(filled) + c.dim('━'.repeat(width - filled));
|
|
97
|
+
const line = `${c.cyan(bar)} ${pct.toString().padStart(3)}% ${c.dim(`(${current}/${total})`)} ${lastLabel}`;
|
|
98
|
+
process.stderr.write(`\r${line}\x1b[K`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tick({ label } = {}) {
|
|
102
|
+
current++;
|
|
103
|
+
if (label !== undefined) lastLabel = label;
|
|
104
|
+
if (tty) {
|
|
105
|
+
render();
|
|
106
|
+
} else if (current === total || current % Math.max(1, Math.floor(total / 10)) === 0) {
|
|
107
|
+
// Every ~10% in non-TTY mode
|
|
108
|
+
const pct = total === 0 ? 100 : Math.round((current / total) * 100);
|
|
109
|
+
process.stderr.write(` ${pct}% ${lastLabel}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function finish(text) {
|
|
114
|
+
if (tty) {
|
|
115
|
+
process.stderr.write('\r\x1b[K');
|
|
116
|
+
}
|
|
117
|
+
if (text) process.stderr.write(`${c.green(icons.check)} ${text}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (tty) render();
|
|
121
|
+
return { tick, finish };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- interactive prompts ---
|
|
125
|
+
|
|
126
|
+
export async function select(opts) {
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass the value as a flag instead.');
|
|
129
|
+
}
|
|
130
|
+
return inqSelect(opts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function confirm(opts) {
|
|
134
|
+
if (!process.stdin.isTTY) {
|
|
135
|
+
throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass --yes to skip confirmation.');
|
|
136
|
+
}
|
|
137
|
+
return inqConfirm(opts);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- summary panel ---
|
|
141
|
+
|
|
142
|
+
export function summary({ title, rows = [], total, hint }) {
|
|
143
|
+
const lines = [];
|
|
144
|
+
lines.push(`${c.green(icons.check)} ${c.bold(title)}`);
|
|
145
|
+
lines.push('');
|
|
146
|
+
|
|
147
|
+
// Compute label column width
|
|
148
|
+
const w = Math.max(...rows.map(r => String(r[0]).length), 0);
|
|
149
|
+
for (const [label, count, status] of rows) {
|
|
150
|
+
const cnt = count > 0 ? c.green(String(count).padStart(4)) : c.dim(String(count).padStart(4));
|
|
151
|
+
const tail = status === 'fail' ? c.red(icons.cross) : c.green(icons.check);
|
|
152
|
+
lines.push(` ${label.padEnd(w)} ${cnt} ${tail}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (total !== undefined || hint) {
|
|
156
|
+
lines.push('');
|
|
157
|
+
const totalStr = total !== undefined ? `Total: ${c.bold(total)}` : '';
|
|
158
|
+
const hintStr = hint ? c.dim(`· ${hint}`) : '';
|
|
159
|
+
lines.push(` ${totalStr}${totalStr && hintStr ? ' ' : ''}${hintStr}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- helpers exposed for tests ---
|
|
166
|
+
|
|
167
|
+
export const _internal = { COLOR_ON };
|