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/README.md +111 -46
- package/dist/builder.js +94 -29
- package/dist/commands/add.js +14 -3
- package/dist/commands/compare.d.ts +1 -0
- package/dist/commands/compare.js +84 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/export.d.ts +25 -0
- package/dist/commands/export.js +289 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +45 -9
- package/dist/commands/remove.js +14 -3
- package/dist/commands/restore.d.ts +1 -0
- package/dist/commands/restore.js +61 -0
- package/dist/commands/status.js +189 -16
- package/dist/commands/sync.d.ts +1 -0
- package/dist/commands/sync.js +71 -8
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +307 -59
- package/dist/index.js +28 -3
- package/dist/manifest.d.ts +12 -0
- package/dist/manifest.js +2 -0
- package/dist/marketplace.d.ts +21 -0
- package/dist/marketplace.js +159 -0
- package/dist/os.js +5 -0
- package/dist/snapshot.d.ts +71 -0
- package/dist/snapshot.js +195 -0
- package/dist/tokens.d.ts +58 -0
- package/dist/tokens.js +132 -0
- package/package.json +49 -49
- package/templates/add.md +122 -3
- package/templates/init-empty.md +58 -1
- package/templates/init.md +53 -16
- package/templates/remove.md +27 -2
- package/templates/sync.md +20 -1
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}
|
|
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
|
|
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 =
|
|
86
|
+
const currentHash = sha256(content);
|
|
78
87
|
const snapshotHash = lastRun.snapshot[mf.snapshotKey];
|
|
79
88
|
if (snapshotHash && currentHash !== snapshotHash) {
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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"]}"
|
|
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
|
-
//
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
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
|
|
178
|
-
if (typeof
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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.
|
|
314
|
-
console.log(
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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();
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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;
|