claude-setup 1.1.1 → 1.1.3
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 +96 -45
- package/dist/builder.js +226 -32
- package/dist/collect.js +40 -5
- package/dist/commands/add.js +10 -7
- package/dist/commands/doctor.d.ts +5 -1
- package/dist/commands/doctor.js +5 -1
- package/dist/commands/init.d.ts +3 -1
- package/dist/commands/init.js +40 -9
- package/dist/commands/remove.js +2 -1
- package/dist/commands/status.js +123 -11
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +65 -10
- package/dist/config.d.ts +14 -0
- package/dist/config.js +89 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +320 -23
- package/dist/index.js +31 -9
- package/dist/manifest.js +12 -0
- package/dist/os.d.ts +25 -0
- package/dist/os.js +52 -0
- package/dist/output.d.ts +18 -0
- package/dist/output.js +40 -0
- package/dist/state.js +5 -1
- package/package.json +1 -1
- package/templates/add.md +8 -4
- package/templates/init.md +81 -17
- package/templates/remove.md +31 -5
- package/templates/sync.md +28 -13
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
+
import { join } from "path";
|
|
3
4
|
import { readManifest } from "./manifest.js";
|
|
4
5
|
import { readState } from "./state.js";
|
|
6
|
+
import { detectOS } from "./os.js";
|
|
7
|
+
import { c, statusLine, section } from "./output.js";
|
|
5
8
|
function tryExec(cmd) {
|
|
6
9
|
try {
|
|
7
10
|
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
@@ -20,53 +23,347 @@ function readIfExists(filePath) {
|
|
|
20
23
|
return null;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
26
|
+
function safeJsonParse(content) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(content);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
25
33
|
}
|
|
26
|
-
export async function runDoctor() {
|
|
34
|
+
export async function runDoctor(verbose = false) {
|
|
35
|
+
const os = detectOS();
|
|
27
36
|
const manifest = await readManifest();
|
|
28
37
|
const state = await readState();
|
|
29
|
-
|
|
30
|
-
|
|
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`);
|
|
40
|
+
// --- Check 1: Claude Code version ---
|
|
41
|
+
section("Environment");
|
|
31
42
|
const cv = tryExec("claude --version");
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
if (cv) {
|
|
44
|
+
statusLine("✅", "Claude Code", cv.trim());
|
|
45
|
+
counts.healthy++;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
statusLine("🔴", "Claude Code", c.red("not found in PATH"));
|
|
49
|
+
counts.critical++;
|
|
50
|
+
}
|
|
51
|
+
// --- Check 2: Manifest integrity ---
|
|
34
52
|
const lastRun = manifest?.runs.at(-1);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
if (manifest) {
|
|
54
|
+
const staleCheck = lastRun ? checkStaleness(lastRun.at) : null;
|
|
55
|
+
const detail = lastRun
|
|
56
|
+
? `last: ${lastRun.command} at ${lastRun.at}${staleCheck ? ` ${staleCheck}` : ""}`
|
|
57
|
+
: "empty runs array";
|
|
58
|
+
statusLine("✅", "Manifest", detail);
|
|
59
|
+
counts.healthy++;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
statusLine("⚠️ ", "Manifest", c.yellow("not found — run: npx claude-setup init"));
|
|
63
|
+
counts.warnings++;
|
|
64
|
+
}
|
|
65
|
+
// --- Check 2b: Out-of-band edit detection ---
|
|
66
|
+
if (lastRun) {
|
|
67
|
+
const { createHash } = await import("crypto");
|
|
68
|
+
const oobFiles = [
|
|
69
|
+
{ label: "CLAUDE.md", path: join(process.cwd(), "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
|
|
70
|
+
{ label: ".mcp.json", path: join(process.cwd(), ".mcp.json"), snapshotKey: ".mcp.json" },
|
|
71
|
+
{ label: "settings.json", path: join(process.cwd(), ".claude", "settings.json"), snapshotKey: ".claude/settings.json" },
|
|
72
|
+
];
|
|
73
|
+
for (const mf of oobFiles) {
|
|
74
|
+
if (!existsSync(mf.path))
|
|
75
|
+
continue;
|
|
76
|
+
const content = readFileSync(mf.path, "utf8");
|
|
77
|
+
const currentHash = createHash("sha256").update(content).digest("hex");
|
|
78
|
+
const snapshotHash = lastRun.snapshot[mf.snapshotKey];
|
|
79
|
+
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++;
|
|
82
|
+
}
|
|
41
83
|
}
|
|
42
84
|
}
|
|
43
|
-
//
|
|
85
|
+
// --- Check 3: OS/MCP format mismatch ---
|
|
86
|
+
if (state.mcpJson.content) {
|
|
87
|
+
section("MCP servers");
|
|
88
|
+
const mcp = safeJsonParse(state.mcpJson.content);
|
|
89
|
+
if (mcp && typeof mcp.mcpServers === "object" && mcp.mcpServers !== null) {
|
|
90
|
+
const servers = mcp.mcpServers;
|
|
91
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
92
|
+
const cmd = config.command;
|
|
93
|
+
if (!cmd) {
|
|
94
|
+
statusLine("⚠️ ", name, c.yellow("no command field"));
|
|
95
|
+
counts.warnings++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (os === "Windows" && cmd === "npx") {
|
|
99
|
+
statusLine("🔴", name, c.red(`BROKEN: Windows can't execute npx directly — use cmd /c npx`));
|
|
100
|
+
counts.critical++;
|
|
101
|
+
}
|
|
102
|
+
else if (os !== "Windows" && cmd === "cmd") {
|
|
103
|
+
statusLine("⚠️ ", name, c.yellow(`UNNECESSARY: cmd wrapper not needed on ${os}`));
|
|
104
|
+
counts.warnings++;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
statusLine("✅", name, `valid, OS-format correct (${cmd})`);
|
|
108
|
+
counts.healthy++;
|
|
109
|
+
}
|
|
110
|
+
// Check for missing -y flag in npx args
|
|
111
|
+
const args = config.args;
|
|
112
|
+
if (args) {
|
|
113
|
+
const npxIndex = args.indexOf("npx");
|
|
114
|
+
if (npxIndex >= 0 && args[npxIndex + 1] !== "-y") {
|
|
115
|
+
statusLine("⚠️ ", name, c.yellow(`npx without -y flag — installs may hang`));
|
|
116
|
+
counts.warnings++;
|
|
117
|
+
}
|
|
118
|
+
// Check for hardcoded connection strings in args
|
|
119
|
+
for (const arg of args) {
|
|
120
|
+
if (/^(postgresql|postgres|mysql|mongodb|redis|amqp):\/\//.test(arg)) {
|
|
121
|
+
statusLine("🔴", name, c.red(`HARDCODED connection string in args — move to env: { "DATABASE_URL": "\${DATABASE_URL}" }`));
|
|
122
|
+
counts.critical++;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Check for channel-type servers
|
|
129
|
+
const channelNames = ["telegram", "discord", "fakechat"];
|
|
130
|
+
for (const [name] of Object.entries(servers)) {
|
|
131
|
+
if (channelNames.includes(name.toLowerCase())) {
|
|
132
|
+
statusLine("⚠️ ", `${name} (CHANNEL)`, c.yellow(`channel server detected — .mcp.json alone does not activate delivery. ` +
|
|
133
|
+
`Launch with: claude --channels plugin:${name.toLowerCase()}@claude-plugins-official`));
|
|
134
|
+
counts.warnings++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (mcp) {
|
|
139
|
+
// Check for flat structure (some .mcp.json files use flat keys)
|
|
140
|
+
if (verbose)
|
|
141
|
+
statusLine("⚠️ ", ".mcp.json", "no mcpServers key found");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (verbose) {
|
|
145
|
+
section("MCP servers");
|
|
146
|
+
statusLine("⏭ ", ".mcp.json", "does not exist");
|
|
147
|
+
}
|
|
148
|
+
// --- Check 4: Hook quoting bugs ---
|
|
149
|
+
if (state.settings.content) {
|
|
150
|
+
section("Hooks");
|
|
151
|
+
const settings = safeJsonParse(state.settings.content);
|
|
152
|
+
if (settings) {
|
|
153
|
+
const hookCategories = [
|
|
154
|
+
"PreToolUse", "PostToolUse", "PostToolUseFailure",
|
|
155
|
+
"Stop", "SessionStart"
|
|
156
|
+
];
|
|
157
|
+
// Bug 6: Check for model override
|
|
158
|
+
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.`));
|
|
160
|
+
counts.warnings++;
|
|
161
|
+
}
|
|
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++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let foundHooks = false;
|
|
173
|
+
for (const category of hookCategories) {
|
|
174
|
+
const hooks = settings[category];
|
|
175
|
+
if (!hooks || !Array.isArray(hooks))
|
|
176
|
+
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)
|
|
184
|
+
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++;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
statusLine("✅", `${category} hook`, "quoting clean");
|
|
196
|
+
counts.healthy++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
statusLine("✅", `${category} hook`, "valid");
|
|
201
|
+
counts.healthy++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!foundHooks && verbose) {
|
|
206
|
+
statusLine("⏭ ", "Hooks", "none configured");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (verbose) {
|
|
211
|
+
section("Hooks");
|
|
212
|
+
statusLine("⏭ ", "settings.json", "does not exist");
|
|
213
|
+
}
|
|
214
|
+
// --- Check 5: Env var coverage ---
|
|
44
215
|
if (state.mcpJson.content) {
|
|
45
216
|
const refs = [...state.mcpJson.content.matchAll(/\$\{?([A-Z_][A-Z0-9_]+)\}?/g)]
|
|
46
217
|
.map(m => m[1]);
|
|
47
218
|
const unique = [...new Set(refs)];
|
|
48
219
|
if (unique.length) {
|
|
49
|
-
const template = readIfExists(".env.example") ?? readIfExists(".env.sample") ?? "";
|
|
50
|
-
|
|
220
|
+
const template = readIfExists(".env.example") ?? readIfExists(".env.sample") ?? readIfExists(".env.template") ?? "";
|
|
221
|
+
section("Env vars");
|
|
51
222
|
for (const v of unique) {
|
|
52
|
-
|
|
223
|
+
if (template.includes(v)) {
|
|
224
|
+
statusLine("✅", `\${${v}}`, "found in env template");
|
|
225
|
+
counts.healthy++;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
statusLine("⚠️ ", `\${${v}}`, c.yellow("used in .mcp.json but missing from .env.example"));
|
|
229
|
+
counts.warnings++;
|
|
230
|
+
}
|
|
53
231
|
}
|
|
54
232
|
}
|
|
55
233
|
}
|
|
56
|
-
// Workflow
|
|
234
|
+
// --- Check 6: Workflow secret coverage ---
|
|
57
235
|
if (state.workflows.length) {
|
|
58
236
|
const secrets = new Set();
|
|
59
237
|
for (const wf of state.workflows) {
|
|
60
238
|
const content = readIfExists(wf) ?? "";
|
|
61
|
-
for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_]
|
|
239
|
+
for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/g)) {
|
|
62
240
|
secrets.add(m[1]);
|
|
63
241
|
}
|
|
64
242
|
}
|
|
65
243
|
if (secrets.size) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
244
|
+
section("Workflow secrets");
|
|
245
|
+
const readme = readIfExists("README.md") ?? "";
|
|
246
|
+
const envTemplate = readIfExists(".env.example") ?? readIfExists(".env.sample") ?? "";
|
|
247
|
+
for (const s of secrets) {
|
|
248
|
+
if (readme.includes(s) || envTemplate.includes(s)) {
|
|
249
|
+
statusLine("✅", s, "documented");
|
|
250
|
+
counts.healthy++;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
statusLine("⚠️ ", s, c.yellow("needs GitHub Settings → Secrets"));
|
|
254
|
+
counts.warnings++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
69
257
|
}
|
|
70
258
|
}
|
|
71
|
-
|
|
259
|
+
// --- Check 7: Stale skill paths ---
|
|
260
|
+
if (state.skills.length) {
|
|
261
|
+
section("Skills");
|
|
262
|
+
for (const skillPath of state.skills) {
|
|
263
|
+
const content = readIfExists(skillPath);
|
|
264
|
+
if (!content) {
|
|
265
|
+
statusLine("⚠️ ", skillPath, c.yellow("skill file not readable"));
|
|
266
|
+
counts.warnings++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Extract file/dir references from skill content
|
|
270
|
+
const pathRefs = extractPathReferences(content);
|
|
271
|
+
const staleRefs = pathRefs.filter(ref => !existsSync(ref) && !existsSync(join(process.cwd(), ref)));
|
|
272
|
+
if (staleRefs.length) {
|
|
273
|
+
statusLine("⚠️ ", skillPath, c.yellow(`stale paths: ${staleRefs.join(", ")}`));
|
|
274
|
+
counts.warnings++;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
statusLine("✅", skillPath, "valid");
|
|
278
|
+
counts.healthy++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (verbose) {
|
|
283
|
+
section("Skills");
|
|
284
|
+
statusLine("⏭ ", "Skills", "none installed");
|
|
285
|
+
}
|
|
286
|
+
// --- Check 8: Files from last run still on disk ---
|
|
287
|
+
// Filter __digest__ — it's a virtual key, not a real file
|
|
288
|
+
if (lastRun?.filesRead.length && verbose) {
|
|
289
|
+
section("Files from last run");
|
|
290
|
+
const realFiles = lastRun.filesRead.filter(f => f !== "__digest__" && f !== ".env");
|
|
291
|
+
for (const f of realFiles.slice(0, 8)) {
|
|
292
|
+
if (existsSync(f)) {
|
|
293
|
+
statusLine("✅", f, "");
|
|
294
|
+
counts.healthy++;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
statusLine("⚠️ ", f, c.yellow("not found on disk"));
|
|
298
|
+
counts.warnings++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (realFiles.length > 8) {
|
|
302
|
+
console.log(c.dim(` ... +${realFiles.length - 8} more`));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// --- Summary ---
|
|
306
|
+
console.log("");
|
|
307
|
+
section("Summary");
|
|
308
|
+
if (counts.critical > 0)
|
|
309
|
+
console.log(` 🔴 ${c.red(`${counts.critical} critical`)} (will break Claude Code)`);
|
|
310
|
+
if (counts.warnings > 0)
|
|
311
|
+
console.log(` ⚠️ ${c.yellow(`${counts.warnings} warning(s)`)} (degraded behavior)`);
|
|
312
|
+
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.`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
console.log(`\n${c.green("✅ Done.")}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// --- Helpers ---
|
|
321
|
+
function checkStaleness(lastRunDate) {
|
|
322
|
+
try {
|
|
323
|
+
const last = new Date(lastRunDate).getTime();
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
const daysSince = Math.floor((now - last) / (1000 * 60 * 60 * 24));
|
|
326
|
+
if (daysSince > 7)
|
|
327
|
+
return c.yellow(`(${daysSince} days ago — may be stale)`);
|
|
328
|
+
}
|
|
329
|
+
catch { /* invalid date */ }
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
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
|
+
let inSingleQuote = false;
|
|
336
|
+
let prevChar = "";
|
|
337
|
+
for (let i = 0; i < shellStr.length; i++) {
|
|
338
|
+
const ch = shellStr[i];
|
|
339
|
+
if (ch === "'" && prevChar !== "\\") {
|
|
340
|
+
inSingleQuote = !inSingleQuote;
|
|
341
|
+
}
|
|
342
|
+
// Unescaped double quote inside single-quoted context is a problem
|
|
343
|
+
// when the outer wrapper is double quotes
|
|
344
|
+
if (ch === '"' && !inSingleQuote && prevChar !== "\\") {
|
|
345
|
+
// Check for patterns like ["'] which mix quote types
|
|
346
|
+
if (i > 0 && shellStr[i - 1] === "[") {
|
|
347
|
+
return `mixed quotes in character class at position ${i}: ...${shellStr.slice(Math.max(0, i - 10), i + 10)}...`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
prevChar = ch;
|
|
351
|
+
}
|
|
352
|
+
// Check for unmatched brackets in grep patterns
|
|
353
|
+
const bracketCount = (shellStr.match(/\[/g) || []).length;
|
|
354
|
+
const closeBracketCount = (shellStr.match(/\]/g) || []).length;
|
|
355
|
+
if (bracketCount !== closeBracketCount) {
|
|
356
|
+
return "unmatched brackets in pattern";
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
function extractPathReferences(skillContent) {
|
|
361
|
+
const paths = [];
|
|
362
|
+
// Match file/directory references in skill content
|
|
363
|
+
// Look for patterns like src/, lib/, *.ts references
|
|
364
|
+
const pathPatterns = skillContent.matchAll(/(?:^|\s)((?:src|lib|app|cmd|bin|pkg|internal|api|core|test|tests|spec)\/[\w/.\\-]*)/gm);
|
|
365
|
+
for (const m of pathPatterns) {
|
|
366
|
+
paths.push(m[1].trim());
|
|
367
|
+
}
|
|
368
|
+
return paths;
|
|
72
369
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,20 +5,42 @@ import { runInit } from "./commands/init.js";
|
|
|
5
5
|
import { runAdd } from "./commands/add.js";
|
|
6
6
|
import { runSync } from "./commands/sync.js";
|
|
7
7
|
import { runStatus } from "./commands/status.js";
|
|
8
|
-
import {
|
|
8
|
+
import { runDoctorCommand } from "./commands/doctor.js";
|
|
9
9
|
import { runRemove } from "./commands/remove.js";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
const pkg = require("../package.json");
|
|
12
12
|
const program = new Command();
|
|
13
13
|
program
|
|
14
14
|
.name("claude-setup")
|
|
15
|
-
.description("Setup layer for Claude Code")
|
|
15
|
+
.description("Setup layer for Claude Code — reads your project, writes command files, Claude Code does the rest")
|
|
16
16
|
.version(pkg.version);
|
|
17
|
-
program
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
program
|
|
23
|
-
|
|
17
|
+
program
|
|
18
|
+
.command("init")
|
|
19
|
+
.description("Full project setup — new or existing")
|
|
20
|
+
.option("--dry-run", "Preview what would be written without writing")
|
|
21
|
+
.action((opts) => runInit({ dryRun: opts.dryRun }));
|
|
22
|
+
program
|
|
23
|
+
.command("add")
|
|
24
|
+
.description("Add a multi-file capability")
|
|
25
|
+
.action(runAdd);
|
|
26
|
+
program
|
|
27
|
+
.command("sync")
|
|
28
|
+
.description("Update setup after project changes")
|
|
29
|
+
.option("--dry-run", "Preview changes without writing")
|
|
30
|
+
.action((opts) => runSync({ dryRun: opts.dryRun }));
|
|
31
|
+
program
|
|
32
|
+
.command("status")
|
|
33
|
+
.description("Show current setup state (instant, no file reads)")
|
|
34
|
+
.action(runStatus);
|
|
35
|
+
program
|
|
36
|
+
.command("doctor")
|
|
37
|
+
.description("Validate environment — OS, MCP, hooks, env vars, skills")
|
|
38
|
+
.option("-v, --verbose", "Show passing checks too")
|
|
39
|
+
.action((opts) => runDoctorCommand({ verbose: opts.verbose }));
|
|
40
|
+
program
|
|
41
|
+
.command("remove")
|
|
42
|
+
.description("Remove a capability cleanly")
|
|
43
|
+
.action(runRemove);
|
|
44
|
+
// Default action when no command given
|
|
45
|
+
program.action(() => runInit({}));
|
|
24
46
|
program.parse();
|
package/dist/manifest.js
CHANGED
|
@@ -47,6 +47,18 @@ export async function updateManifest(command, collected, opts = {}) {
|
|
|
47
47
|
for (const { path, content } of collected.source) {
|
|
48
48
|
snapshot[path] = sha256(content);
|
|
49
49
|
}
|
|
50
|
+
// Also snapshot CLI-managed files for out-of-band edit detection
|
|
51
|
+
const managedFiles = [
|
|
52
|
+
{ key: "CLAUDE.md", path: join(cwd, "CLAUDE.md") },
|
|
53
|
+
{ key: ".mcp.json", path: join(cwd, ".mcp.json") },
|
|
54
|
+
{ key: ".claude/settings.json", path: join(cwd, ".claude", "settings.json") },
|
|
55
|
+
];
|
|
56
|
+
for (const mf of managedFiles) {
|
|
57
|
+
if (existsSync(mf.path)) {
|
|
58
|
+
const content = readFileSync(mf.path, "utf8");
|
|
59
|
+
snapshot[mf.key] = sha256(content);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
50
62
|
const filesRead = [
|
|
51
63
|
...Object.keys(collected.configs),
|
|
52
64
|
...collected.source.map(s => s.path),
|
package/dist/os.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type DetectedOS = "Windows" | "macOS" | "Linux";
|
|
2
|
+
/**
|
|
3
|
+
* Detect OS once per session. Order per spec:
|
|
4
|
+
* 1. COMSPEC set → Windows
|
|
5
|
+
* 2. OS === "Windows_NT" → Windows
|
|
6
|
+
* 3. process.platform === "win32" → Windows
|
|
7
|
+
* 4. uname() === "Darwin" → macOS
|
|
8
|
+
* 5. default → Linux
|
|
9
|
+
*/
|
|
10
|
+
export declare function detectOS(): DetectedOS;
|
|
11
|
+
/**
|
|
12
|
+
* Verified MCP package names — ONLY use these.
|
|
13
|
+
* If a service is not in this map, do not guess a package name.
|
|
14
|
+
*/
|
|
15
|
+
export declare const VERIFIED_MCP_PACKAGES: Record<string, string>;
|
|
16
|
+
/** MCP command format per OS — always includes -y to prevent npx install hangs */
|
|
17
|
+
export declare function mcpCommandFormat(os: DetectedOS, pkg: string): {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
};
|
|
21
|
+
/** Hook shell format per OS */
|
|
22
|
+
export declare function hookShellFormat(os: DetectedOS, cmd: string): {
|
|
23
|
+
command: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
};
|
package/dist/os.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Detect OS once per session. Order per spec:
|
|
4
|
+
* 1. COMSPEC set → Windows
|
|
5
|
+
* 2. OS === "Windows_NT" → Windows
|
|
6
|
+
* 3. process.platform === "win32" → Windows
|
|
7
|
+
* 4. uname() === "Darwin" → macOS
|
|
8
|
+
* 5. default → Linux
|
|
9
|
+
*/
|
|
10
|
+
export function detectOS() {
|
|
11
|
+
if (process.env.COMSPEC)
|
|
12
|
+
return "Windows";
|
|
13
|
+
if (process.env.OS === "Windows_NT")
|
|
14
|
+
return "Windows";
|
|
15
|
+
if (process.platform === "win32")
|
|
16
|
+
return "Windows";
|
|
17
|
+
try {
|
|
18
|
+
const uname = execSync("uname", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
19
|
+
if (uname === "Darwin")
|
|
20
|
+
return "macOS";
|
|
21
|
+
}
|
|
22
|
+
catch { /* not unix — unlikely to reach here */ }
|
|
23
|
+
return "Linux";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Verified MCP package names — ONLY use these.
|
|
27
|
+
* If a service is not in this map, do not guess a package name.
|
|
28
|
+
*/
|
|
29
|
+
export const VERIFIED_MCP_PACKAGES = {
|
|
30
|
+
playwright: "@playwright/mcp@latest",
|
|
31
|
+
postgres: "@modelcontextprotocol/server-postgres",
|
|
32
|
+
filesystem: "@modelcontextprotocol/server-filesystem",
|
|
33
|
+
memory: "@modelcontextprotocol/server-memory",
|
|
34
|
+
github: "@modelcontextprotocol/server-github",
|
|
35
|
+
brave: "@modelcontextprotocol/server-brave-search",
|
|
36
|
+
puppeteer: "@modelcontextprotocol/server-puppeteer",
|
|
37
|
+
slack: "@modelcontextprotocol/server-slack",
|
|
38
|
+
};
|
|
39
|
+
/** MCP command format per OS — always includes -y to prevent npx install hangs */
|
|
40
|
+
export function mcpCommandFormat(os, pkg) {
|
|
41
|
+
if (os === "Windows") {
|
|
42
|
+
return { command: "cmd", args: ["/c", "npx", "-y", pkg] };
|
|
43
|
+
}
|
|
44
|
+
return { command: "npx", args: ["-y", pkg] };
|
|
45
|
+
}
|
|
46
|
+
/** Hook shell format per OS */
|
|
47
|
+
export function hookShellFormat(os, cmd) {
|
|
48
|
+
if (os === "Windows") {
|
|
49
|
+
return { command: "cmd", args: ["/c", cmd] };
|
|
50
|
+
}
|
|
51
|
+
return { command: "bash", args: ["-c", cmd] };
|
|
52
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output helpers — no dependencies.
|
|
3
|
+
* Raw ANSI codes. Falls back gracefully if NO_COLOR or piped.
|
|
4
|
+
*/
|
|
5
|
+
export declare const c: {
|
|
6
|
+
bold: (t: string) => string;
|
|
7
|
+
dim: (t: string) => string;
|
|
8
|
+
red: (t: string) => string;
|
|
9
|
+
green: (t: string) => string;
|
|
10
|
+
yellow: (t: string) => string;
|
|
11
|
+
blue: (t: string) => string;
|
|
12
|
+
cyan: (t: string) => string;
|
|
13
|
+
gray: (t: string) => string;
|
|
14
|
+
};
|
|
15
|
+
/** Print a status line: icon + label + optional detail */
|
|
16
|
+
export declare function statusLine(icon: string, label: string, detail?: string): void;
|
|
17
|
+
/** Print a section header */
|
|
18
|
+
export declare function section(title: string): void;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output helpers — no dependencies.
|
|
3
|
+
* Raw ANSI codes. Falls back gracefully if NO_COLOR or piped.
|
|
4
|
+
*/
|
|
5
|
+
const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
6
|
+
const codes = {
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
red: "\x1b[31m",
|
|
11
|
+
green: "\x1b[32m",
|
|
12
|
+
yellow: "\x1b[33m",
|
|
13
|
+
blue: "\x1b[34m",
|
|
14
|
+
cyan: "\x1b[36m",
|
|
15
|
+
gray: "\x1b[90m",
|
|
16
|
+
};
|
|
17
|
+
function wrap(code, text) {
|
|
18
|
+
if (!supportsColor)
|
|
19
|
+
return text;
|
|
20
|
+
return `${code}${text}${codes.reset}`;
|
|
21
|
+
}
|
|
22
|
+
export const c = {
|
|
23
|
+
bold: (t) => wrap(codes.bold, t),
|
|
24
|
+
dim: (t) => wrap(codes.dim, t),
|
|
25
|
+
red: (t) => wrap(codes.red, t),
|
|
26
|
+
green: (t) => wrap(codes.green, t),
|
|
27
|
+
yellow: (t) => wrap(codes.yellow, t),
|
|
28
|
+
blue: (t) => wrap(codes.blue, t),
|
|
29
|
+
cyan: (t) => wrap(codes.cyan, t),
|
|
30
|
+
gray: (t) => wrap(codes.gray, t),
|
|
31
|
+
};
|
|
32
|
+
/** Print a status line: icon + label + optional detail */
|
|
33
|
+
export function statusLine(icon, label, detail) {
|
|
34
|
+
const detailStr = detail ? ` — ${detail}` : "";
|
|
35
|
+
console.log(` ${icon} ${label}${detailStr}`);
|
|
36
|
+
}
|
|
37
|
+
/** Print a section header */
|
|
38
|
+
export function section(title) {
|
|
39
|
+
console.log(`\n${c.bold(title)}`);
|
|
40
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -9,9 +9,13 @@ export async function readState(cwd = process.cwd()) {
|
|
|
9
9
|
const claudeMd = readIfExists(claudeMdPath);
|
|
10
10
|
const mcpJson = readIfExists(mcpJsonPath);
|
|
11
11
|
const settings = readIfExists(settingsPath);
|
|
12
|
+
// Scan all three skill patterns and deduplicate (Bug 4 fix)
|
|
12
13
|
let skills = [];
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
+
const structured = await glob(".claude/skills/*/SKILL.md", { cwd, posix: true });
|
|
16
|
+
const flat = await glob(".claude/skills/*.md", { cwd, posix: true });
|
|
17
|
+
const nested = await glob(".claude/skills/**/*.md", { cwd, posix: true });
|
|
18
|
+
skills = [...new Set([...structured, ...flat, ...nested])];
|
|
15
19
|
}
|
|
16
20
|
catch { /* no skills */ }
|
|
17
21
|
let commands = [];
|
package/package.json
CHANGED
package/templates/add.md
CHANGED
|
@@ -5,7 +5,7 @@ Add to Claude Code setup: "{{USER_INPUT}}"
|
|
|
5
5
|
## Project context
|
|
6
6
|
{{PROJECT_CONTEXT}}
|
|
7
7
|
|
|
8
|
-
## Current setup
|
|
8
|
+
## Current setup — read before writing anything
|
|
9
9
|
|
|
10
10
|
{{#if HAS_CLAUDE_MD}}
|
|
11
11
|
### CLAUDE.md
|
|
@@ -25,9 +25,13 @@ Add to Claude Code setup: "{{USER_INPUT}}"
|
|
|
25
25
|
Skills: {{SKILLS_LIST}} | Commands: {{COMMANDS_LIST}}
|
|
26
26
|
|
|
27
27
|
## Rules
|
|
28
|
-
- Read current content before writing. Merge/append only.
|
|
29
|
-
- If request mentions something not in project files: ask first.
|
|
28
|
+
- Read current content above before writing. Merge/append only.
|
|
29
|
+
- If request mentions something not evidenced in project files: ask first.
|
|
30
|
+
- OS detected: {{DETECTED_OS}}. Use correct command format for MCP/hooks:
|
|
31
|
+
- Windows: `{ "command": "cmd", "args": ["/c", "npx", "<pkg>"] }`
|
|
32
|
+
- macOS/Linux: `{ "command": "npx", "args": ["<pkg>"] }`
|
|
33
|
+
- All env var refs use `${VARNAME}` syntax. Document new vars in .env.example.
|
|
30
34
|
|
|
31
35
|
## Output — one line per file
|
|
32
36
|
Updated: ✅ [path] — [what and why]
|
|
33
|
-
Skipped: ⏭ [path] — [why]
|
|
37
|
+
Skipped: ⏭ [path] — [why not needed for this request]
|