@luanpdd/kit-mcp 1.15.0 → 1.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.15.0",
3
- "timestamp": "2026-05-09T13:10:19.305Z",
2
+ "version": "1.17.0",
3
+ "timestamp": "2026-05-09T15:58:57.716Z",
4
4
  "files": {
5
5
  "COMANDOS.md": "d24ec61a6ec35db314cc5f2ae287bfb927b794789c8f1d558c55862f5e6534b2",
6
6
  "COMPATIBILITY.md": "794e336a87045cdf0161785b9a7a0975a49abbd80bdd816b8852251fcc8126ca",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,11 +47,13 @@
47
47
  "prepublishOnly": "node scripts/regen-manifest.js && node scripts/update-readme-counts.js && node test/run.mjs test/unit && node test/run.mjs test/integration"
48
48
  },
49
49
  "dependencies": {
50
- "@inquirer/prompts": "^8.4.2",
51
50
  "@modelcontextprotocol/sdk": "^1.0.0",
52
- "chokidar": "^5.0.0",
53
51
  "commander": "^14.0.3",
54
- "open": "^11.0.0",
55
52
  "picocolors": "^1.1.1"
53
+ },
54
+ "optionalDependencies": {
55
+ "@inquirer/prompts": "^8.4.2",
56
+ "chokidar": "^5.0.0",
57
+ "open": "^11.0.0"
56
58
  }
57
59
  }
package/src/cli/index.js CHANGED
@@ -29,11 +29,16 @@ import { listReplays, loadReplay } from '../core/replays.js';
29
29
  import { installMcp, listInstallTargets } from '../mcp-server/install.js';
30
30
  import * as render from './render.js';
31
31
  import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
32
- import { createServer } from '../ui/server.js';
33
32
  import { readLock, lockPathFor } from '../ui/lockfile.js';
34
- import { wrapProgressForUi } from '../ui/wrapper.js';
35
- import { openBrowser } from '../ui/browser.js';
36
- import { checkUpgrade, getLocalVersion } from './upgrade-check.js';
33
+ import { checkUpgrade } from './upgrade-check.js';
34
+ // PERF-16-04: ui/server.js, ui/wrapper.js, ui/browser.js are loaded LAZILY
35
+ // inside the subcommand handlers that need them. See:
36
+ // - maybeWrapForUi (gated on lockfile presence)
37
+ // - ui.start (createServer + openBrowser)
38
+ // - ui.open (openBrowser)
39
+ // This trims ~700 LOC + transitive deps off the cold-start path of non-UI
40
+ // commands like `kit kit list-agents --terse`. lockfile.js stays eager because
41
+ // readLock() is called by every withProgress() invocation and is dep-free.
37
42
  import http from 'node:http';
38
43
  import fs from 'node:fs';
39
44
  import os from 'node:os';
@@ -106,7 +111,7 @@ async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
106
111
  }
107
112
 
108
113
  // Auto-wrap if a sidecar is running for this projectRoot.
109
- const wrapper = maybeWrapForUi(onProgress, { tool, projectRoot });
114
+ const wrapper = await maybeWrapForUi(onProgress, { tool, projectRoot });
110
115
  try {
111
116
  const r = await fn(wrapper);
112
117
  if (p) p.finish(label);
@@ -121,7 +126,11 @@ async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
121
126
 
122
127
  // maybeWrapForUi — returns the original callback unchanged when no sidecar is up
123
128
  // or the user opted out. Otherwise returns a wrapped callback with .done/.error.
124
- function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
129
+ //
130
+ // PERF-16-04: this is async because we lazy-load ../ui/wrapper.js only when a
131
+ // sidecar lockfile is detected. Common path (no sidecar) returns synchronously
132
+ // via passthroughWrapper without touching wrapper.js or its transitive deps.
133
+ async function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
125
134
  const globalOpts = program.opts();
126
135
  // commander stores `--no-ui` as opts.ui === false
127
136
  if (globalOpts.ui === false || process.env.KIT_MCP_NO_UI === '1') {
@@ -131,6 +140,8 @@ function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
131
140
  if (!readLock(root)) {
132
141
  return passthroughWrapper(onProgress);
133
142
  }
143
+ // Lazy import — only paid when a sidecar IS up for this project.
144
+ const { wrapProgressForUi } = await import('../ui/wrapper.js');
134
145
  return wrapProgressForUi(onProgress, { projectRoot: root, tool: tool ?? null });
135
146
  }
136
147
 
@@ -407,6 +418,8 @@ ui.command('start')
407
418
  const projectRoot = opts.projectRoot || process.cwd();
408
419
  const port = opts.port ? Number(opts.port) : undefined;
409
420
  const idleMs = opts.idleMs !== undefined ? Number(opts.idleMs) : undefined;
421
+ // PERF-16-04: lazy-load the sidecar HTTP server module only when starting it.
422
+ const { createServer } = await import('../ui/server.js');
410
423
  const srv = createServer({ projectRoot, idleMs });
411
424
  try {
412
425
  const { port: actualPort } = await srv.start({ port });
@@ -422,6 +435,8 @@ ui.command('start')
422
435
  }
423
436
  }).catch(() => { /* offline / silent */ });
424
437
  if (opts.open !== false) {
438
+ // PERF-16-04: lazy-load browser-opener (it lazy-loads `open` package itself).
439
+ const { openBrowser } = await import('../ui/browser.js');
425
440
  await openBrowser(url);
426
441
  }
427
442
  // The server's own SIGINT handler will perform shutdown + cleanup.
@@ -480,6 +495,8 @@ ui.command('open')
480
495
  const lock = readLock(projectRoot);
481
496
  if (!lock) return fail('no sidecar running — start one with `kit ui start`');
482
497
  const url = `http://127.0.0.1:${lock.port}/`;
498
+ // PERF-16-04: lazy-load browser-opener.
499
+ const { openBrowser } = await import('../ui/browser.js');
483
500
  const r = await openBrowser(url, { force: true });
484
501
  if (!r.opened) {
485
502
  process.stderr.write(`${c.yellow(icons.warn)} could not open browser (${r.reason}); copy the URL above\n`);
@@ -14,8 +14,39 @@ import path from 'node:path';
14
14
  import fs from 'node:fs/promises';
15
15
  import crypto from 'node:crypto';
16
16
 
17
+ // PERF-17-01: parallelize SHA256 hashing in batches of 16. Same pattern
18
+ // as Phase 88.01 sync.js. Hardcoded — env override is overengineering
19
+ // for verifyManifest (single hot path, not user-facing latency budget).
20
+ const BATCH_SIZE = 16;
21
+
22
+ // PERF-17-01: in-memory cache for verifyManifest. Same pattern as kit.js
23
+ // listKit cache (PERF-01). Watch triggers (file save → re-sync) call this
24
+ // back-to-back; the 2nd+ call within TTL hits cache and returns <5ms.
25
+ //
26
+ // Caching rules:
27
+ // - Only cache ok=true results. mismatches/missing → recompute every call
28
+ // so devs see fixes immediately (don't punish them for the slow path).
29
+ // - Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (test isolation + emergency dev escape).
30
+ // - Cache key is kitRoot — different roots are independent entries.
31
+ const VERIFY_CACHE_TTL_MS = 30_000;
32
+ const verifyManifestCache = new Map(); // kitRoot -> { value, ts }
33
+ const NO_CACHE_ENV = 'KIT_MCP_VERIFY_NO_CACHE';
34
+
35
+ /**
36
+ * Test/emergency helper — clears the cache. Exported for unit tests.
37
+ * Production code should never need this; use the env var instead.
38
+ */
39
+ export function clearVerifyManifestCache() { verifyManifestCache.clear(); }
40
+
17
41
  const SKIP_ENV = 'KIT_MCP_SKIP_MANIFEST_CHECK';
18
42
 
43
+ /**
44
+ * SEC-14-05: verify kit/file-manifest.json against actual file contents.
45
+ * PERF-17-01: hashes in Promise.all batches of 16 (was sequential pre-v1.17).
46
+ * Called by syncTo() in install path before any write — refuses to project a tampered kit.
47
+ * @param {string} kitRoot - absolute path to kit/ directory.
48
+ * @returns {Promise<{ok: boolean, skipped?: boolean, reason?: string, mismatches?: Array, missing?: string[]}>}
49
+ */
19
50
  export async function verifyManifest(kitRoot) {
20
51
  if (process.env[SKIP_ENV] === '1') {
21
52
  process.stderr.write(
@@ -24,6 +55,15 @@ export async function verifyManifest(kitRoot) {
24
55
  return { ok: true, skipped: true };
25
56
  }
26
57
 
58
+ // PERF-17-01: cache hit — repeated calls within TTL skip the I/O + hashing.
59
+ // Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (tests + dev emergency escape).
60
+ if (process.env[NO_CACHE_ENV] !== '1') {
61
+ const cached = verifyManifestCache.get(kitRoot);
62
+ if (cached && Date.now() - cached.ts < VERIFY_CACHE_TTL_MS) {
63
+ return cached.value;
64
+ }
65
+ }
66
+
27
67
  const manifestPath = path.join(kitRoot, 'file-manifest.json');
28
68
  let manifest;
29
69
  try {
@@ -50,27 +90,54 @@ export async function verifyManifest(kitRoot) {
50
90
  const mismatches = [];
51
91
  const missing = [];
52
92
 
53
- for (const [rel, expected] of Object.entries(manifest.files)) {
93
+ const entries = Object.entries(manifest.files);
94
+
95
+ // Per-file check — returns { rel, status: 'ok'|'mismatch'|'missing', expected?, actual? }.
96
+ // Pure function (no side effects on shared arrays) so Promise.all in batches
97
+ // is safe — caller aggregates after each batch resolves.
98
+ const checkOne = async ([rel, expected]) => {
54
99
  const abs = path.join(kitRoot, rel);
55
100
  let buf;
56
101
  try {
57
102
  buf = await fs.readFile(abs);
58
103
  } catch {
59
- missing.push(rel);
60
- continue;
104
+ return { rel, status: 'missing' };
61
105
  }
62
106
  // Normalize CRLF→LF before hashing so manifest is platform-stable.
63
107
  // git checkout converts EOL on Windows but Linux CI checks out LF —
64
- // hashing raw bytes would diverge across platforms.
108
+ // hashing raw bytes would diverge across platforms. (PRESERVED from v1.15)
65
109
  const normalized = Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
66
110
  const actual = crypto.createHash('sha256').update(normalized).digest('hex');
67
111
  if (actual !== expected) {
68
- mismatches.push({ path: rel, expected: expected.slice(0, 16), actual: actual.slice(0, 16) });
112
+ return { rel, status: 'mismatch', expected, actual };
113
+ }
114
+ return { rel, status: 'ok' };
115
+ };
116
+
117
+ // Sequential batches — within a batch, Promise.all parallelizes hashing;
118
+ // between batches, await bounds max-in-flight at BATCH_SIZE (defensive
119
+ // against fd ulimit on large kits). Order of completion within a batch
120
+ // doesn't matter — aggregator below is order-independent.
121
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
122
+ const slice = entries.slice(i, i + BATCH_SIZE);
123
+ const results = await Promise.all(slice.map(checkOne));
124
+ for (const r of results) {
125
+ if (r.status === 'mismatch') {
126
+ mismatches.push({ path: r.rel, expected: r.expected.slice(0, 16), actual: r.actual.slice(0, 16) });
127
+ } else if (r.status === 'missing') {
128
+ missing.push(r.rel);
129
+ }
69
130
  }
70
131
  }
71
132
 
72
133
  if (mismatches.length === 0 && missing.length === 0) {
73
- return { ok: true };
134
+ const result = { ok: true };
135
+ // PERF-17-01: cache only ok=true. Mismatch/missing always recompute
136
+ // so dev fixing a tampered file sees the next sync recover immediately.
137
+ if (process.env[NO_CACHE_ENV] !== '1') {
138
+ verifyManifestCache.set(kitRoot, { value: result, ts: Date.now() });
139
+ }
140
+ return result;
74
141
  }
75
142
 
76
143
  // Build a concise reason — first 3 mismatches, plus counts.
@@ -32,6 +32,36 @@ import fs from 'node:fs/promises';
32
32
  // six regexes; one suffices.
33
33
  const SENTINEL = 'MCP sync requires projectRoot to be a git workspace';
34
34
 
35
+ /**
36
+ * SEC-14-03: validate that a `projectRoot` supplied via MCP message points to
37
+ * a real git workspace before any handler dispatches into sync.js / reverse-sync.js.
38
+ *
39
+ * Pure function — never throws. Returns a discriminated union so MCP handlers
40
+ * can wrap rejections in `{ error }` envelopes without try/catch boilerplate
41
+ * (matches handleSync/handleGates/handleForensics in src/mcp-server/index.js).
42
+ *
43
+ * Validation chain (each step short-circuits on rejection):
44
+ * 1. `projectRoot` is non-empty string (rejects nullish, empty, non-string types).
45
+ * 2. `path.resolve()` collapses `..` segments and produces an absolute path.
46
+ * 3. The resolved path exists and is a directory (UNC failures bubble up here).
47
+ * 4. A `.git` entry exists somewhere in the ancestor chain (file or directory —
48
+ * `git worktree` uses a file). Walk-up bounded by `path.dirname()` fixed point.
49
+ *
50
+ * The CLI does NOT call this — `bin/cli.js` trusts whoever invoked it (same
51
+ * trust model as Phase 79.01's gates.run guard). Only MCP-message-sourced paths
52
+ * need this check.
53
+ *
54
+ * Rejection reasons all embed the literal `"git workspace"` string — public
55
+ * contract relied on by test/unit/mcp-projectroot-guard.test.js and downstream
56
+ * MCP clients. Don't rephrase without coordinating callers.
57
+ *
58
+ * @param {unknown} projectRoot - the candidate path supplied by an MCP client.
59
+ * Expected to be an absolute filesystem path; any other shape is rejected.
60
+ * @returns {Promise<{ok: true, resolvedPath: string} | {ok: false, reason: string}>}
61
+ * On success, `resolvedPath` is the path-resolved absolute form of `projectRoot`
62
+ * (callers should use it instead of the raw input). On failure, `reason` is a
63
+ * human-readable string suitable for MCP `{error}` envelopes.
64
+ */
35
65
  export async function validateProjectRoot(projectRoot) {
36
66
  // Reject empty / nullish up-front. We require an explicit projectRoot from
37
67
  // MCP messages — falling back to `process.cwd()` of the MCP server would let
@@ -31,17 +31,34 @@ export async function detectReverse(targetId, opts = {}) {
31
31
 
32
32
  const candidates = [];
33
33
 
34
- // For each capability that this target supports AND that has files on disk,
35
- // walk and classify.
36
- if (target.agents) await scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot);
37
- if (target.commands) await scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot);
38
- if (target.skills) await scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot);
34
+ // PERF-16-03: parallelize the 5 scans. Each scan reads a distinct subdirectory
35
+ // of the IDE layout (.claude/agents, .claude/commands, .claude/skills,
36
+ // .claude/framework, .claude/hooks) — there is no I/O contention between them.
37
+ //
38
+ // Each scan continues to push into the shared `candidates` array. This is safe
39
+ // under the single-threaded JS event loop: `Array.prototype.push` is a
40
+ // synchronous operation that completes between awaits, so concurrent scans
41
+ // never produce a torn write. The trade-off is that candidate ordering is no
42
+ // longer deterministic across categories — existing reverse-sync tests use
43
+ // `.find` / `.some` / `.filter` and never index `candidates[N]`, so this is a
44
+ // safe widening of the contract.
45
+ //
46
+ // Error semantics: `Promise.all` rejects on the first rejection — identical to
47
+ // the previous sequential `await` chain (which also propagated the first error
48
+ // and aborted the rest). Fail-fast is preserved.
49
+ const pending = [];
50
+
51
+ if (target.agents) pending.push(scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot));
52
+ if (target.commands) pending.push(scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot));
53
+ if (target.skills) pending.push(scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot));
39
54
  for (const cap of ['framework', 'hooks']) {
40
55
  const spec = target[cap];
41
56
  if (!spec || spec.mode !== 'mirror-tree') continue;
42
- await scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot);
57
+ pending.push(scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot));
43
58
  }
44
59
 
60
+ await Promise.all(pending);
61
+
45
62
  return { target: targetId, projectRoot, kitRoot, candidates };
46
63
  }
47
64
 
package/src/core/sync.js CHANGED
@@ -19,6 +19,52 @@ const STUB_MARKER = '<!-- kit-mcp:reference -->';
19
19
  const MANAGED_MARKER_FILE = '.kit-mcp-managed';
20
20
  const MANAGED_MARKER_BODY = '# Managed by @luanpdd/kit-mcp — this directory is overwritten on every `kit sync install`.\n# Do not edit files here directly; edit the canonical source under kit/ and re-run sync.\n# Removing this file disables `kit sync remove` cleanup of this tree.\n';
21
21
 
22
+ // PERF-16-01: parallelize file writes in syncTo() via Promise.all batches.
23
+ // BATCH_SIZE=16 default — safe under Linux ulimit 1024 fd default and
24
+ // macOS/Windows equivalents. Configurable via env (e.g. on slow disks).
25
+ // Values outside [1, 256] fall back to 16 (defensive — env vars are strings).
26
+ function resolveBatchSize() {
27
+ const raw = process.env.KIT_MCP_SYNC_BATCH_SIZE;
28
+ if (!raw) return 16;
29
+ const n = Number.parseInt(raw, 10);
30
+ if (!Number.isFinite(n) || n < 1 || n > 256) return 16;
31
+ return n;
32
+ }
33
+
34
+ // PERF-17-02: opt-out of stat-based diff skip. Forces full sync (every op writes)
35
+ // for cleanup/recovery scenarios where target files may be subtly out of sync
36
+ // (manual edits, partial fs corruption) but pass the mtime+size diff heuristic.
37
+ function resolveForceFullSync() {
38
+ return process.env.KIT_MCP_FORCE_FULL_SYNC === '1';
39
+ }
40
+
41
+ /**
42
+ * Project the canonical kit/ into an IDE-specific layout (claude-code, cursor, etc.).
43
+ *
44
+ * Workflow:
45
+ * 1. SEC-14-05: verifyManifest(kitRoot) — refuses tampered kits (Phase 83+90).
46
+ * 2. Build ops[] (rules + agents + commands + skills + framework/hooks treeCopy).
47
+ * 3. PERF-17-02: stat-based diff filter — skip treeCopy ops whose target already
48
+ * matches source (mtime+size). Bypassed via KIT_MCP_FORCE_FULL_SYNC=1.
49
+ * 4. PERF-16-01: Promise.all batches=16 over writeOps (Phase 88.01).
50
+ *
51
+ * onProgress callback receives one event per op (written or skipped); skipped ops
52
+ * carry `skipped: true` for UI granularity.
53
+ *
54
+ * Stable API v1.0+ preserved: return shape unchanged. `written[]` lists all op
55
+ * paths (projected files), not just actually-written — semantics: "what's in the
56
+ * target tree after this call", not "what fs.writeFile ran".
57
+ *
58
+ * @param {string} targetId - registry target id (e.g. 'claude-code', 'cursor').
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.projectRoot=process.cwd()] - destination project root.
61
+ * @param {string} [opts.kitRoot] - canonical kit/ root (auto-resolved if absent).
62
+ * @param {'reference'|'copy'|'symlink'} [opts.mode='reference'] - projection mode.
63
+ * @param {boolean} [opts.dryRun=false] - skip all fs writes; ops still listed.
64
+ * @param {Function} [opts.onProgress] - per-op callback ({phase, current, total, label, skipped?}).
65
+ * @param {object} [opts.kit] - pre-loaded kit (skips listKit re-walk).
66
+ * @returns {Promise<{target, mode, projectRoot, kitRoot, written, dryRun}>}
67
+ */
22
68
  export async function syncTo(targetId, opts = {}) {
23
69
  const target = getTarget(targetId);
24
70
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
@@ -100,16 +146,83 @@ export async function syncTo(targetId, opts = {}) {
100
146
  }
101
147
 
102
148
  if (!dryRun) {
103
- let i = 0;
104
- for (const op of ops) {
149
+ const BATCH_SIZE = resolveBatchSize();
150
+ let completed = 0;
151
+ const total = ops.length;
152
+
153
+ // PERF-17-02: stat-based diff filter — skip ops whose target already matches source.
154
+ // Only applies to treeCopy ops (framework/hooks subtrees) — content ops (agents,
155
+ // commands, skills, rules) include `Generated by kit-mcp at ${ISO timestamp}` so
156
+ // they re-render every time and can't safely diff. treeCopy ops dominate wall
157
+ // time on large kits (327+ files), so this captures the PERF-17-02 win.
158
+ //
159
+ // Filter logic per op:
160
+ // - forceFullSync env set → never skip
161
+ // - !treeCopy (content op) → never skip
162
+ // - target stat fails (absent)→ never skip (must write)
163
+ // - src stat fails (defensive)→ never skip (let copy fail naturally)
164
+ // - target.size === src.size AND target.mtimeMs >= src.mtimeMs → SKIP
165
+ //
166
+ // Implementation: Promise.all over ops produces { op, skip } pairs. Skipped ops
167
+ // emit onProgress({ skipped: true }) and increment the same `completed` counter
168
+ // as written ops (so progress UI shows full ops.length total).
169
+ const forceFullSync = resolveForceFullSync();
170
+
171
+ const diffOne = async (op) => {
172
+ if (forceFullSync) return { op, skip: false };
173
+ if (!op.treeCopy) return { op, skip: false };
174
+ let targetStat;
175
+ try { targetStat = await fs.stat(op.path); }
176
+ catch { return { op, skip: false }; }
177
+ let srcStat;
178
+ try { srcStat = await fs.stat(op.srcAbs); }
179
+ catch { return { op, skip: false }; }
180
+ if (targetStat.size === srcStat.size && targetStat.mtimeMs >= srcStat.mtimeMs) {
181
+ return { op, skip: true };
182
+ }
183
+ return { op, skip: false };
184
+ };
185
+
186
+ // Stats are cheap — no batch limit needed (Promise.all over all ops is fine).
187
+ const diffResults = await Promise.all(ops.map(diffOne));
188
+ const writeOps = [];
189
+ for (const { op, skip } of diffResults) {
190
+ if (skip) {
191
+ completed += 1;
192
+ onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path), skipped: true });
193
+ } else {
194
+ writeOps.push(op);
195
+ }
196
+ }
197
+
198
+ // Apply one op (mkdir + write or copy + onProgress).
199
+ // Each op is independent: ops[] is built so writes don't share parent
200
+ // directories that need ordering — mkdir({recursive:true}) is idempotent
201
+ // even when 16 ops race for the same parent dir.
202
+ const applyOp = async (op) => {
105
203
  await fs.mkdir(path.dirname(op.path), { recursive: true });
106
204
  if (op.treeCopy) {
107
205
  await fs.copyFile(op.srcAbs, op.path);
108
206
  } else {
109
207
  await fs.writeFile(op.path, op.content, 'utf8');
110
208
  }
111
- i++;
112
- onProgress({ phase: op.kind, current: i, total: ops.length, label: path.basename(op.path) });
209
+ // Counter increment is single-threaded by JS event loop semantics —
210
+ // no torn reads even with 16 ops resolving in any order.
211
+ // (PERF-17-02: diff filter increments the same counter for skipped ops before
212
+ // this batch loop runs, so `current` in onProgress reflects total progress.)
213
+ completed += 1;
214
+ onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path) });
215
+ };
216
+
217
+ // PERF-16-01 batched writes — now operating on writeOps (post-diff filter).
218
+ // Sequential batches — within a batch, Promise.all parallelizes writes;
219
+ // between batches, we await to bound max-in-flight at BATCH_SIZE. If any
220
+ // op in a batch rejects, Promise.all rejects on first failure (matches
221
+ // existing behavior — sync.js had no retry logic, so a single fs error
222
+ // already aborted the install).
223
+ for (let i = 0; i < writeOps.length; i += BATCH_SIZE) {
224
+ const slice = writeOps.slice(i, i + BATCH_SIZE);
225
+ await Promise.all(slice.map(applyOp));
113
226
  }
114
227
  }
115
228
 
package/src/core/ui.js CHANGED
@@ -10,7 +10,23 @@
10
10
  // - Zero hidden globals — every primitive is a plain function/class.
11
11
 
12
12
  import pc from 'picocolors';
13
- import { select as inqSelect, confirm as inqConfirm } from '@inquirer/prompts';
13
+
14
+ // PERF-16-05: @inquirer/prompts é optionalDependency. Carregamos lazy dentro
15
+ // de select()/confirm() para que (a) modo MCP server e CI não paguem o custo
16
+ // de boot, e (b) `npm install --omit=optional` produza CLI core funcional
17
+ // (apenas comandos interativos falham com mensagem descritiva).
18
+ let _inquirerModule = null;
19
+ async function loadInquirer() {
20
+ if (_inquirerModule) return _inquirerModule;
21
+ try {
22
+ _inquirerModule = await import('@inquirer/prompts');
23
+ return _inquirerModule;
24
+ } catch (err) {
25
+ throw new Error(
26
+ 'Interactive prompts require @inquirer/prompts. Install with `npm i @inquirer/prompts` or pass --yes / --no-interactive to skip the prompt.'
27
+ );
28
+ }
29
+ }
14
30
 
15
31
  // --- color helpers ---
16
32
 
@@ -127,6 +143,7 @@ export async function select(opts) {
127
143
  if (!process.stdin.isTTY) {
128
144
  throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass the value as a flag instead.');
129
145
  }
146
+ const { select: inqSelect } = await loadInquirer();
130
147
  return inqSelect(opts);
131
148
  }
132
149
 
@@ -134,6 +151,7 @@ export async function confirm(opts) {
134
151
  if (!process.stdin.isTTY) {
135
152
  throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass --yes to skip confirmation.');
136
153
  }
154
+ const { confirm: inqConfirm } = await loadInquirer();
137
155
  return inqConfirm(opts);
138
156
  }
139
157
 
package/src/core/watch.js CHANGED
@@ -11,16 +11,35 @@
11
11
 
12
12
  import path from 'node:path';
13
13
  import fs from 'node:fs/promises';
14
- import chokidar from 'chokidar';
15
14
  import { syncTo } from './sync.js';
16
15
  import { listTargets } from './registry.js';
17
- import { resolveKitRoot } from './kit.js';
16
+ import { resolveKitRoot, clearKitCache } from './kit.js';
17
+
18
+ // PERF-16-06: chokidar é optionalDependency. Carregamos lazy dentro de watchKit()
19
+ // para que (a) `kit sync install` (que NÃO usa watch) não pague o custo de boot,
20
+ // e (b) `npm install --omit=optional` produza CLI core funcional (apenas
21
+ // `kit sync watch` falha com mensagem descritiva).
22
+ let _chokidarModule = null;
23
+ async function loadChokidar() {
24
+ if (_chokidarModule) return _chokidarModule;
25
+ try {
26
+ const mod = await import('chokidar');
27
+ _chokidarModule = mod.default || mod;
28
+ return _chokidarModule;
29
+ } catch (err) {
30
+ throw new Error(
31
+ 'kit sync watch requires chokidar. Install with `npm i chokidar` or use `kit sync install <target>` for one-shot syncing instead.'
32
+ );
33
+ }
34
+ }
18
35
 
19
36
  export async function watchKit(targets, opts = {}) {
20
37
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
21
38
  const kitRoot = resolveKitRoot(opts.kitRoot);
22
39
  const mode = opts.mode ?? 'reference';
23
- const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 300;
40
+ // PERF-16-02: bump default 300 500ms to coalesce IDE save-bursts (typical
41
+ // IDE auto-save fires 5-10 events in < 500ms during a single user save).
42
+ const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 500;
24
43
  const onLog = opts.onLog ?? (() => {});
25
44
 
26
45
  if (!Array.isArray(targets) || targets.length === 0) {
@@ -33,6 +52,7 @@ export async function watchKit(targets, opts = {}) {
33
52
  onLog(`✓ initial sync → ${t} (${r.written.length} files)`);
34
53
  }
35
54
 
55
+ const chokidar = await loadChokidar();
36
56
  const watcher = chokidar.watch(kitRoot, {
37
57
  ignored: (p) => /(^|[/\\])\.[^/\\]/.test(p), // ignore dotfiles/dotdirs
38
58
  persistent: true,
@@ -46,6 +66,12 @@ export async function watchKit(targets, opts = {}) {
46
66
  if (pending) clearTimeout(pending);
47
67
  pending = setTimeout(async () => {
48
68
  pending = null;
69
+ // PERF-16-02: invalidate kitCache (TTL 30s in kit.js PERF-01) BEFORE
70
+ // re-sync — otherwise listKit() inside syncTo can return the pre-edit
71
+ // cached value if the burst happened within the TTL window. Coalescing
72
+ // the edit-burst via debounce means clearKitCache fires AT MOST ONCE
73
+ // per 500ms window, regardless of how many save events came in.
74
+ clearKitCache();
49
75
  for (const t of targets) {
50
76
  try {
51
77
  const r = await syncTo(t, { projectRoot, kitRoot, mode });