claude-setup 1.1.3 → 1.1.4

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/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { execSync } from "child_process";
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { execSync, spawnSync } from "child_process";
3
3
  import { join } from "path";
4
- import { readManifest } from "./manifest.js";
4
+ import { readManifest, sha256 } from "./manifest.js";
5
5
  import { readState } from "./state.js";
6
6
  import { detectOS } from "./os.js";
7
7
  import { c, statusLine, section } from "./output.js";
@@ -31,12 +31,17 @@ function safeJsonParse(content) {
31
31
  return null;
32
32
  }
33
33
  }
34
- export async function runDoctor(verbose = false) {
34
+ export async function runDoctor(verbose = false, fix = false, testHooks = false) {
35
35
  const os = detectOS();
36
36
  const manifest = await readManifest();
37
37
  const state = await readState();
38
- const counts = { critical: 0, warnings: 0, healthy: 0 };
39
- console.log(`${c.bold("claude-setup doctor")} — ${new Date().toISOString().split("T")[0]} | OS: ${os}\n`);
38
+ const counts = { critical: 0, warnings: 0, healthy: 0, fixed: 0 };
39
+ console.log(`${c.bold("claude-setup doctor")} — ${new Date().toISOString().split("T")[0]} | OS: ${os}`);
40
+ if (fix)
41
+ console.log(`${c.cyan("Auto-fix mode enabled")}`);
42
+ if (testHooks)
43
+ console.log(`${c.cyan("Hook testing enabled")}`);
44
+ console.log("");
40
45
  // --- Check 1: Claude Code version ---
41
46
  section("Environment");
42
47
  const cv = tryExec("claude --version");
@@ -63,8 +68,12 @@ export async function runDoctor(verbose = false) {
63
68
  counts.warnings++;
64
69
  }
65
70
  // --- Check 2b: Out-of-band edit detection ---
71
+ // BUG 12 FIX: Distinguish expected modifications (from Claude Code sessions triggered
72
+ // by /stack-init, /stack-add etc.) from truly unexpected external modifications.
66
73
  if (lastRun) {
67
- const { createHash } = await import("crypto");
74
+ const lastCommand = lastRun.command;
75
+ const expectedModCommands = new Set(["init", "add", "sync"]);
76
+ const isExpectedMod = expectedModCommands.has(lastCommand);
68
77
  const oobFiles = [
69
78
  { label: "CLAUDE.md", path: join(process.cwd(), "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
70
79
  { label: ".mcp.json", path: join(process.cwd(), ".mcp.json"), snapshotKey: ".mcp.json" },
@@ -74,11 +83,33 @@ export async function runDoctor(verbose = false) {
74
83
  if (!existsSync(mf.path))
75
84
  continue;
76
85
  const content = readFileSync(mf.path, "utf8");
77
- const currentHash = createHash("sha256").update(content).digest("hex");
86
+ const currentHash = sha256(content);
78
87
  const snapshotHash = lastRun.snapshot[mf.snapshotKey];
79
88
  if (snapshotHash && currentHash !== snapshotHash) {
80
- statusLine("⚠️ ", mf.label, c.yellow("modified outside the CLI since last run — run sync to re-snapshot"));
81
- counts.warnings++;
89
+ if (isExpectedMod) {
90
+ if (verbose) {
91
+ statusLine("✅", mf.label, `modified after ${lastCommand} (expected — Claude Code session)`);
92
+ counts.healthy++;
93
+ }
94
+ }
95
+ else {
96
+ statusLine("⚠️ ", mf.label, c.yellow("modified outside the CLI since last run — run sync to re-snapshot"));
97
+ counts.warnings++;
98
+ if (fix) {
99
+ // Auto-fix: re-snapshot the file
100
+ lastRun.snapshot[mf.snapshotKey] = currentHash;
101
+ statusLine("🔧", mf.label, c.green("re-snapshotted"));
102
+ counts.fixed++;
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // Write back updated manifest if fix modified snapshots
108
+ if (fix && manifest) {
109
+ const { writeFileSync: wfs } = await import("fs");
110
+ const manifestPath = join(process.cwd(), ".claude/claude-setup.json");
111
+ if (existsSync(manifestPath)) {
112
+ wfs(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
82
113
  }
83
114
  }
84
115
  }
@@ -88,6 +119,7 @@ export async function runDoctor(verbose = false) {
88
119
  const mcp = safeJsonParse(state.mcpJson.content);
89
120
  if (mcp && typeof mcp.mcpServers === "object" && mcp.mcpServers !== null) {
90
121
  const servers = mcp.mcpServers;
122
+ let mcpModified = false;
91
123
  for (const [name, config] of Object.entries(servers)) {
92
124
  const cmd = config.command;
93
125
  if (!cmd) {
@@ -98,10 +130,30 @@ export async function runDoctor(verbose = false) {
98
130
  if (os === "Windows" && cmd === "npx") {
99
131
  statusLine("🔴", name, c.red(`BROKEN: Windows can't execute npx directly — use cmd /c npx`));
100
132
  counts.critical++;
133
+ if (fix) {
134
+ // Auto-fix: convert to Windows format
135
+ const args = config.args ?? [];
136
+ config.command = "cmd";
137
+ config.args = ["/c", "npx", ...args];
138
+ statusLine("🔧", name, c.green("fixed → cmd /c npx"));
139
+ counts.fixed++;
140
+ mcpModified = true;
141
+ }
101
142
  }
102
143
  else if (os !== "Windows" && cmd === "cmd") {
103
144
  statusLine("⚠️ ", name, c.yellow(`UNNECESSARY: cmd wrapper not needed on ${os}`));
104
145
  counts.warnings++;
146
+ if (fix) {
147
+ // Auto-fix: remove cmd wrapper
148
+ const args = config.args ?? [];
149
+ if (args[0] === "/c") {
150
+ config.command = args[1] ?? "npx";
151
+ config.args = args.slice(2);
152
+ statusLine("🔧", name, c.green(`fixed → ${config.command}`));
153
+ counts.fixed++;
154
+ mcpModified = true;
155
+ }
156
+ }
105
157
  }
106
158
  else {
107
159
  statusLine("✅", name, `valid, OS-format correct (${cmd})`);
@@ -114,6 +166,12 @@ export async function runDoctor(verbose = false) {
114
166
  if (npxIndex >= 0 && args[npxIndex + 1] !== "-y") {
115
167
  statusLine("⚠️ ", name, c.yellow(`npx without -y flag — installs may hang`));
116
168
  counts.warnings++;
169
+ if (fix) {
170
+ args.splice(npxIndex + 1, 0, "-y");
171
+ statusLine("🔧", name, c.green("added -y flag"));
172
+ counts.fixed++;
173
+ mcpModified = true;
174
+ }
117
175
  }
118
176
  // Check for hardcoded connection strings in args
119
177
  for (const arg of args) {
@@ -134,9 +192,14 @@ export async function runDoctor(verbose = false) {
134
192
  counts.warnings++;
135
193
  }
136
194
  }
195
+ // Write back fixed .mcp.json
196
+ if (fix && mcpModified) {
197
+ const mcpPath = join(process.cwd(), ".mcp.json");
198
+ writeFileSync(mcpPath, JSON.stringify(mcp, null, 2), "utf8");
199
+ statusLine("🔧", ".mcp.json", c.green("saved with fixes"));
200
+ }
137
201
  }
138
202
  else if (mcp) {
139
- // Check for flat structure (some .mcp.json files use flat keys)
140
203
  if (verbose)
141
204
  statusLine("⚠️ ", ".mcp.json", "no mcpServers key found");
142
205
  }
@@ -145,66 +208,172 @@ export async function runDoctor(verbose = false) {
145
208
  section("MCP servers");
146
209
  statusLine("⏭ ", ".mcp.json", "does not exist");
147
210
  }
148
- // --- Check 4: Hook quoting bugs ---
211
+ // --- Check 4: Hook format and quoting ---
149
212
  if (state.settings.content) {
150
213
  section("Hooks");
151
214
  const settings = safeJsonParse(state.settings.content);
152
215
  if (settings) {
153
- const hookCategories = [
216
+ const ALL_HOOK_EVENTS = [
154
217
  "PreToolUse", "PostToolUse", "PostToolUseFailure",
155
- "Stop", "SessionStart"
218
+ "Stop", "SessionStart", "Notification", "SubagentStart", "SubagentStop",
219
+ "UserPromptSubmit", "PermissionRequest", "ConfigChange",
220
+ "InstructionsLoaded", "TaskCompleted", "TeammateIdle",
221
+ "StopFailure", "SessionEnd", "PreCompact", "PostCompact",
222
+ "WorktreeCreate", "WorktreeRemove", "Elicitation", "ElicitationResult",
156
223
  ];
157
- // Bug 6: Check for model override
224
+ const validHookNames = new Set(ALL_HOOK_EVENTS);
225
+ const KNOWN_SETTINGS_KEYS = new Set([
226
+ "permissions", "model", "env", "allowedTools", "hooks",
227
+ "disableAllHooks", "statusLine", "outputStyle", "attribution",
228
+ "includeCoAuthoredBy", "includeGitInstructions", "enableAllProjectMcpServers",
229
+ "enabledMcpjsonServers", "disabledMcpjsonServers", "apiKeyHelper",
230
+ "companyAnnouncements", "effortLevel", "language", "$schema",
231
+ ]);
232
+ let settingsModified = false;
233
+ // BUG 13 FIX: Model override with auto-fix
158
234
  if (settings["model"]) {
159
- statusLine("⚠️ ", "MODEL OVERRIDE", c.yellow(`"model": "${settings["model"]}" in settings.json forces this model on every session. Remove it if not intentional.`));
235
+ statusLine("⚠️ ", "MODEL OVERRIDE", c.yellow(`"model": "${settings["model"]}" forces this model on every session.\n` +
236
+ ` Fix: remove the "model" key from .claude/settings.json, or use /model in Claude Code to switch per-session.`));
160
237
  counts.warnings++;
238
+ if (fix) {
239
+ delete settings["model"];
240
+ statusLine("🔧", "MODEL OVERRIDE", c.green("removed — model selection is now per-session"));
241
+ counts.fixed++;
242
+ settingsModified = true;
243
+ }
161
244
  }
162
- // Check for invalid hook event names
163
- const validHookNames = new Set(hookCategories);
164
- for (const key of Object.keys(settings)) {
165
- if (key === "permissions" || key === "model" || key === "env" || key === "allowedTools")
166
- continue;
167
- if (Array.isArray(settings[key]) && !validHookNames.has(key)) {
168
- statusLine("🔴", `"${key}"`, c.red(`INVALID hook event name. Valid names: ${hookCategories.join(", ")}`));
169
- counts.critical++;
245
+ // Determine where hooks live correct format uses "hooks" key
246
+ const hooksObj = settings["hooks"] ?? null;
247
+ const hookSource = hooksObj ?? settings;
248
+ // Check for invalid hook event names (only in flat format)
249
+ if (!hooksObj) {
250
+ for (const key of Object.keys(settings)) {
251
+ if (KNOWN_SETTINGS_KEYS.has(key))
252
+ continue;
253
+ if (Array.isArray(settings[key]) && !validHookNames.has(key)) {
254
+ statusLine("🔴", `"${key}"`, c.red(`INVALID hook event name. Valid: ${ALL_HOOK_EVENTS.slice(0, 5).join(", ")}... ` +
255
+ `See Claude Code hooks documentation.`));
256
+ counts.critical++;
257
+ }
170
258
  }
171
259
  }
260
+ // Collect hooks for potential testing
261
+ const hookCommands = [];
172
262
  let foundHooks = false;
173
- for (const category of hookCategories) {
174
- const hooks = settings[category];
175
- if (!hooks || !Array.isArray(hooks))
263
+ for (const category of ALL_HOOK_EVENTS) {
264
+ const entries = hookSource[category];
265
+ if (!entries || !Array.isArray(entries))
176
266
  continue;
177
- for (const hook of hooks) {
178
- if (typeof hook !== "object" || !hook)
179
- continue;
180
- const h = hook;
181
- const hookCmd = h.command;
182
- const args = h.args;
183
- if (!hookCmd || !args)
267
+ for (const entry of entries) {
268
+ if (typeof entry !== "object" || !entry)
184
269
  continue;
185
- foundHooks = true;
186
- // Check for bash -c quoting bugs
187
- if (hookCmd === "bash" && args[0] === "-c" && args[1]) {
188
- const shellStr = args[1];
189
- const quotingIssue = checkBashQuoting(shellStr);
190
- if (quotingIssue) {
191
- statusLine("🔴", `${category} hook`, c.red(`quoting bug: ${quotingIssue}`));
192
- counts.critical++;
270
+ const e = entry;
271
+ if (Array.isArray(e.hooks)) {
272
+ const matcher = e.matcher ?? "";
273
+ // Validate matcher is valid regex
274
+ if (matcher) {
275
+ try {
276
+ new RegExp(matcher);
277
+ }
278
+ catch {
279
+ statusLine("🔴", `${category} matcher`, c.red(`invalid regex: "${matcher}"`));
280
+ counts.critical++;
281
+ }
193
282
  }
194
- else {
195
- statusLine("", `${category} hook`, "quoting clean");
196
- counts.healthy++;
283
+ for (const hook of e.hooks) {
284
+ if (typeof hook !== "object" || !hook)
285
+ continue;
286
+ const h = hook;
287
+ foundHooks = true;
288
+ if (h.type === "command" && typeof h.command === "string") {
289
+ const cmd = h.command;
290
+ const bashQuoting = checkBashQuoting(cmd);
291
+ if (bashQuoting) {
292
+ statusLine("🔴", `${category} hook`, c.red(`quoting bug: ${bashQuoting}`));
293
+ counts.critical++;
294
+ }
295
+ else {
296
+ statusLine("✅", `${category} hook`, `command: ${cmd.slice(0, 60)}${cmd.length > 60 ? "..." : ""}`);
297
+ counts.healthy++;
298
+ }
299
+ hookCommands.push({ event: category, matcher, command: cmd });
300
+ }
301
+ else {
302
+ statusLine("✅", `${category} hook`, `type: ${h.type ?? "unknown"}`);
303
+ counts.healthy++;
304
+ }
197
305
  }
198
306
  }
199
307
  else {
200
- statusLine("✅", `${category} hook`, "valid");
201
- counts.healthy++;
308
+ // Legacy flat format
309
+ const hookCmd = e.command;
310
+ const args = e.args;
311
+ if (!hookCmd)
312
+ continue;
313
+ foundHooks = true;
314
+ if (hookCmd === "bash" && args?.[0] === "-c" && args[1]) {
315
+ const quotingIssue = checkBashQuoting(args[1]);
316
+ if (quotingIssue) {
317
+ statusLine("🔴", `${category} hook`, c.red(`quoting bug: ${quotingIssue}`));
318
+ counts.critical++;
319
+ }
320
+ else {
321
+ statusLine("✅", `${category} hook`, "quoting clean");
322
+ counts.healthy++;
323
+ }
324
+ hookCommands.push({ event: category, matcher: "", command: args[1] });
325
+ }
326
+ else {
327
+ statusLine("✅", `${category} hook`, "valid");
328
+ counts.healthy++;
329
+ hookCommands.push({ event: category, matcher: "", command: `${hookCmd} ${(args ?? []).join(" ")}` });
330
+ }
202
331
  }
203
332
  }
204
333
  }
205
334
  if (!foundHooks && verbose) {
206
335
  statusLine("⏭ ", "Hooks", "none configured");
207
336
  }
337
+ // Write back fixed settings.json
338
+ if (fix && settingsModified) {
339
+ const settingsPath = join(process.cwd(), ".claude", "settings.json");
340
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
341
+ statusLine("🔧", "settings.json", c.green("saved with fixes"));
342
+ }
343
+ // --- Feature G: Hook testing (--test-hooks) ---
344
+ if (testHooks && hookCommands.length > 0) {
345
+ section("Hook testing");
346
+ console.log(`${c.dim("Running each hook once in sandbox mode...\n")}`);
347
+ for (const hook of hookCommands) {
348
+ const result = testSingleHook(hook.command, os);
349
+ const label = `${hook.event}${hook.matcher ? `:${hook.matcher}` : ""}`;
350
+ if (result.status === "pass") {
351
+ statusLine("✅", label, c.green(`PASS (${result.timeMs}ms) — ${hook.command.slice(0, 50)}`));
352
+ counts.healthy++;
353
+ }
354
+ else if (result.status === "not_found") {
355
+ statusLine("🔴", label, c.red(`FAIL — command not found: ${result.tool}\n` +
356
+ ` Install ${result.tool} or remove this hook.`));
357
+ counts.critical++;
358
+ }
359
+ else if (result.status === "timeout") {
360
+ statusLine("⚠️ ", label, c.yellow(`TIMEOUT (>${result.timeMs}ms) — hook may hang in real sessions\n` +
361
+ ` Command: ${hook.command.slice(0, 50)}`));
362
+ counts.warnings++;
363
+ }
364
+ else if (result.status === "error") {
365
+ statusLine("⚠️ ", label, c.yellow(`FAIL (exit ${result.exitCode}, ${result.timeMs}ms)\n` +
366
+ ` Command: ${hook.command.slice(0, 50)}\n` +
367
+ ` ${result.stderr ? `stderr: ${result.stderr.slice(0, 100)}` : ""}`));
368
+ counts.warnings++;
369
+ }
370
+ else if (result.status === "permission") {
371
+ statusLine("🔴", label, c.red(`PERMISSION DENIED — ${hook.command.slice(0, 50)}\n` +
372
+ ` Check file permissions or access rights.`));
373
+ counts.critical++;
374
+ }
375
+ }
376
+ }
208
377
  }
209
378
  }
210
379
  else if (verbose) {
@@ -266,7 +435,6 @@ export async function runDoctor(verbose = false) {
266
435
  counts.warnings++;
267
436
  continue;
268
437
  }
269
- // Extract file/dir references from skill content
270
438
  const pathRefs = extractPathReferences(content);
271
439
  const staleRefs = pathRefs.filter(ref => !existsSync(ref) && !existsSync(join(process.cwd(), ref)));
272
440
  if (staleRefs.length) {
@@ -284,7 +452,6 @@ export async function runDoctor(verbose = false) {
284
452
  statusLine("⏭ ", "Skills", "none installed");
285
453
  }
286
454
  // --- Check 8: Files from last run still on disk ---
287
- // Filter __digest__ — it's a virtual key, not a real file
288
455
  if (lastRun?.filesRead.length && verbose) {
289
456
  section("Files from last run");
290
457
  const realFiles = lastRun.filesRead.filter(f => f !== "__digest__" && f !== ".env");
@@ -310,8 +477,13 @@ export async function runDoctor(verbose = false) {
310
477
  if (counts.warnings > 0)
311
478
  console.log(` ⚠️ ${c.yellow(`${counts.warnings} warning(s)`)} (degraded behavior)`);
312
479
  console.log(` ✅ ${c.green(`${counts.healthy} healthy`)}`);
313
- if (counts.critical > 0) {
314
- console.log(`\n${c.red("Fix critical issues first.")} Run ${c.cyan("npx claude-setup sync")} after fixing.`);
480
+ if (counts.fixed > 0)
481
+ console.log(` 🔧 ${c.cyan(`${counts.fixed} auto-fixed`)}`);
482
+ if (counts.critical > 0 && !fix) {
483
+ console.log(`\n${c.red("Fix critical issues first.")} Run ${c.cyan("npx claude-setup doctor --fix")} to auto-fix what's possible.`);
484
+ }
485
+ else if (counts.fixed > 0) {
486
+ console.log(`\n${c.green("✅ Auto-fix applied.")} Run ${c.cyan("npx claude-setup sync")} to re-snapshot.`);
315
487
  }
316
488
  else {
317
489
  console.log(`\n${c.green("✅ Done.")}`);
@@ -330,8 +502,6 @@ function checkStaleness(lastRunDate) {
330
502
  return null;
331
503
  }
332
504
  function checkBashQuoting(shellStr) {
333
- // Check for unescaped double quotes inside the command string
334
- // The -c "..." outer string must never contain a bare " character
335
505
  let inSingleQuote = false;
336
506
  let prevChar = "";
337
507
  for (let i = 0; i < shellStr.length; i++) {
@@ -339,17 +509,13 @@ function checkBashQuoting(shellStr) {
339
509
  if (ch === "'" && prevChar !== "\\") {
340
510
  inSingleQuote = !inSingleQuote;
341
511
  }
342
- // Unescaped double quote inside single-quoted context is a problem
343
- // when the outer wrapper is double quotes
344
512
  if (ch === '"' && !inSingleQuote && prevChar !== "\\") {
345
- // Check for patterns like ["'] which mix quote types
346
513
  if (i > 0 && shellStr[i - 1] === "[") {
347
514
  return `mixed quotes in character class at position ${i}: ...${shellStr.slice(Math.max(0, i - 10), i + 10)}...`;
348
515
  }
349
516
  }
350
517
  prevChar = ch;
351
518
  }
352
- // Check for unmatched brackets in grep patterns
353
519
  const bracketCount = (shellStr.match(/\[/g) || []).length;
354
520
  const closeBracketCount = (shellStr.match(/\]/g) || []).length;
355
521
  if (bracketCount !== closeBracketCount) {
@@ -359,11 +525,93 @@ function checkBashQuoting(shellStr) {
359
525
  }
360
526
  function extractPathReferences(skillContent) {
361
527
  const paths = [];
362
- // Match file/directory references in skill content
363
- // Look for patterns like src/, lib/, *.ts references
364
528
  const pathPatterns = skillContent.matchAll(/(?:^|\s)((?:src|lib|app|cmd|bin|pkg|internal|api|core|test|tests|spec)\/[\w/.\\-]*)/gm);
365
529
  for (const m of pathPatterns) {
366
530
  paths.push(m[1].trim());
367
531
  }
368
532
  return paths;
369
533
  }
534
+ /**
535
+ * Test a single hook command by spawning it.
536
+ * Checks: command existence, execution, exit code, stderr, timeout.
537
+ */
538
+ function testSingleHook(command, os) {
539
+ const TIMEOUT_MS = 10_000;
540
+ // Extract the base tool from the command
541
+ const tool = extractToolName(command);
542
+ // Step 1: Check if the tool exists on the system
543
+ if (tool) {
544
+ const whichCmd = os === "Windows" ? `where ${tool} 2>nul` : `which ${tool} 2>/dev/null`;
545
+ const found = tryExec(whichCmd);
546
+ if (!found || !found.trim()) {
547
+ return { status: "not_found", timeMs: 0, tool };
548
+ }
549
+ }
550
+ // Step 2: Actually execute the hook command with a timeout
551
+ const start = Date.now();
552
+ try {
553
+ let result;
554
+ if (os === "Windows") {
555
+ result = spawnSync("cmd", ["/c", command], {
556
+ timeout: TIMEOUT_MS,
557
+ encoding: "utf8",
558
+ stdio: ["pipe", "pipe", "pipe"],
559
+ cwd: process.cwd(),
560
+ });
561
+ }
562
+ else {
563
+ result = spawnSync("bash", ["-c", command], {
564
+ timeout: TIMEOUT_MS,
565
+ encoding: "utf8",
566
+ stdio: ["pipe", "pipe", "pipe"],
567
+ cwd: process.cwd(),
568
+ });
569
+ }
570
+ const timeMs = Date.now() - start;
571
+ if (result.error) {
572
+ const err = result.error;
573
+ if (err.code === "ETIMEDOUT") {
574
+ return { status: "timeout", timeMs: TIMEOUT_MS };
575
+ }
576
+ if (err.code === "EACCES" || err.code === "EPERM") {
577
+ return { status: "permission", timeMs };
578
+ }
579
+ return { status: "error", exitCode: -1, stderr: err.message, timeMs };
580
+ }
581
+ if (result.status === 0) {
582
+ return { status: "pass", exitCode: 0, timeMs };
583
+ }
584
+ return {
585
+ status: "error",
586
+ exitCode: result.status ?? -1,
587
+ stderr: result.stderr?.trim().slice(0, 200) ?? "",
588
+ timeMs,
589
+ };
590
+ }
591
+ catch (err) {
592
+ const timeMs = Date.now() - start;
593
+ return { status: "error", exitCode: -1, stderr: String(err).slice(0, 200), timeMs };
594
+ }
595
+ }
596
+ /**
597
+ * Extract the base tool name from a shell command.
598
+ * "command -v mvn && mvn compile -q" → "mvn"
599
+ * "npm run build" → "npm"
600
+ * "prettier --check ." → "prettier"
601
+ */
602
+ function extractToolName(command) {
603
+ // Handle "command -v X && ..." or "which X && ..."
604
+ const guardMatch = command.match(/(?:command -v|which|where)\s+(\S+)\s*&&\s*(.*)/);
605
+ if (guardMatch) {
606
+ return guardMatch[1];
607
+ }
608
+ // Handle simple commands
609
+ const parts = command.trim().split(/\s+/);
610
+ const first = parts[0];
611
+ if (!first)
612
+ return null;
613
+ // Skip shell builtins
614
+ if (["cd", "echo", "test", "[", "if", "for", "while"].includes(first))
615
+ return null;
616
+ return first;
617
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import { runSync } from "./commands/sync.js";
7
7
  import { runStatus } from "./commands/status.js";
8
8
  import { runDoctorCommand } from "./commands/doctor.js";
9
9
  import { runRemove } from "./commands/remove.js";
10
+ import { runRestore } from "./commands/restore.js";
11
+ import { runCompare } from "./commands/compare.js";
12
+ import { runExport } from "./commands/export.js";
10
13
  const require = createRequire(import.meta.url);
11
14
  const pkg = require("../package.json");
12
15
  const program = new Command();
@@ -18,7 +21,8 @@ program
18
21
  .command("init")
19
22
  .description("Full project setup — new or existing")
20
23
  .option("--dry-run", "Preview what would be written without writing")
21
- .action((opts) => runInit({ dryRun: opts.dryRun }));
24
+ .option("--template <path>", "Apply a template instead of scanning (local path or URL)")
25
+ .action((opts) => runInit({ dryRun: opts.dryRun, template: opts.template }));
22
26
  program
23
27
  .command("add")
24
28
  .description("Add a multi-file capability")
@@ -27,7 +31,8 @@ program
27
31
  .command("sync")
28
32
  .description("Update setup after project changes")
29
33
  .option("--dry-run", "Preview changes without writing")
30
- .action((opts) => runSync({ dryRun: opts.dryRun }));
34
+ .option("--budget <tokens>", "Override token budget for this run", parseInt)
35
+ .action((opts) => runSync({ dryRun: opts.dryRun, budget: opts.budget }));
31
36
  program
32
37
  .command("status")
33
38
  .description("Show current setup state (instant, no file reads)")
@@ -36,11 +41,31 @@ program
36
41
  .command("doctor")
37
42
  .description("Validate environment — OS, MCP, hooks, env vars, skills")
38
43
  .option("-v, --verbose", "Show passing checks too")
39
- .action((opts) => runDoctorCommand({ verbose: opts.verbose }));
44
+ .option("--fix", "Auto-fix issues where possible (model override, OS format, re-snapshot)")
45
+ .option("--test-hooks", "Run every hook once in sandbox, report pass/fail")
46
+ .action((opts) => runDoctorCommand({
47
+ verbose: opts.verbose,
48
+ fix: opts.fix,
49
+ testHooks: opts.testHooks,
50
+ }));
40
51
  program
41
52
  .command("remove")
42
53
  .description("Remove a capability cleanly")
43
54
  .action(runRemove);
55
+ // Feature A: Time-travel snapshot commands
56
+ program
57
+ .command("restore")
58
+ .description("Jump to any snapshot node, restore files to that state")
59
+ .action(runRestore);
60
+ program
61
+ .command("compare")
62
+ .description("Diff between any two snapshot nodes to see what changed")
63
+ .action(runCompare);
64
+ // Feature H: Config template export
65
+ program
66
+ .command("export")
67
+ .description("Save current project config as a reusable template")
68
+ .action(runExport);
44
69
  // Default action when no command given
45
70
  program.action(() => runInit({}));
46
71
  program.parse();
@@ -6,6 +6,12 @@ export interface ManifestRun {
6
6
  input?: string;
7
7
  filesRead: string[];
8
8
  snapshot: Record<string, string>;
9
+ estimatedTokens?: number;
10
+ estimatedCost?: {
11
+ opus: number;
12
+ sonnet: number;
13
+ haiku: number;
14
+ };
9
15
  }
10
16
  export interface Manifest {
11
17
  version: string;
@@ -17,4 +23,10 @@ export declare function readManifest(cwd?: string): Promise<Manifest | null>;
17
23
  export declare function updateManifest(command: string, collected: CollectedFiles, opts?: {
18
24
  input?: string;
19
25
  cwd?: string;
26
+ estimatedTokens?: number;
27
+ estimatedCost?: {
28
+ opus: number;
29
+ sonnet: number;
30
+ haiku: number;
31
+ };
20
32
  }): Promise<void>;
package/dist/manifest.js CHANGED
@@ -70,6 +70,8 @@ export async function updateManifest(command, collected, opts = {}) {
70
70
  ...(opts.input ? { input: opts.input } : {}),
71
71
  filesRead,
72
72
  snapshot,
73
+ ...(opts.estimatedTokens ? { estimatedTokens: opts.estimatedTokens } : {}),
74
+ ...(opts.estimatedCost ? { estimatedCost: opts.estimatedCost } : {}),
73
75
  };
74
76
  let manifest;
75
77
  const existing = await readManifest(cwd);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Marketplace intelligence — provides catalog info and decision logic
3
+ * for the add command to suggest plugins, skills, and MCP servers.
4
+ *
5
+ * Zero API calls at import time. Catalog is fetched only when needed.
6
+ */
7
+ export declare const MARKETPLACE_REPO = "jeremylongshore/claude-code-plugins-plus-skills";
8
+ export declare const MARKETPLACE_CATALOG_URL = "https://raw.githubusercontent.com/jeremylongshore/claude-code-plugins-plus-skills/main/.claude-plugin/marketplace.extended.json";
9
+ /** The 20 skill categories in the marketplace */
10
+ export declare const SKILL_CATEGORIES: readonly ["01-code-quality", "02-testing", "03-security", "04-devops", "05-api-development", "06-database", "07-frontend", "08-backend", "09-mobile", "10-data-science", "11-documentation", "12-project-management", "13-communication", "14-research", "15-content-creation", "16-business", "17-finance", "18-visual-content", "19-legal", "20-productivity"];
11
+ /** SaaS packs available in the marketplace */
12
+ export declare const SAAS_PACKS: readonly ["Supabase", "Vercel", "OpenRouter", "GitHub", "Azure", "MongoDB", "Playwright", "Tavily", "Stripe", "Slack", "Linear", "Notion"];
13
+ /** Keyword-to-category mapping for classifying user requests */
14
+ export declare const KEYWORD_CATEGORY_MAP: Record<string, string>;
15
+ /** Classify a user request into marketplace categories */
16
+ export declare function classifyRequest(input: string): {
17
+ categories: string[];
18
+ saasMatches: string[];
19
+ };
20
+ /** Generate marketplace search instructions for the add template */
21
+ export declare function buildMarketplaceInstructions(input: string): string;