@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 +96 -1
- package/README.md +34 -6
- package/kit/commands/adicionar-backlog.md +1 -1
- package/kit/commands/adicionar-fase.md +1 -1
- package/kit/commands/adicionar-tarefa.md +1 -1
- package/kit/commands/concluir-marco.md +1 -1
- package/kit/commands/definir-perfil.md +1 -1
- package/kit/commands/depurar.md +1 -1
- package/kit/commands/fio.md +1 -1
- package/kit/commands/inserir-fase.md +1 -1
- package/package.json +8 -3
- package/src/cli/index.js +170 -35
- package/src/cli/render.js +187 -0
- package/src/core/kit.js +4 -1
- package/src/core/reverse-sync.js +98 -5
- package/src/core/sync.js +14 -6
- package/src/core/ui.js +167 -0
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/
|
|
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:** ~
|
|
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 |
|
|
@@ -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`, `.
|
|
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
|
-
##
|
|
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: 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
|
package/kit/commands/depurar.md
CHANGED
package/kit/commands/fio.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luanpdd/kit-mcp",
|
|
3
|
-
"version": "
|
|
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.
|
|
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/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 (
|
|
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
|
}
|
package/src/core/reverse-sync.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
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 };
|