@luanpdd/kit-mcp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +32 -0
- package/bin/ui.js +74 -0
- package/package.json +2 -1
- package/src/cli/index.js +211 -10
- package/src/mcp-server/index.js +53 -6
- package/src/ui/auto-spawn.js +108 -0
- package/src/ui/browser.js +78 -0
- package/src/ui/client.js +115 -0
- package/src/ui/events.js +65 -0
- package/src/ui/lockfile.js +147 -0
- package/src/ui/port.js +67 -0
- package/src/ui/server.js +432 -0
- package/src/ui/static/index.html +609 -0
- package/src/ui/wrapper.js +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,106 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.2.0] - 2026-05-04
|
|
10
|
+
|
|
11
|
+
**GUI sidecar de acompanhamento.** Janela web localhost paralela mostra ao vivo (via Server-Sent Events) o que kit-mcp está fazendo enquanto sua IDE chama tools — `sync install`, `reverse-sync apply`, `gates run`. Sidecar é totalmente opt-in: quem não invoca `kit ui` continua com a experiência v1.1 idêntica.
|
|
12
|
+
|
|
13
|
+
### Adicionado — Phase 11: Lock arquitetural
|
|
14
|
+
- ADR consolidado em `.planning/decisions.md` (porta 7100-7199, lockfile em `os.tmpdir()` keyed por sha1(projectRoot), idle 30min default, sem auth no v1.2 com mitigação compensatória)
|
|
15
|
+
- Threat model em `docs/sidecar-security.md`
|
|
16
|
+
- 2 audit gates novos no CI: stdout discipline em `src/ui/` (proíbe `console.log`/`process.stdout.write`) e dep budget (≤ baseline+1)
|
|
17
|
+
|
|
18
|
+
### Adicionado — Phase 12: Fundações
|
|
19
|
+
- `src/ui/events.js` — schema de evento, validador puro, `makeEvent`, `newRunId`
|
|
20
|
+
- `src/ui/port.js` — `findFreePort` na faixa 7100-7199 com retry-loop
|
|
21
|
+
- `src/ui/lockfile.js` — `acquireLock` atômico via `O_EXCL`, `probeStale` via `process.kill(pid, 0)` + healthz HTTP
|
|
22
|
+
|
|
23
|
+
### Adicionado — Phase 13: Servidor HTTP + SSE
|
|
24
|
+
- `src/ui/server.js` — http.Server nativo, bind 127.0.0.1 literal, 5 rotas (`/`, `/events` SSE, `/healthz`, `/state`, `/publish`, `/shutdown`)
|
|
25
|
+
- Heartbeat `: ping\n\n` cada 15s; reconnect auto via EventSource native + `retry: 3000`
|
|
26
|
+
- Ring buffer in-memory de 200 eventos (FIFO; sem persistência em disco)
|
|
27
|
+
- Cap de 32 conexões SSE; cleanup quádruplo (req+res × close+error)
|
|
28
|
+
- Idle shutdown 30min default (`--idle-ms 0` desabilita)
|
|
29
|
+
- Encerramento gracioso em SIGINT/SIGTERM com active sockets destruídos
|
|
30
|
+
- Validação de `Host` header (mitiga DNS rebinding) e `Origin` em endpoints non-GET
|
|
31
|
+
- `bin/ui.js` entry detached
|
|
32
|
+
|
|
33
|
+
### Adicionado — Phase 14: UI estática single-file
|
|
34
|
+
- `src/ui/static/index.html` (~470 LOC) — vanilla DOM + EventSource, sem build step
|
|
35
|
+
- Lista cronológica + auto-scroll + `<details>` expand
|
|
36
|
+
- Badges coloridos por tipo (`run.start`, `run.end`, `tool_invocation`, `progress`, `milestone`, `error`, `shutdown`)
|
|
37
|
+
- Status conexão (CONNECTING/OPEN/CLOSED) + reconexão automática
|
|
38
|
+
- Filter por tipo (chips) + substring search
|
|
39
|
+
- Pause/resume com buffer + autoscroll toggle
|
|
40
|
+
- Dark mode automático via `prefers-color-scheme`
|
|
41
|
+
- Banner de shutdown PT-BR em CLOSED >5s ou evento `shutdown`
|
|
42
|
+
- CSP estrito (`default-src 'self'; ...; frame-ancestors 'none'`)
|
|
43
|
+
|
|
44
|
+
### Adicionado — Phase 15: Publisher + wrapper + browser-open
|
|
45
|
+
- `src/ui/client.js` — `publish(event, {projectRoot})` fire-and-forget, cache TTL 5s, falha silenciosa em ECONNREFUSED
|
|
46
|
+
- `src/ui/wrapper.js` — `wrapProgressForUi(onProgress, ctx)` multiplexa terminal + sidecar; helpers `.done/.error/.emit`; `redactPath` central scrubando `$HOME → ~` e `projectRoot → <project>` em TODO payload
|
|
47
|
+
- `src/ui/browser.js` — wrapper sobre `open@11` com detection de headless (CI, DISPLAY, SSH, WSL, sandbox); fallback "imprime URL no stderr"
|
|
48
|
+
- Nova dep: `open@^11.0.0` (única adição; budget atingido em 6/6)
|
|
49
|
+
|
|
50
|
+
### Adicionado — Phase 16: CLI integration
|
|
51
|
+
- `kit ui start` — sobe sidecar foreground (Ctrl+C mata); flags `--port`, `--idle-ms`, `--no-open`
|
|
52
|
+
- `kit ui stop` — POST /shutdown
|
|
53
|
+
- `kit ui status` — exibe pid, port, uptime, eventos, subscribers
|
|
54
|
+
- `kit ui open` — reabre browser na sidecar atual
|
|
55
|
+
- Auto-detect: `kit sync install` e `kit reverse-sync apply` checam lockfile e wrappam `onProgress` automaticamente quando sidecar está rodando
|
|
56
|
+
- Opt-out global via `--no-ui` flag ou `KIT_MCP_NO_UI=1` env var
|
|
57
|
+
|
|
58
|
+
### Adicionado — Phase 17: MCP --auto-spawn
|
|
59
|
+
- `src/ui/auto-spawn.js` — `ensureSidecar({projectRoot})` checa lockfile + healthz; se ausente, spawna `bin/ui.js` em **detached** com `windowsHide: true` e `stdio: ['ignore', 'ignore', 'inherit']` (fecha stdout completamente — não pode poluir canal MCP do parent)
|
|
60
|
+
- 3 tools MCP ganham campo opcional `autoSpawn: boolean` no inputSchema:
|
|
61
|
+
- `sync` (action=install)
|
|
62
|
+
- `reverse-sync` (action=apply)
|
|
63
|
+
- `gates` (nova action `run`, com autoSpawn)
|
|
64
|
+
- Tools triviais (`kit`, `forensics`, `install`) **não** ganham autoSpawn — explicit-out por design
|
|
65
|
+
|
|
66
|
+
### Adicionado — Phase 18: Hardening + release
|
|
67
|
+
- 3 hardening tests novos: kill -9 recovery, multi-publisher race, MCP stdio uncorrupted (validação rigorosa do REQ SEC-04 em produção)
|
|
68
|
+
- README seção "Live UI" com primeiros passos
|
|
69
|
+
- `npm pack --dry-run` valida que `src/ui/static/index.html` é incluído no tarball
|
|
70
|
+
|
|
71
|
+
### Corrigido
|
|
72
|
+
- **REL-01 (bug pré-existente):** `kit --version` agora lê de `package.json` em vez de retornar string hardcoded `1.0.0`. Em v1.0/v1.1 o comando exibia versão errada — corrigido nesta release.
|
|
73
|
+
|
|
74
|
+
### Stable API additions (1.x compatible)
|
|
75
|
+
|
|
76
|
+
A v1.0 commitment continua válida. Estas adições são parte do contrato:
|
|
77
|
+
|
|
78
|
+
- **MCP tool `sync` inputSchema:** campo opcional `autoSpawn: boolean` em action=install. Tools que não passam mantêm comportamento idêntico.
|
|
79
|
+
- **MCP tool `reverse-sync` inputSchema:** campo opcional `autoSpawn: boolean` em action=apply.
|
|
80
|
+
- **MCP tool `gates` inputSchema:** campo opcional `autoSpawn: boolean` E nova action `run` com `id`/`projectRoot`/`autoSpawn` campos.
|
|
81
|
+
- **CLI subgroup `kit ui`:** novo grupo com `start | stop | status | open` subcommands.
|
|
82
|
+
- **CLI flag `--no-ui` global** + env var `KIT_MCP_NO_UI=1` — opt-out do auto-detect de sidecar.
|
|
83
|
+
- **Stable runtime guarantee:** core (`syncTo`, `applyReverse`, `runGate`) é literalmente intocado. Wrapper de `onProgress` é montado APENAS no callsite (CLI handler ou MCP tool handler).
|
|
84
|
+
|
|
85
|
+
### Migration
|
|
86
|
+
|
|
87
|
+
**Usuários v1.1 não precisam fazer nada.** Sidecar é estritamente opt-in.
|
|
88
|
+
|
|
89
|
+
Para experimentar a UI:
|
|
90
|
+
```bash
|
|
91
|
+
# 1. Em um terminal:
|
|
92
|
+
kit ui start
|
|
93
|
+
|
|
94
|
+
# 2. Em outro (ou via Claude Code/Cursor):
|
|
95
|
+
kit sync install claude-code
|
|
96
|
+
|
|
97
|
+
# A janela mostra o progresso em tempo real.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Para tools MCP, passe `autoSpawn: true` quando quiser auto-abrir:
|
|
101
|
+
```jsonc
|
|
102
|
+
{ "tool": "sync", "arguments": { "action": "install", "target": "claude-code", "autoSpawn": true } }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Threat model resumido
|
|
106
|
+
|
|
107
|
+
Sidecar é **localhost only**, single-user, dev workstation. Sem auth (mitigado por bind 127.0.0.1 + Host/Origin check + CSP estrito + path scrubbing). Sem persistência. Sem TLS (loopback). Detalhes em [`docs/sidecar-security.md`](docs/sidecar-security.md).
|
|
108
|
+
|
|
9
109
|
## [1.1.0] - 2026-05-03
|
|
10
110
|
|
|
11
111
|
**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.
|
package/README.md
CHANGED
|
@@ -272,6 +272,38 @@ kit forensics load-replay <id> --project-root .
|
|
|
272
272
|
|
|
273
273
|
The proposal is always saved to `.planning/learnings/{agent}.proposal.md` first; the canonical is only modified after explicit confirmation (or `--apply`). MCP `forensics.reflect` never auto-applies.
|
|
274
274
|
|
|
275
|
+
### `kit ui ...` — live process viewer (sidecar) — _new in 1.2_
|
|
276
|
+
|
|
277
|
+
A tiny localhost web app that streams what kit-mcp is doing while your IDE drives it. Strictly opt-in: ignore it and v1.1 behavior is unchanged.
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
# In one terminal — keeps running until Ctrl+C
|
|
281
|
+
kit ui start
|
|
282
|
+
|
|
283
|
+
# In another terminal (or via Claude Code / Cursor) — runs as before, but events
|
|
284
|
+
# are now also broadcast to the sidecar window
|
|
285
|
+
kit sync install claude-code
|
|
286
|
+
|
|
287
|
+
# Tools too: pass autoSpawn:true on the MCP side, or just `kit ui start` first
|
|
288
|
+
kit ui status
|
|
289
|
+
kit ui stop
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
What you get:
|
|
293
|
+
|
|
294
|
+
- A single browser tab at `http://127.0.0.1:7100` (or the next free port up to 7199)
|
|
295
|
+
- Live event stream over Server-Sent Events — `tool_invocation`, `progress`, `error`, `milestone`, `run.start`, `run.end`
|
|
296
|
+
- Filters by event type and substring; pause/resume; auto-scroll; dark mode tracks the OS
|
|
297
|
+
- The sidecar shuts itself down after 30 minutes of idle; `--idle-ms 0` disables that
|
|
298
|
+
|
|
299
|
+
**Auto-spawn from MCP tools.** Pass `autoSpawn: true` in the inputSchema of `sync` (action=install), `reverse-sync` (action=apply), or `gates` (action=run). The MCP server will spawn `bin/ui.js` detached, wait for it to come online, open your default browser, and stream that tool's progress. Trivial tools (`kit list-*`, `forensics`, `install`) deliberately don't accept `autoSpawn` — the overhead isn't worth it.
|
|
300
|
+
|
|
301
|
+
**Opt-out always available.** From the CLI: pass `--no-ui` or set `KIT_MCP_NO_UI=1`. The sidecar is never started behind your back; it's only used when a lockfile is already present (someone ran `kit ui start` or `autoSpawn: true`).
|
|
302
|
+
|
|
303
|
+
**Security model.** Sidecar binds to `127.0.0.1` literally — never `0.0.0.0`, never `localhost` (which resolves to `::1` on Windows). Every route validates the `Host` header to mitigate DNS rebinding. CSP is strict (`default-src 'self'; …; frame-ancestors 'none'`). Paths in payloads are scrubbed (`$HOME → ~`, `<projectRoot> → <project>`) so screenshots don't leak directory structure. No persistence, no TLS, no auth — single-user dev workstation only. Full threat model in [`docs/sidecar-security.md`](docs/sidecar-security.md).
|
|
304
|
+
|
|
305
|
+
**First-run quirks.** Windows Defender / macOS firewall may prompt the first time `kit ui start` binds. Approving "Private networks" is enough — the server doesn't accept anything from outside loopback regardless. On WSL, `kit ui start` opens the URL in the Windows host browser via `wslview`. In CI / SSH / `TERM=dumb`, browser launch is suppressed and the URL is printed to stderr instead.
|
|
306
|
+
|
|
275
307
|
---
|
|
276
308
|
|
|
277
309
|
## MCP usage
|
package/bin/ui.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/ui.js — entry point for the sidecar HTTP server.
|
|
3
|
+
//
|
|
4
|
+
// Used both directly (when the user runs `kit ui start`) and as the spawn target
|
|
5
|
+
// when `--auto-spawn` is enabled on an MCP tool.
|
|
6
|
+
//
|
|
7
|
+
// Logging discipline: all output goes to stderr. stdout is reserved so that
|
|
8
|
+
// callers can pipe `node bin/ui.js | jq` without UI server chatter contaminating
|
|
9
|
+
// data streams (and so that this file can never poison an MCP stdio channel).
|
|
10
|
+
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import { createServer } from '../src/ui/server.js';
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const args = { projectRoot: process.cwd(), port: undefined, idleMs: undefined };
|
|
16
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
+
const a = argv[i];
|
|
18
|
+
if (a === '--project-root' && argv[i + 1]) { args.projectRoot = argv[i + 1]; i += 1; }
|
|
19
|
+
else if (a === '--port' && argv[i + 1]) { args.port = Number(argv[i + 1]); i += 1; }
|
|
20
|
+
else if (a === '--idle-ms' && argv[i + 1]) { args.idleMs = Number(argv[i + 1]); i += 1; }
|
|
21
|
+
else if (a === '--version') { args.printVersion = true; }
|
|
22
|
+
else if (a === '--help' || a === '-h') { args.help = true; }
|
|
23
|
+
}
|
|
24
|
+
return args;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const args = parseArgs(process.argv.slice(2));
|
|
28
|
+
|
|
29
|
+
if (args.help) {
|
|
30
|
+
process.stderr.write([
|
|
31
|
+
'kit-mcp sidecar entry — usually invoked via `kit ui start`',
|
|
32
|
+
'',
|
|
33
|
+
'Usage: node bin/ui.js [options]',
|
|
34
|
+
' --project-root <path> project root for lockfile keying (default: cwd)',
|
|
35
|
+
' --port <n> bind to a specific port (default: auto-pick 7100-7199)',
|
|
36
|
+
' --idle-ms <ms> idle shutdown timeout (default: 1800000 = 30min; 0 = never)',
|
|
37
|
+
' --version print version and exit',
|
|
38
|
+
' --help this text',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n'));
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let pkgVersion = null;
|
|
45
|
+
try {
|
|
46
|
+
const { default: pkg } = await import('../package.json', { with: { type: 'json' } });
|
|
47
|
+
pkgVersion = pkg.version;
|
|
48
|
+
} catch {
|
|
49
|
+
// ok — version may not be available in some packaged contexts
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (args.printVersion) {
|
|
53
|
+
process.stderr.write(`${pkgVersion ?? 'unknown'}\n`);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = createServer({
|
|
58
|
+
projectRoot: args.projectRoot,
|
|
59
|
+
version: pkgVersion,
|
|
60
|
+
idleMs: args.idleMs,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const { port } = await server.start({ port: args.port });
|
|
65
|
+
process.stderr.write(`[kit-mcp ui] listening on http://127.0.0.1:${port}/\n`);
|
|
66
|
+
process.stderr.write(`[kit-mcp ui] project: ${args.projectRoot}\n`);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === 'ELIVE') {
|
|
69
|
+
process.stderr.write(`[kit-mcp ui] sidecar already running for this project (pid=${err.lock?.pid}, port=${err.lock?.port})\n`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
process.stderr.write(`[kit-mcp ui] failed to start: ${err.message}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luanpdd/kit-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
52
52
|
"chokidar": "^5.0.0",
|
|
53
53
|
"commander": "^12.1.0",
|
|
54
|
+
"open": "^11.0.0",
|
|
54
55
|
"picocolors": "^1.1.1"
|
|
55
56
|
}
|
|
56
57
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// programmatic consumers.
|
|
14
14
|
|
|
15
15
|
import { Command } from 'commander';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import path from 'node:path';
|
|
16
19
|
import { listKit, searchKit, findItem } from '../core/kit.js';
|
|
17
20
|
import { listTargets } from '../core/registry.js';
|
|
18
21
|
import { syncTo, statusOf, removeFrom } from '../core/sync.js';
|
|
@@ -26,13 +29,31 @@ import { listReplays, loadReplay } from '../core/replays.js';
|
|
|
26
29
|
import { installMcp, listInstallTargets } from '../mcp-server/install.js';
|
|
27
30
|
import * as render from './render.js';
|
|
28
31
|
import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
|
|
32
|
+
import { createServer } from '../ui/server.js';
|
|
33
|
+
import { readLock, lockPathFor } from '../ui/lockfile.js';
|
|
34
|
+
import { wrapProgressForUi } from '../ui/wrapper.js';
|
|
35
|
+
import { openBrowser } from '../ui/browser.js';
|
|
36
|
+
import http from 'node:http';
|
|
37
|
+
|
|
38
|
+
// Read package.json version at boot so `--version` is always accurate. Falls
|
|
39
|
+
// back to a string if the file lookup fails (e.g. unusual install layout).
|
|
40
|
+
function readPkgVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const pkgPath = path.resolve(here, '..', '..', 'package.json');
|
|
44
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
45
|
+
} catch {
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
29
49
|
|
|
30
50
|
const program = new Command()
|
|
31
51
|
.name('kit')
|
|
32
52
|
.description('Personal kit (agents/commands/skills) — CLI mirror of the kit-mcp server.')
|
|
33
|
-
.version(
|
|
53
|
+
.version(readPkgVersion())
|
|
34
54
|
.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)')
|
|
55
|
+
.option('--json', 'Output JSON to stdout (machine-readable, restores pre-1.1 default)')
|
|
56
|
+
.option('--no-ui', 'Suppress sidecar event publishing for this run (default: auto-detect lockfile)');
|
|
36
57
|
|
|
37
58
|
program.hook('preAction', (thisCommand, actionCommand) => {
|
|
38
59
|
const opts = program.opts();
|
|
@@ -67,22 +88,57 @@ async function withSpinner(text, fn) {
|
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
// withProgress wraps a long op; passes onProgress callback to the core fn.
|
|
70
|
-
|
|
91
|
+
// Also auto-detects a running sidecar (via lockfile) and multiplexes events to
|
|
92
|
+
// it when present. Opt-out via --no-ui or KIT_MCP_NO_UI=1.
|
|
93
|
+
async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
|
|
71
94
|
const opts = program.opts();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
let onProgress;
|
|
96
|
+
let p = null;
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
onProgress = () => {};
|
|
99
|
+
} else {
|
|
100
|
+
p = progress({ total, label });
|
|
101
|
+
let last = '';
|
|
102
|
+
onProgress = ({ current, label }) => { last = label || last; p.tick({ label: last }); };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Auto-wrap if a sidecar is running for this projectRoot.
|
|
106
|
+
const wrapper = maybeWrapForUi(onProgress, { tool, projectRoot });
|
|
76
107
|
try {
|
|
77
|
-
const r = await fn(
|
|
78
|
-
p.finish(label);
|
|
108
|
+
const r = await fn(wrapper);
|
|
109
|
+
if (p) p.finish(label);
|
|
110
|
+
if (wrapper.done) wrapper.done({ ok: true });
|
|
79
111
|
return r;
|
|
80
112
|
} catch (e) {
|
|
81
|
-
p.finish();
|
|
113
|
+
if (p) p.finish();
|
|
114
|
+
if (wrapper.error) wrapper.error(e);
|
|
82
115
|
throw e;
|
|
83
116
|
}
|
|
84
117
|
}
|
|
85
118
|
|
|
119
|
+
// maybeWrapForUi — returns the original callback unchanged when no sidecar is up
|
|
120
|
+
// or the user opted out. Otherwise returns a wrapped callback with .done/.error.
|
|
121
|
+
function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
|
|
122
|
+
const globalOpts = program.opts();
|
|
123
|
+
// commander stores `--no-ui` as opts.ui === false
|
|
124
|
+
if (globalOpts.ui === false || process.env.KIT_MCP_NO_UI === '1') {
|
|
125
|
+
return passthroughWrapper(onProgress);
|
|
126
|
+
}
|
|
127
|
+
const root = projectRoot || process.cwd();
|
|
128
|
+
if (!readLock(root)) {
|
|
129
|
+
return passthroughWrapper(onProgress);
|
|
130
|
+
}
|
|
131
|
+
return wrapProgressForUi(onProgress, { projectRoot: root, tool: tool ?? null });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function passthroughWrapper(onProgress) {
|
|
135
|
+
const cb = (p) => { if (typeof onProgress === 'function') onProgress(p); };
|
|
136
|
+
cb.done = () => {};
|
|
137
|
+
cb.error = () => {};
|
|
138
|
+
cb.emit = () => {};
|
|
139
|
+
return cb;
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
function fail(msg) {
|
|
87
143
|
process.stderr.write(`${c.red(icons.cross)} ${msg}\n`);
|
|
88
144
|
process.exit(1);
|
|
@@ -134,6 +190,7 @@ sync.command('install [target]')
|
|
|
134
190
|
`Syncing kit → ${target}`,
|
|
135
191
|
300,
|
|
136
192
|
(onProgress) => syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun, onProgress }),
|
|
193
|
+
{ tool: 'sync.install', projectRoot: opts.projectRoot },
|
|
137
194
|
);
|
|
138
195
|
out(result, render.renderSyncInstall);
|
|
139
196
|
});
|
|
@@ -181,6 +238,7 @@ reverse.command('apply <target>')
|
|
|
181
238
|
`Applying reverse-sync (${opts.strategy})`,
|
|
182
239
|
50,
|
|
183
240
|
(onProgress) => applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun, onProgress }),
|
|
241
|
+
{ tool: 'reverse-sync.apply', projectRoot: opts.projectRoot },
|
|
184
242
|
);
|
|
185
243
|
out(result, render.renderReverseApply);
|
|
186
244
|
});
|
|
@@ -314,4 +372,147 @@ async function pickTarget(targets, message) {
|
|
|
314
372
|
}
|
|
315
373
|
}
|
|
316
374
|
|
|
375
|
+
// --- ui (sidecar process viewer) ---
|
|
376
|
+
const ui = program.command('ui').description('Live process viewer in a localhost browser tab.');
|
|
377
|
+
|
|
378
|
+
ui.command('start')
|
|
379
|
+
.description('Start the sidecar HTTP server in foreground (Ctrl+C to stop). Prints URL on stderr.')
|
|
380
|
+
.option('--project-root <path>', 'Project root for lockfile keying (default: cwd)')
|
|
381
|
+
.option('--port <n>', 'Bind to a specific port (default: auto-pick 7100-7199)')
|
|
382
|
+
.option('--idle-ms <ms>', 'Idle shutdown timeout (default 30min; 0 = never)')
|
|
383
|
+
.option('--no-open', 'Skip auto-opening the browser')
|
|
384
|
+
.action(async (opts) => {
|
|
385
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
386
|
+
const port = opts.port ? Number(opts.port) : undefined;
|
|
387
|
+
const idleMs = opts.idleMs !== undefined ? Number(opts.idleMs) : undefined;
|
|
388
|
+
const srv = createServer({ projectRoot, idleMs });
|
|
389
|
+
try {
|
|
390
|
+
const { port: actualPort } = await srv.start({ port });
|
|
391
|
+
const url = `http://127.0.0.1:${actualPort}/`;
|
|
392
|
+
process.stderr.write(`${c.cyan(icons.info)} kit-mcp ui listening on ${url}\n`);
|
|
393
|
+
process.stderr.write(`${c.dim(` project: ${projectRoot}`)}\n`);
|
|
394
|
+
if (opts.open !== false) {
|
|
395
|
+
await openBrowser(url);
|
|
396
|
+
}
|
|
397
|
+
// The server's own SIGINT handler will perform shutdown + cleanup.
|
|
398
|
+
// We just stay alive — server is foreground.
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (err.code === 'ELIVE') {
|
|
401
|
+
process.stderr.write(`${c.yellow(icons.warn)} sidecar already running for this project\n`);
|
|
402
|
+
process.stderr.write(` pid: ${err.lock?.pid}, port: ${err.lock?.port}\n`);
|
|
403
|
+
process.stderr.write(` use 'kit ui status' or 'kit ui open' to inspect\n`);
|
|
404
|
+
process.exit(2);
|
|
405
|
+
}
|
|
406
|
+
fail(err.message);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
ui.command('stop')
|
|
411
|
+
.description('Stop the sidecar running for this project (POST /shutdown).')
|
|
412
|
+
.option('--project-root <path>')
|
|
413
|
+
.action(async (opts) => {
|
|
414
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
415
|
+
const lock = readLock(projectRoot);
|
|
416
|
+
if (!lock) return out({ ok: false, reason: 'no_sidecar' }, () => `${icons.warn} no sidecar running for this project\n`);
|
|
417
|
+
try {
|
|
418
|
+
await postShutdown(lock.port);
|
|
419
|
+
out({ ok: true, port: lock.port }, () => `${icons.check} sidecar at port ${lock.port} stopped\n`);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
fail(`could not stop sidecar at port ${lock.port}: ${err.message}`);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
ui.command('status')
|
|
426
|
+
.description('Show whether a sidecar is running for this project.')
|
|
427
|
+
.option('--project-root <path>')
|
|
428
|
+
.action(async (opts) => {
|
|
429
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
430
|
+
const lock = readLock(projectRoot);
|
|
431
|
+
if (!lock) {
|
|
432
|
+
out({ running: false, reason: 'no_lockfile' }, () => `${icons.warn} no sidecar running\n`);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const health = await getHealthz(lock.port);
|
|
437
|
+
out({ running: true, ...health, lockfile: lockPathFor(projectRoot) }, render.renderUiStatus ?? renderUiStatusFallback);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
out({ running: false, reason: 'unreachable', lockfile: lockPathFor(projectRoot), error: err.message },
|
|
440
|
+
() => `${icons.cross} lockfile present but sidecar unreachable: ${err.message}\n`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
ui.command('open')
|
|
446
|
+
.description('Open the running sidecar in a browser. Fails if no sidecar is up.')
|
|
447
|
+
.option('--project-root <path>')
|
|
448
|
+
.action(async (opts) => {
|
|
449
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
450
|
+
const lock = readLock(projectRoot);
|
|
451
|
+
if (!lock) return fail('no sidecar running — start one with `kit ui start`');
|
|
452
|
+
const url = `http://127.0.0.1:${lock.port}/`;
|
|
453
|
+
const r = await openBrowser(url, { force: true });
|
|
454
|
+
if (!r.opened) {
|
|
455
|
+
process.stderr.write(`${c.yellow(icons.warn)} could not open browser (${r.reason}); copy the URL above\n`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Helpers for kit ui (live in cli/ — stdout/console allowed here)
|
|
461
|
+
async function postShutdown(port) {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const req = http.request({
|
|
464
|
+
method: 'POST',
|
|
465
|
+
host: '127.0.0.1',
|
|
466
|
+
port,
|
|
467
|
+
path: '/shutdown',
|
|
468
|
+
agent: false,
|
|
469
|
+
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'content-length': 0, connection: 'close' },
|
|
470
|
+
}, (res) => {
|
|
471
|
+
res.resume();
|
|
472
|
+
res.on('end', () => res.statusCode < 400 ? resolve() : reject(new Error(`http_${res.statusCode}`)));
|
|
473
|
+
});
|
|
474
|
+
req.on('error', reject);
|
|
475
|
+
req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
|
|
476
|
+
req.end();
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function getHealthz(port) {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
const req = http.request({
|
|
483
|
+
method: 'GET',
|
|
484
|
+
host: '127.0.0.1',
|
|
485
|
+
port,
|
|
486
|
+
path: '/healthz',
|
|
487
|
+
agent: false,
|
|
488
|
+
headers: { host: `127.0.0.1:${port}`, connection: 'close' },
|
|
489
|
+
}, (res) => {
|
|
490
|
+
const chunks = [];
|
|
491
|
+
res.on('data', (c) => chunks.push(c));
|
|
492
|
+
res.on('end', () => {
|
|
493
|
+
if (res.statusCode >= 400) return reject(new Error(`http_${res.statusCode}`));
|
|
494
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
495
|
+
catch (e) { reject(e); }
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
req.on('error', reject);
|
|
499
|
+
req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
|
|
500
|
+
req.end();
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function renderUiStatusFallback(v) {
|
|
505
|
+
if (!v.running) return `${icons.warn} not running\n`;
|
|
506
|
+
return [
|
|
507
|
+
`${icons.check} sidecar running`,
|
|
508
|
+
` port: ${v.port}`,
|
|
509
|
+
` pid (sdcr): ${v.lockfile ? readLock(process.cwd())?.pid : '?'}`,
|
|
510
|
+
` uptime: ${Math.round((v.uptime || 0) / 1000)}s`,
|
|
511
|
+
` events: ${v.eventsTotal}`,
|
|
512
|
+
` subscribers: ${v.subscribers}`,
|
|
513
|
+
` url: http://127.0.0.1:${v.port}/`,
|
|
514
|
+
'',
|
|
515
|
+
].join('\n');
|
|
516
|
+
}
|
|
517
|
+
|
|
317
518
|
program.parseAsync(process.argv);
|
package/src/mcp-server/index.js
CHANGED
|
@@ -17,10 +17,13 @@ import { listTargets } from '../core/registry.js';
|
|
|
17
17
|
import { syncTo, statusOf, removeFrom } from '../core/sync.js';
|
|
18
18
|
import { detectReverse, applyReverse } from '../core/reverse-sync.js';
|
|
19
19
|
import { listGates, getGate, gatesForStage } from '../core/gates.js';
|
|
20
|
+
import { runGate } from '../core/gate-runner.js';
|
|
20
21
|
import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failures.js';
|
|
21
22
|
import { reflect } from '../core/reflect.js';
|
|
22
23
|
import { recordReplay, listReplays, loadReplay, annotateReplay } from '../core/replays.js';
|
|
23
24
|
import { installMcp, listInstallTargets } from './install.js';
|
|
25
|
+
import { ensureSidecar } from '../ui/auto-spawn.js';
|
|
26
|
+
import { wrapProgressForUi } from '../ui/wrapper.js';
|
|
24
27
|
|
|
25
28
|
const TOOLS = [
|
|
26
29
|
{
|
|
@@ -48,6 +51,7 @@ const TOOLS = [
|
|
|
48
51
|
projectRoot: { type: 'string', description: 'Defaults to cwd' },
|
|
49
52
|
mode: { type: 'string', enum: ['reference', 'copy'], description: 'Default: reference' },
|
|
50
53
|
dryRun: { type: 'boolean' },
|
|
54
|
+
autoSpawn: { type: 'boolean', description: 'On action=install: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
|
|
51
55
|
},
|
|
52
56
|
required: ['action'],
|
|
53
57
|
},
|
|
@@ -64,19 +68,22 @@ const TOOLS = [
|
|
|
64
68
|
strategy: { type: 'string', enum: ['skip', 'overwrite', 'merge', 'rename'], description: 'For action=apply' },
|
|
65
69
|
only: { type: 'array', items: { type: 'string' }, description: 'For action=apply: limit to these kind/name pairs' },
|
|
66
70
|
dryRun: { type: 'boolean' },
|
|
71
|
+
autoSpawn: { type: 'boolean', description: 'On action=apply: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
|
|
67
72
|
},
|
|
68
73
|
required: ['action', 'target'],
|
|
69
74
|
},
|
|
70
75
|
},
|
|
71
76
|
{
|
|
72
77
|
name: 'gates',
|
|
73
|
-
description: 'List or
|
|
78
|
+
description: 'List, fetch, or execute reusable workflow gates (regression, confidence, etc).',
|
|
74
79
|
inputSchema: {
|
|
75
80
|
type: 'object',
|
|
76
81
|
properties: {
|
|
77
|
-
action:
|
|
78
|
-
id:
|
|
79
|
-
stage:
|
|
82
|
+
action: { type: 'string', enum: ['list', 'get', 'for-stage', 'run'] },
|
|
83
|
+
id: { type: 'string', description: 'For action=get or action=run' },
|
|
84
|
+
stage: { type: 'string', enum: ['pre-plan', 'pre-execute', 'pre-verify', 'post-verify', 'any'], description: 'For action=for-stage' },
|
|
85
|
+
projectRoot: { type: 'string', description: 'For action=run' },
|
|
86
|
+
autoSpawn: { type: 'boolean', description: 'On action=run: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
|
|
80
87
|
},
|
|
81
88
|
required: ['action'],
|
|
82
89
|
},
|
|
@@ -136,11 +143,38 @@ async function handleKit(args) {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
// withAutoSpawn — if args.autoSpawn is set, ensure the sidecar is up and wrap
|
|
147
|
+
// the user-supplied onProgress so events flow there. Otherwise pass-through.
|
|
148
|
+
async function withAutoSpawn(args, tool, run) {
|
|
149
|
+
const projectRoot = args.projectRoot || process.cwd();
|
|
150
|
+
let wrapped = null;
|
|
151
|
+
let sidecarInfo = null;
|
|
152
|
+
|
|
153
|
+
if (args.autoSpawn) {
|
|
154
|
+
sidecarInfo = await ensureSidecar({ projectRoot, openBrowserOnSpawn: true });
|
|
155
|
+
if (sidecarInfo?.ready) {
|
|
156
|
+
wrapped = wrapProgressForUi(null, { projectRoot, tool });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// run(onProgress) — pass our wrapped callback (or undefined to no-op)
|
|
161
|
+
try {
|
|
162
|
+
const result = await run(wrapped);
|
|
163
|
+
if (wrapped?.done) wrapped.done({ ok: true });
|
|
164
|
+
return sidecarInfo ? { ...result, _sidecar: sidecarInfo } : result;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (wrapped?.error) wrapped.error(err);
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
139
171
|
async function handleSync(args) {
|
|
140
172
|
switch (args.action) {
|
|
141
173
|
case 'targets': return listTargets();
|
|
142
174
|
case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
|
|
143
|
-
case 'install':
|
|
175
|
+
case 'install':
|
|
176
|
+
return withAutoSpawn(args, 'sync.install', (onProgress) =>
|
|
177
|
+
syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
|
|
144
178
|
case 'remove': return removeFrom(args.target, { projectRoot: args.projectRoot });
|
|
145
179
|
default: return { error: `Unknown action: ${args.action}` };
|
|
146
180
|
}
|
|
@@ -149,7 +183,13 @@ async function handleSync(args) {
|
|
|
149
183
|
async function handleReverseSync(args) {
|
|
150
184
|
switch (args.action) {
|
|
151
185
|
case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
|
|
152
|
-
case 'apply':
|
|
186
|
+
case 'apply':
|
|
187
|
+
return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) =>
|
|
188
|
+
applyReverse(args.target, {
|
|
189
|
+
projectRoot: args.projectRoot,
|
|
190
|
+
strategy: args.strategy, only: args.only, dryRun: args.dryRun,
|
|
191
|
+
onProgress,
|
|
192
|
+
}));
|
|
153
193
|
default: return { error: `Unknown action: ${args.action}` };
|
|
154
194
|
}
|
|
155
195
|
}
|
|
@@ -159,6 +199,13 @@ async function handleGates(args) {
|
|
|
159
199
|
case 'list': return listGates();
|
|
160
200
|
case 'get': return getGate(args.id);
|
|
161
201
|
case 'for-stage': return gatesForStage(args.stage);
|
|
202
|
+
case 'run':
|
|
203
|
+
return withAutoSpawn(args, 'gates.run', () =>
|
|
204
|
+
runGate(args.id, {
|
|
205
|
+
projectRoot: args.projectRoot,
|
|
206
|
+
yes: true, // MCP context: never prompt
|
|
207
|
+
interactive: false, // MCP never prompts
|
|
208
|
+
}));
|
|
162
209
|
default: return { error: `Unknown action: ${args.action}` };
|
|
163
210
|
}
|
|
164
211
|
}
|