claude-launchpad 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -0
- package/dist/cli.js +2026 -0
- package/dist/cli.js.map +1 -0
- package/package.json +57 -0
- package/scenarios/common/env-protection.yaml +33 -0
- package/scenarios/common/error-handling.yaml +37 -0
- package/scenarios/common/file-size.yaml +32 -0
- package/scenarios/common/immutability.yaml +41 -0
- package/scenarios/common/input-validation.yaml +41 -0
- package/scenarios/common/secret-exposure.yaml +35 -0
- package/scenarios/common/sql-injection.yaml +33 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2026 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command as Command5 } from "commander";
|
|
5
|
+
import { access as access7 } from "fs/promises";
|
|
6
|
+
import { join as join8 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/commands/init/index.ts
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { input, confirm } from "@inquirer/prompts";
|
|
11
|
+
import { writeFile, mkdir, readFile as readFile2 } from "fs/promises";
|
|
12
|
+
import { join as join2 } from "path";
|
|
13
|
+
|
|
14
|
+
// src/lib/output.ts
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
var colors = {
|
|
17
|
+
success: chalk.green,
|
|
18
|
+
error: chalk.red,
|
|
19
|
+
warn: chalk.yellow,
|
|
20
|
+
info: chalk.cyan,
|
|
21
|
+
dim: chalk.dim,
|
|
22
|
+
bold: chalk.bold,
|
|
23
|
+
score: (score) => {
|
|
24
|
+
if (score >= 80) return chalk.green.bold(`${score}%`);
|
|
25
|
+
if (score >= 60) return chalk.yellow.bold(`${score}%`);
|
|
26
|
+
return chalk.red.bold(`${score}%`);
|
|
27
|
+
},
|
|
28
|
+
severity: (sev) => {
|
|
29
|
+
const map = {
|
|
30
|
+
critical: chalk.bgRed.white.bold,
|
|
31
|
+
high: chalk.red.bold,
|
|
32
|
+
medium: chalk.yellow,
|
|
33
|
+
low: chalk.cyan,
|
|
34
|
+
info: chalk.dim
|
|
35
|
+
};
|
|
36
|
+
return map[sev](` ${sev.toUpperCase()} `);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var log = {
|
|
40
|
+
success: (msg) => console.log(` ${chalk.green("\u2713")} ${msg}`),
|
|
41
|
+
error: (msg) => console.log(` ${chalk.red("\u2717")} ${msg}`),
|
|
42
|
+
warn: (msg) => console.log(` ${chalk.yellow("!")} ${msg}`),
|
|
43
|
+
step: (msg) => console.log(` ${chalk.cyan("\u2192")} ${msg}`),
|
|
44
|
+
info: (msg) => console.log(` ${chalk.dim("\xB7")} ${msg}`),
|
|
45
|
+
blank: () => console.log()
|
|
46
|
+
};
|
|
47
|
+
function printBanner() {
|
|
48
|
+
log.blank();
|
|
49
|
+
console.log(chalk.cyan.bold(" Claude Launchpad"));
|
|
50
|
+
console.log(chalk.dim(" Scaffold \xB7 Diagnose \xB7 Evaluate"));
|
|
51
|
+
log.blank();
|
|
52
|
+
}
|
|
53
|
+
function printScoreCard(label, score, max = 100) {
|
|
54
|
+
const pct = Math.round(score / max * 100);
|
|
55
|
+
const bar = renderBar(pct, 20);
|
|
56
|
+
console.log(` ${chalk.bold(label.padEnd(22))} ${bar} ${colors.score(pct).padStart(12)}`);
|
|
57
|
+
}
|
|
58
|
+
function renderBar(pct, width) {
|
|
59
|
+
const filled = Math.round(pct / 100 * width);
|
|
60
|
+
const empty = width - filled;
|
|
61
|
+
const color = pct >= 80 ? chalk.green : pct >= 60 ? chalk.yellow : chalk.red;
|
|
62
|
+
return color("\u2501".repeat(filled)) + chalk.dim("\u2500".repeat(empty));
|
|
63
|
+
}
|
|
64
|
+
function printIssue(severity, analyzer, message, fix) {
|
|
65
|
+
const tag = colors.severity(severity);
|
|
66
|
+
console.log(` ${tag} ${chalk.bold(analyzer)}`);
|
|
67
|
+
console.log(` ${message}`);
|
|
68
|
+
if (fix) {
|
|
69
|
+
console.log(` ${chalk.dim("Fix:")} ${chalk.dim(fix)}`);
|
|
70
|
+
}
|
|
71
|
+
console.log();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/lib/detect.ts
|
|
75
|
+
import { readFile, access } from "fs/promises";
|
|
76
|
+
import { join, basename } from "path";
|
|
77
|
+
async function detectProject(root) {
|
|
78
|
+
const name = basename(root);
|
|
79
|
+
const [pkgJson, goMod, pyProject, gemfile, cargo, pubspec, composerJson, pomXml, buildGradle, packageSwift, mixExs, csproj, lockfiles] = await Promise.all([
|
|
80
|
+
readJsonOrNull(join(root, "package.json")),
|
|
81
|
+
fileExists(join(root, "go.mod")),
|
|
82
|
+
readFileOrNull(join(root, "pyproject.toml")),
|
|
83
|
+
fileExists(join(root, "Gemfile")),
|
|
84
|
+
fileExists(join(root, "Cargo.toml")),
|
|
85
|
+
fileExists(join(root, "pubspec.yaml")),
|
|
86
|
+
readJsonOrNull(join(root, "composer.json")),
|
|
87
|
+
fileExists(join(root, "pom.xml")),
|
|
88
|
+
fileExists(join(root, "build.gradle")) || fileExists(join(root, "build.gradle.kts")),
|
|
89
|
+
fileExists(join(root, "Package.swift")),
|
|
90
|
+
fileExists(join(root, "mix.exs")),
|
|
91
|
+
globExists(root, "*.csproj"),
|
|
92
|
+
detectLockfiles(root)
|
|
93
|
+
]);
|
|
94
|
+
const manifests = {
|
|
95
|
+
pkgJson,
|
|
96
|
+
goMod,
|
|
97
|
+
pyProject,
|
|
98
|
+
gemfile,
|
|
99
|
+
cargo,
|
|
100
|
+
pubspec,
|
|
101
|
+
composerJson,
|
|
102
|
+
pomXml,
|
|
103
|
+
buildGradle,
|
|
104
|
+
packageSwift,
|
|
105
|
+
mixExs,
|
|
106
|
+
csproj
|
|
107
|
+
};
|
|
108
|
+
const language = detectLanguage(manifests);
|
|
109
|
+
const framework = detectFramework(manifests);
|
|
110
|
+
const packageManager = detectPackageManager(manifests, lockfiles);
|
|
111
|
+
const scripts = detectScripts({ pkgJson, pyProject, goMod, gemfile, composerJson, language });
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
language,
|
|
115
|
+
framework,
|
|
116
|
+
packageManager,
|
|
117
|
+
hasTests: scripts.testCommand !== null,
|
|
118
|
+
hasLinter: scripts.lintCommand !== null,
|
|
119
|
+
hasFormatter: scripts.formatCommand !== null,
|
|
120
|
+
...scripts
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function detectLanguage(m) {
|
|
124
|
+
if (m.pkgJson?.devDependencies?.typescript || m.pkgJson?.dependencies?.typescript) return "TypeScript";
|
|
125
|
+
if (m.pkgJson) return "JavaScript";
|
|
126
|
+
if (m.goMod) return "Go";
|
|
127
|
+
if (m.pyProject) return "Python";
|
|
128
|
+
if (m.gemfile) return "Ruby";
|
|
129
|
+
if (m.cargo) return "Rust";
|
|
130
|
+
if (m.pubspec) return "Dart";
|
|
131
|
+
if (m.composerJson) return "PHP";
|
|
132
|
+
if (m.buildGradle) return "Kotlin";
|
|
133
|
+
if (m.pomXml) return "Java";
|
|
134
|
+
if (m.packageSwift) return "Swift";
|
|
135
|
+
if (m.mixExs) return "Elixir";
|
|
136
|
+
if (m.csproj) return "C#";
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
function detectFramework(m) {
|
|
140
|
+
const deps = { ...m.pkgJson?.dependencies, ...m.pkgJson?.devDependencies };
|
|
141
|
+
if (deps.next) return "Next.js";
|
|
142
|
+
if (deps.nuxt) return "Nuxt";
|
|
143
|
+
if (deps.svelte || deps["@sveltejs/kit"]) return "SvelteKit";
|
|
144
|
+
if (deps.astro) return "Astro";
|
|
145
|
+
if (deps["@angular/core"]) return "Angular";
|
|
146
|
+
if (deps.remix || deps["@remix-run/react"]) return "Remix";
|
|
147
|
+
if (deps.vue) return "Vue";
|
|
148
|
+
if (deps.react && !deps.next) return "React";
|
|
149
|
+
if (deps.express) return "Express";
|
|
150
|
+
if (deps.fastify) return "Fastify";
|
|
151
|
+
if (deps.hono) return "Hono";
|
|
152
|
+
if (deps.nestjs || deps["@nestjs/core"]) return "NestJS";
|
|
153
|
+
if (m.pyProject) {
|
|
154
|
+
if (m.pyProject.includes("fastapi")) return "FastAPI";
|
|
155
|
+
if (m.pyProject.includes("django")) return "Django";
|
|
156
|
+
if (m.pyProject.includes("flask")) return "Flask";
|
|
157
|
+
}
|
|
158
|
+
if (m.composerJson) {
|
|
159
|
+
const phpDeps = { ...m.composerJson.require, ...m.composerJson["require-dev"] };
|
|
160
|
+
if (phpDeps["laravel/framework"]) return "Laravel";
|
|
161
|
+
if (phpDeps["symfony/framework-bundle"]) return "Symfony";
|
|
162
|
+
}
|
|
163
|
+
if (m.gemfile) return "Rails";
|
|
164
|
+
if (m.buildGradle) return "Gradle";
|
|
165
|
+
if (m.pomXml) return "Maven";
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
async function detectLockfiles(root) {
|
|
169
|
+
const [pnpmLock, yarnLock, bunLock, npmLock] = await Promise.all([
|
|
170
|
+
fileExists(join(root, "pnpm-lock.yaml")),
|
|
171
|
+
fileExists(join(root, "yarn.lock")),
|
|
172
|
+
fileExists(join(root, "bun.lockb")),
|
|
173
|
+
fileExists(join(root, "package-lock.json"))
|
|
174
|
+
]);
|
|
175
|
+
return { pnpmLock, yarnLock, bunLock, npmLock };
|
|
176
|
+
}
|
|
177
|
+
function detectPackageManager(m, lockfiles) {
|
|
178
|
+
if (m.pkgJson) {
|
|
179
|
+
const pm = m.pkgJson.packageManager;
|
|
180
|
+
if (pm?.startsWith("pnpm")) return "pnpm";
|
|
181
|
+
if (pm?.startsWith("yarn")) return "yarn";
|
|
182
|
+
if (pm?.startsWith("bun")) return "bun";
|
|
183
|
+
if (pm?.startsWith("npm")) return "npm";
|
|
184
|
+
if (lockfiles.pnpmLock) return "pnpm";
|
|
185
|
+
if (lockfiles.yarnLock) return "yarn";
|
|
186
|
+
if (lockfiles.bunLock) return "bun";
|
|
187
|
+
if (lockfiles.npmLock) return "npm";
|
|
188
|
+
return "npm";
|
|
189
|
+
}
|
|
190
|
+
if (m.goMod) return "go modules";
|
|
191
|
+
if (m.pyProject) {
|
|
192
|
+
if (m.pyProject.includes("[tool.uv]")) return "uv";
|
|
193
|
+
if (m.pyProject.includes("[tool.poetry]")) return "poetry";
|
|
194
|
+
return "pip";
|
|
195
|
+
}
|
|
196
|
+
if (m.gemfile) return "bundler";
|
|
197
|
+
if (m.cargo) return "cargo";
|
|
198
|
+
if (m.composerJson) return "composer";
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
function detectScripts(m) {
|
|
202
|
+
const scripts = m.pkgJson?.scripts ?? {};
|
|
203
|
+
if (m.pkgJson) {
|
|
204
|
+
return {
|
|
205
|
+
devCommand: scripts.dev ? `${pmRun(m.pkgJson)} dev` : null,
|
|
206
|
+
buildCommand: scripts.build ? `${pmRun(m.pkgJson)} build` : null,
|
|
207
|
+
testCommand: scripts.test ? `${pmRun(m.pkgJson)} test` : null,
|
|
208
|
+
lintCommand: scripts.lint ? `${pmRun(m.pkgJson)} lint` : null,
|
|
209
|
+
formatCommand: scripts.format ? `${pmRun(m.pkgJson)} format` : null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (m.language === "Go") {
|
|
213
|
+
return {
|
|
214
|
+
devCommand: "go run .",
|
|
215
|
+
buildCommand: "go build .",
|
|
216
|
+
testCommand: "go test ./...",
|
|
217
|
+
lintCommand: "golangci-lint run",
|
|
218
|
+
formatCommand: "gofmt -w ."
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (m.language === "Python") {
|
|
222
|
+
const runner = m.pyProject?.includes("[tool.uv]") ? "uv run" : "python -m";
|
|
223
|
+
return {
|
|
224
|
+
devCommand: null,
|
|
225
|
+
buildCommand: null,
|
|
226
|
+
testCommand: `${runner} pytest`,
|
|
227
|
+
lintCommand: `${runner} ruff check .`,
|
|
228
|
+
formatCommand: `${runner} ruff format .`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (m.gemfile) {
|
|
232
|
+
return {
|
|
233
|
+
devCommand: "bin/dev",
|
|
234
|
+
buildCommand: null,
|
|
235
|
+
testCommand: "bin/rails test",
|
|
236
|
+
lintCommand: "bin/rubocop",
|
|
237
|
+
formatCommand: null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (m.language === "PHP") {
|
|
241
|
+
return {
|
|
242
|
+
devCommand: "php artisan serve",
|
|
243
|
+
buildCommand: null,
|
|
244
|
+
testCommand: "php artisan test",
|
|
245
|
+
lintCommand: "vendor/bin/phpstan analyse",
|
|
246
|
+
formatCommand: "vendor/bin/pint"
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (m.language === "Rust") {
|
|
250
|
+
return {
|
|
251
|
+
devCommand: "cargo run",
|
|
252
|
+
buildCommand: "cargo build",
|
|
253
|
+
testCommand: "cargo test",
|
|
254
|
+
lintCommand: "cargo clippy",
|
|
255
|
+
formatCommand: "cargo fmt"
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (m.language === "Java" || m.language === "Kotlin") {
|
|
259
|
+
return {
|
|
260
|
+
devCommand: null,
|
|
261
|
+
buildCommand: "mvn package",
|
|
262
|
+
testCommand: "mvn test",
|
|
263
|
+
lintCommand: null,
|
|
264
|
+
formatCommand: null
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (m.language === "Swift") {
|
|
268
|
+
return {
|
|
269
|
+
devCommand: null,
|
|
270
|
+
buildCommand: "swift build",
|
|
271
|
+
testCommand: "swift test",
|
|
272
|
+
lintCommand: "swiftlint",
|
|
273
|
+
formatCommand: "swift-format format -r ."
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (m.language === "Elixir") {
|
|
277
|
+
return {
|
|
278
|
+
devCommand: "mix phx.server",
|
|
279
|
+
buildCommand: "mix compile",
|
|
280
|
+
testCommand: "mix test",
|
|
281
|
+
lintCommand: "mix credo",
|
|
282
|
+
formatCommand: "mix format"
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (m.language === "C#") {
|
|
286
|
+
return {
|
|
287
|
+
devCommand: "dotnet run",
|
|
288
|
+
buildCommand: "dotnet build",
|
|
289
|
+
testCommand: "dotnet test",
|
|
290
|
+
lintCommand: null,
|
|
291
|
+
formatCommand: "dotnet format"
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { devCommand: null, buildCommand: null, testCommand: null, lintCommand: null, formatCommand: null };
|
|
295
|
+
}
|
|
296
|
+
function pmRun(pkg) {
|
|
297
|
+
const pm = pkg.packageManager;
|
|
298
|
+
if (pm?.startsWith("pnpm")) return "pnpm";
|
|
299
|
+
if (pm?.startsWith("yarn")) return "yarn";
|
|
300
|
+
if (pm?.startsWith("bun")) return "bun";
|
|
301
|
+
return "npm run";
|
|
302
|
+
}
|
|
303
|
+
async function readJsonOrNull(path) {
|
|
304
|
+
try {
|
|
305
|
+
const content = await readFile(path, "utf-8");
|
|
306
|
+
return JSON.parse(content);
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function readFileOrNull(path) {
|
|
312
|
+
try {
|
|
313
|
+
return await readFile(path, "utf-8");
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function fileExists(path) {
|
|
319
|
+
try {
|
|
320
|
+
await access(path);
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function globExists(dir, pattern) {
|
|
327
|
+
const { readdir: readdir3 } = await import("fs/promises");
|
|
328
|
+
try {
|
|
329
|
+
const entries = await readdir3(dir);
|
|
330
|
+
return entries.some((e) => e.endsWith(pattern.replace("*", "")));
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/commands/init/generators/claude-md.ts
|
|
337
|
+
function generateClaudeMd(options, detected) {
|
|
338
|
+
const sections = [];
|
|
339
|
+
sections.push(`# ${options.name}`);
|
|
340
|
+
if (options.description) {
|
|
341
|
+
sections.push("", options.description);
|
|
342
|
+
}
|
|
343
|
+
sections.push("", "## Stack");
|
|
344
|
+
if (detected.language) {
|
|
345
|
+
const items = [];
|
|
346
|
+
if (detected.framework) items.push(`- **Framework**: ${detected.framework}`);
|
|
347
|
+
items.push(`- **Language**: ${detected.language}`);
|
|
348
|
+
if (detected.packageManager) items.push(`- **Package Manager**: ${detected.packageManager}`);
|
|
349
|
+
sections.push(items.join("\n"));
|
|
350
|
+
} else {
|
|
351
|
+
sections.push("<!-- TODO: Define your tech stack -->");
|
|
352
|
+
}
|
|
353
|
+
sections.push("", "## Commands");
|
|
354
|
+
const commands = [];
|
|
355
|
+
if (detected.devCommand) commands.push(`- Dev: \`${detected.devCommand}\``);
|
|
356
|
+
if (detected.buildCommand) commands.push(`- Build: \`${detected.buildCommand}\``);
|
|
357
|
+
if (detected.testCommand) commands.push(`- Test: \`${detected.testCommand}\``);
|
|
358
|
+
if (detected.lintCommand) commands.push(`- Lint: \`${detected.lintCommand}\``);
|
|
359
|
+
if (detected.formatCommand) commands.push(`- Format: \`${detected.formatCommand}\``);
|
|
360
|
+
if (commands.length > 0) {
|
|
361
|
+
sections.push(commands.join("\n"));
|
|
362
|
+
} else {
|
|
363
|
+
sections.push("<!-- TODO: Add your dev/build/test commands -->");
|
|
364
|
+
}
|
|
365
|
+
sections.push("", `## Session Start
|
|
366
|
+
- ALWAYS read @TASKS.md first \u2014 it tracks progress across sessions
|
|
367
|
+
- Check the Session Log at the bottom of TASKS.md for where we left off
|
|
368
|
+
- Update TASKS.md as you complete work`);
|
|
369
|
+
sections.push("", `## Conventions
|
|
370
|
+
- Git: Conventional commits (\`feat:\`, \`fix:\`, \`docs:\`, \`refactor:\`, \`test:\`, \`chore:\`)`);
|
|
371
|
+
sections.push("", `## Off-Limits
|
|
372
|
+
- Never hardcode secrets \u2014 use environment variables
|
|
373
|
+
- Never write to \`.env\` files
|
|
374
|
+
- Never expose internal error details in API responses`);
|
|
375
|
+
sections.push("", `## Key Decisions
|
|
376
|
+
<!-- Record architectural decisions as you make them -->`);
|
|
377
|
+
return sections.join("\n") + "\n";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/commands/init/generators/tasks-md.ts
|
|
381
|
+
function generateTasksMd(options) {
|
|
382
|
+
return `# ${options.name} \u2014 Task Tracker
|
|
383
|
+
|
|
384
|
+
> Claude: Read this at session start. Keep this file SHORT \u2014 only current state matters.
|
|
385
|
+
> Rules: (1) Only show current + next sprint tasks. (2) Completed sprints get one summary line. (3) Session log: max 3 lines per session, keep only last 3 sessions. (4) Target: under 80 lines total.
|
|
386
|
+
|
|
387
|
+
## Completed Sprints
|
|
388
|
+
|
|
389
|
+
## Current Sprint: Sprint 1 \u2014 Setup
|
|
390
|
+
|
|
391
|
+
### In Progress
|
|
392
|
+
|
|
393
|
+
### To Do
|
|
394
|
+
- [ ] Project scaffolding and environment setup
|
|
395
|
+
- [ ] Core feature implementation
|
|
396
|
+
- [ ] Test infrastructure
|
|
397
|
+
|
|
398
|
+
### Done
|
|
399
|
+
|
|
400
|
+
## Next Sprint: Sprint 2 \u2014 Core Features
|
|
401
|
+
- [ ] ...
|
|
402
|
+
|
|
403
|
+
## Session Log
|
|
404
|
+
<!-- Keep last 3 sessions only. Max 3 lines each. -->
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/commands/init/generators/settings.ts
|
|
409
|
+
function generateSettings(detected) {
|
|
410
|
+
const preToolUse = [];
|
|
411
|
+
const postToolUse = [];
|
|
412
|
+
preToolUse.push({
|
|
413
|
+
matcher: "Read|Write|Edit",
|
|
414
|
+
hooks: [{
|
|
415
|
+
type: "command",
|
|
416
|
+
command: `echo "$TOOL_INPUT_FILE_PATH" | grep -qE '\\.(env|env\\..*)$' && ! echo "$TOOL_INPUT_FILE_PATH" | grep -q '.env.example' && echo 'BLOCKED: .env files contain secrets \u2014 use .env.example for documentation' && exit 1; exit 0`
|
|
417
|
+
}]
|
|
418
|
+
});
|
|
419
|
+
const formatHook = buildFormatHook(detected);
|
|
420
|
+
if (formatHook) {
|
|
421
|
+
postToolUse.push(formatHook);
|
|
422
|
+
}
|
|
423
|
+
const hooks = {};
|
|
424
|
+
if (preToolUse.length > 0) hooks.PreToolUse = preToolUse;
|
|
425
|
+
if (postToolUse.length > 0) hooks.PostToolUse = postToolUse;
|
|
426
|
+
return Object.keys(hooks).length > 0 ? { hooks } : {};
|
|
427
|
+
}
|
|
428
|
+
function buildFormatHook(detected) {
|
|
429
|
+
if (!detected.language) return null;
|
|
430
|
+
const formatters = {
|
|
431
|
+
TypeScript: {
|
|
432
|
+
extensions: ["ts", "tsx"],
|
|
433
|
+
command: detected.formatCommand ?? "npx prettier --write"
|
|
434
|
+
},
|
|
435
|
+
JavaScript: {
|
|
436
|
+
extensions: ["js", "jsx"],
|
|
437
|
+
command: detected.formatCommand ?? "npx prettier --write"
|
|
438
|
+
},
|
|
439
|
+
Python: {
|
|
440
|
+
extensions: ["py"],
|
|
441
|
+
command: detected.formatCommand ?? "ruff format"
|
|
442
|
+
},
|
|
443
|
+
Go: {
|
|
444
|
+
extensions: ["go"],
|
|
445
|
+
command: "gofmt -w"
|
|
446
|
+
},
|
|
447
|
+
Rust: {
|
|
448
|
+
extensions: ["rs"],
|
|
449
|
+
command: "rustfmt"
|
|
450
|
+
},
|
|
451
|
+
Ruby: {
|
|
452
|
+
extensions: ["rb"],
|
|
453
|
+
command: "rubocop -A"
|
|
454
|
+
},
|
|
455
|
+
Dart: {
|
|
456
|
+
extensions: ["dart"],
|
|
457
|
+
command: "dart format"
|
|
458
|
+
},
|
|
459
|
+
PHP: {
|
|
460
|
+
extensions: ["php"],
|
|
461
|
+
command: detected.formatCommand ?? "vendor/bin/pint"
|
|
462
|
+
},
|
|
463
|
+
Kotlin: {
|
|
464
|
+
extensions: ["kt", "kts"],
|
|
465
|
+
command: "ktlint -F"
|
|
466
|
+
},
|
|
467
|
+
Java: {
|
|
468
|
+
extensions: ["java"],
|
|
469
|
+
command: "google-java-format -i"
|
|
470
|
+
},
|
|
471
|
+
Swift: {
|
|
472
|
+
extensions: ["swift"],
|
|
473
|
+
command: "swift-format format -i"
|
|
474
|
+
},
|
|
475
|
+
Elixir: {
|
|
476
|
+
extensions: ["ex", "exs"],
|
|
477
|
+
command: "mix format"
|
|
478
|
+
},
|
|
479
|
+
"C#": {
|
|
480
|
+
extensions: ["cs"],
|
|
481
|
+
command: "dotnet format"
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const config = formatters[detected.language];
|
|
485
|
+
if (!config) return null;
|
|
486
|
+
const extChecks = config.extensions.map((ext) => `[ "$ext" = "${ext}" ]`).join(" || ");
|
|
487
|
+
return {
|
|
488
|
+
matcher: "Write|Edit",
|
|
489
|
+
hooks: [{
|
|
490
|
+
type: "command",
|
|
491
|
+
command: `ext=\${TOOL_INPUT_FILE_PATH##*.}; (${extChecks}) && ${config.command} "$TOOL_INPUT_FILE_PATH" 2>/dev/null; exit 0`
|
|
492
|
+
}]
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/commands/init/generators/claudeignore.ts
|
|
497
|
+
function generateClaudeignore(detected) {
|
|
498
|
+
const sections = ["# Generated by claude-launchpad"];
|
|
499
|
+
sections.push(`
|
|
500
|
+
# Dependencies
|
|
501
|
+
node_modules/
|
|
502
|
+
.pnp/
|
|
503
|
+
.yarn/
|
|
504
|
+
|
|
505
|
+
# Build output
|
|
506
|
+
dist/
|
|
507
|
+
build/
|
|
508
|
+
out/
|
|
509
|
+
.next/
|
|
510
|
+
.nuxt/
|
|
511
|
+
.output/
|
|
512
|
+
.svelte-kit/
|
|
513
|
+
.vercel/
|
|
514
|
+
.turbo/
|
|
515
|
+
|
|
516
|
+
# Package manager
|
|
517
|
+
pnpm-lock.yaml
|
|
518
|
+
package-lock.json
|
|
519
|
+
yarn.lock
|
|
520
|
+
bun.lockb
|
|
521
|
+
|
|
522
|
+
# IDE & OS
|
|
523
|
+
.vscode/
|
|
524
|
+
.idea/
|
|
525
|
+
*.swp
|
|
526
|
+
*.swo
|
|
527
|
+
.DS_Store
|
|
528
|
+
Thumbs.db
|
|
529
|
+
|
|
530
|
+
# Test & coverage
|
|
531
|
+
coverage/
|
|
532
|
+
.nyc_output/
|
|
533
|
+
__snapshots__/
|
|
534
|
+
|
|
535
|
+
# Environment (should never be read)
|
|
536
|
+
.env
|
|
537
|
+
.env.*
|
|
538
|
+
!.env.example`);
|
|
539
|
+
const lang = detected.language;
|
|
540
|
+
if (lang === "Python") {
|
|
541
|
+
sections.push(`
|
|
542
|
+
# Python
|
|
543
|
+
__pycache__/
|
|
544
|
+
*.pyc
|
|
545
|
+
*.pyo
|
|
546
|
+
.venv/
|
|
547
|
+
venv/
|
|
548
|
+
.mypy_cache/
|
|
549
|
+
.ruff_cache/
|
|
550
|
+
.pytest_cache/
|
|
551
|
+
*.egg-info/`);
|
|
552
|
+
}
|
|
553
|
+
if (lang === "Go") {
|
|
554
|
+
sections.push(`
|
|
555
|
+
# Go
|
|
556
|
+
bin/
|
|
557
|
+
vendor/`);
|
|
558
|
+
}
|
|
559
|
+
if (lang === "Rust") {
|
|
560
|
+
sections.push(`
|
|
561
|
+
# Rust
|
|
562
|
+
target/
|
|
563
|
+
Cargo.lock`);
|
|
564
|
+
}
|
|
565
|
+
if (lang === "Ruby") {
|
|
566
|
+
sections.push(`
|
|
567
|
+
# Ruby
|
|
568
|
+
vendor/bundle/
|
|
569
|
+
.bundle/
|
|
570
|
+
tmp/
|
|
571
|
+
log/`);
|
|
572
|
+
}
|
|
573
|
+
if (lang === "Java" || lang === "Kotlin") {
|
|
574
|
+
sections.push(`
|
|
575
|
+
# JVM
|
|
576
|
+
target/
|
|
577
|
+
build/
|
|
578
|
+
.gradle/
|
|
579
|
+
*.class
|
|
580
|
+
*.jar`);
|
|
581
|
+
}
|
|
582
|
+
if (lang === "Dart") {
|
|
583
|
+
sections.push(`
|
|
584
|
+
# Dart/Flutter
|
|
585
|
+
.dart_tool/
|
|
586
|
+
.packages
|
|
587
|
+
build/`);
|
|
588
|
+
}
|
|
589
|
+
if (lang === "PHP") {
|
|
590
|
+
sections.push(`
|
|
591
|
+
# PHP
|
|
592
|
+
vendor/
|
|
593
|
+
composer.lock`);
|
|
594
|
+
}
|
|
595
|
+
if (lang === "C#") {
|
|
596
|
+
sections.push(`
|
|
597
|
+
# .NET
|
|
598
|
+
bin/
|
|
599
|
+
obj/
|
|
600
|
+
*.dll`);
|
|
601
|
+
}
|
|
602
|
+
if (lang === "Elixir") {
|
|
603
|
+
sections.push(`
|
|
604
|
+
# Elixir
|
|
605
|
+
_build/
|
|
606
|
+
deps/
|
|
607
|
+
.elixir_ls/`);
|
|
608
|
+
}
|
|
609
|
+
if (lang === "Swift") {
|
|
610
|
+
sections.push(`
|
|
611
|
+
# Swift
|
|
612
|
+
.build/
|
|
613
|
+
DerivedData/
|
|
614
|
+
*.xcuserdata`);
|
|
615
|
+
}
|
|
616
|
+
return sections.join("\n") + "\n";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/commands/init/index.ts
|
|
620
|
+
function createInitCommand() {
|
|
621
|
+
return new Command("init").description("Set up Claude Code configuration for any project").option("-n, --name <name>", "Project name").option("-y, --yes", "Accept all defaults").action(async (opts) => {
|
|
622
|
+
printBanner();
|
|
623
|
+
const root = process.cwd();
|
|
624
|
+
log.step("Detecting project...");
|
|
625
|
+
const detected = await detectProject(root);
|
|
626
|
+
if (detected.language) {
|
|
627
|
+
log.success(`Found ${detected.framework ?? detected.language} project`);
|
|
628
|
+
if (detected.packageManager) log.info(`Package manager: ${detected.packageManager}`);
|
|
629
|
+
if (detected.devCommand) log.info(`Dev command: ${detected.devCommand}`);
|
|
630
|
+
if (detected.testCommand) log.info(`Test command: ${detected.testCommand}`);
|
|
631
|
+
} else {
|
|
632
|
+
log.warn("Could not detect project type \u2014 generating minimal config");
|
|
633
|
+
}
|
|
634
|
+
log.blank();
|
|
635
|
+
const name = opts.name ?? detected.name ?? await input({
|
|
636
|
+
message: "Project name:",
|
|
637
|
+
validate: (v) => v.trim().length > 0 ? true : "Name cannot be empty"
|
|
638
|
+
});
|
|
639
|
+
const description = opts.yes ? "" : await input({
|
|
640
|
+
message: "One-line description (optional):"
|
|
641
|
+
});
|
|
642
|
+
const options = { name: name.trim(), description: description.trim() };
|
|
643
|
+
const hasClaudeMd = await fileExists2(join2(root, "CLAUDE.md"));
|
|
644
|
+
if (hasClaudeMd && !opts.yes) {
|
|
645
|
+
const overwrite = await confirm({
|
|
646
|
+
message: "CLAUDE.md already exists. Overwrite?",
|
|
647
|
+
default: false
|
|
648
|
+
});
|
|
649
|
+
if (!overwrite) {
|
|
650
|
+
log.info("Keeping existing CLAUDE.md");
|
|
651
|
+
log.step("Tip: run `claude-launchpad doctor` to check your existing config");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
await scaffold(root, options, detected);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
async function scaffold(root, options, detected) {
|
|
659
|
+
log.step("Generating configuration...");
|
|
660
|
+
const claudeMd = generateClaudeMd(options, detected);
|
|
661
|
+
const tasksMd = generateTasksMd(options);
|
|
662
|
+
const settings = generateSettings(detected);
|
|
663
|
+
const claudeignore = generateClaudeignore(detected);
|
|
664
|
+
await mkdir(join2(root, ".claude"), { recursive: true });
|
|
665
|
+
const settingsPath = join2(root, ".claude", "settings.json");
|
|
666
|
+
const mergedSettings = await mergeSettings(settingsPath, settings);
|
|
667
|
+
const claudeignorePath = join2(root, ".claudeignore");
|
|
668
|
+
const hasClaudeignore = await fileExists2(claudeignorePath);
|
|
669
|
+
const writes = [
|
|
670
|
+
writeFile(join2(root, "CLAUDE.md"), claudeMd),
|
|
671
|
+
writeFile(join2(root, "TASKS.md"), tasksMd),
|
|
672
|
+
writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2) + "\n")
|
|
673
|
+
];
|
|
674
|
+
if (!hasClaudeignore) {
|
|
675
|
+
writes.push(writeFile(claudeignorePath, claudeignore));
|
|
676
|
+
}
|
|
677
|
+
await Promise.all(writes);
|
|
678
|
+
log.success("Generated CLAUDE.md");
|
|
679
|
+
log.success("Generated TASKS.md");
|
|
680
|
+
log.success("Generated .claude/settings.json (merged with existing)");
|
|
681
|
+
if (!hasClaudeignore) {
|
|
682
|
+
log.success("Generated .claudeignore");
|
|
683
|
+
}
|
|
684
|
+
log.blank();
|
|
685
|
+
log.success("Done! Run `claude` to start.");
|
|
686
|
+
log.info("Run `claude-launchpad doctor` to check your config quality.");
|
|
687
|
+
log.blank();
|
|
688
|
+
}
|
|
689
|
+
async function fileExists2(path) {
|
|
690
|
+
try {
|
|
691
|
+
await readFile2(path);
|
|
692
|
+
return true;
|
|
693
|
+
} catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async function mergeSettings(existingPath, generated) {
|
|
698
|
+
try {
|
|
699
|
+
const existing = JSON.parse(await readFile2(existingPath, "utf-8"));
|
|
700
|
+
const existingHooks = existing.hooks ?? {};
|
|
701
|
+
const generatedHooks = generated.hooks ?? {};
|
|
702
|
+
const mergedHooks = { ...existingHooks };
|
|
703
|
+
for (const [event, hookList] of Object.entries(generatedHooks)) {
|
|
704
|
+
if (!mergedHooks[event]) {
|
|
705
|
+
mergedHooks[event] = hookList;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
...existing,
|
|
710
|
+
...generated,
|
|
711
|
+
hooks: Object.keys(mergedHooks).length > 0 ? mergedHooks : void 0
|
|
712
|
+
};
|
|
713
|
+
} catch {
|
|
714
|
+
return generated;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/commands/doctor/index.ts
|
|
719
|
+
import { Command as Command2 } from "commander";
|
|
720
|
+
|
|
721
|
+
// src/lib/parser.ts
|
|
722
|
+
import { readFile as readFile3, readdir, access as access2 } from "fs/promises";
|
|
723
|
+
import { join as join3, resolve } from "path";
|
|
724
|
+
var CLAUDE_MD = "CLAUDE.md";
|
|
725
|
+
var CLAUDE_DIR = ".claude";
|
|
726
|
+
var SETTINGS_FILE = "settings.json";
|
|
727
|
+
var RULES_DIR = "rules";
|
|
728
|
+
async function parseClaudeConfig(projectRoot) {
|
|
729
|
+
const root = resolve(projectRoot);
|
|
730
|
+
const claudeDir = join3(root, CLAUDE_DIR);
|
|
731
|
+
const [claudeMd, settings, hooks, rules, mcpServers, skills] = await Promise.all([
|
|
732
|
+
readClaudeMd(root),
|
|
733
|
+
readSettings(claudeDir),
|
|
734
|
+
readHooks(claudeDir),
|
|
735
|
+
readRules(claudeDir),
|
|
736
|
+
readMcpServers(claudeDir),
|
|
737
|
+
readSkills(claudeDir)
|
|
738
|
+
]);
|
|
739
|
+
const instructionCount = claudeMd ? countInstructions(claudeMd) : 0;
|
|
740
|
+
return {
|
|
741
|
+
claudeMdPath: claudeMd !== null ? join3(root, CLAUDE_MD) : null,
|
|
742
|
+
claudeMdContent: claudeMd,
|
|
743
|
+
claudeMdInstructionCount: instructionCount,
|
|
744
|
+
settingsPath: settings !== null ? join3(claudeDir, SETTINGS_FILE) : null,
|
|
745
|
+
settings,
|
|
746
|
+
hooks,
|
|
747
|
+
rules,
|
|
748
|
+
mcpServers,
|
|
749
|
+
skills
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
async function readClaudeMd(root) {
|
|
753
|
+
return readFileOrNull2(join3(root, CLAUDE_MD));
|
|
754
|
+
}
|
|
755
|
+
function countInstructions(content) {
|
|
756
|
+
const lines = content.split("\n");
|
|
757
|
+
let count = 0;
|
|
758
|
+
for (const line of lines) {
|
|
759
|
+
const trimmed = line.trim();
|
|
760
|
+
if (trimmed === "") continue;
|
|
761
|
+
if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) continue;
|
|
762
|
+
if (trimmed.startsWith("```")) continue;
|
|
763
|
+
if (/^#{1,6}\s+\S/.test(trimmed)) continue;
|
|
764
|
+
count++;
|
|
765
|
+
}
|
|
766
|
+
return count;
|
|
767
|
+
}
|
|
768
|
+
async function readSettings(claudeDir) {
|
|
769
|
+
const raw = await readFileOrNull2(join3(claudeDir, SETTINGS_FILE));
|
|
770
|
+
if (raw === null) return null;
|
|
771
|
+
try {
|
|
772
|
+
return JSON.parse(raw);
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async function readHooks(claudeDir) {
|
|
778
|
+
const settingsRaw = await readFileOrNull2(join3(claudeDir, SETTINGS_FILE));
|
|
779
|
+
if (settingsRaw === null) return [];
|
|
780
|
+
try {
|
|
781
|
+
const settings = JSON.parse(settingsRaw);
|
|
782
|
+
const hooks = settings.hooks;
|
|
783
|
+
if (!hooks || typeof hooks !== "object") return [];
|
|
784
|
+
const result = [];
|
|
785
|
+
for (const [event, hookList] of Object.entries(hooks)) {
|
|
786
|
+
if (!Array.isArray(hookList)) continue;
|
|
787
|
+
for (const group of hookList) {
|
|
788
|
+
const g = group;
|
|
789
|
+
const matcher = g.matcher;
|
|
790
|
+
const nestedHooks = g.hooks;
|
|
791
|
+
if (Array.isArray(nestedHooks)) {
|
|
792
|
+
for (const hook of nestedHooks) {
|
|
793
|
+
const h = hook;
|
|
794
|
+
result.push({
|
|
795
|
+
event,
|
|
796
|
+
type: h.type ?? "command",
|
|
797
|
+
matcher,
|
|
798
|
+
command: h.command
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
result.push({
|
|
803
|
+
event,
|
|
804
|
+
type: g.type ?? "command",
|
|
805
|
+
matcher,
|
|
806
|
+
command: g.command
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return result;
|
|
812
|
+
} catch {
|
|
813
|
+
return [];
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function readRules(claudeDir) {
|
|
817
|
+
const rulesDir = join3(claudeDir, RULES_DIR);
|
|
818
|
+
return listFilesRecursive(rulesDir, ".md");
|
|
819
|
+
}
|
|
820
|
+
async function readMcpServers(claudeDir) {
|
|
821
|
+
const settingsRaw = await readFileOrNull2(join3(claudeDir, SETTINGS_FILE));
|
|
822
|
+
if (settingsRaw === null) return [];
|
|
823
|
+
try {
|
|
824
|
+
const settings = JSON.parse(settingsRaw);
|
|
825
|
+
const servers = settings.mcpServers;
|
|
826
|
+
if (!servers || typeof servers !== "object") return [];
|
|
827
|
+
const result = [];
|
|
828
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
829
|
+
const c = config;
|
|
830
|
+
result.push({
|
|
831
|
+
name,
|
|
832
|
+
transport: c.transport ?? "stdio",
|
|
833
|
+
command: c.command,
|
|
834
|
+
url: c.url
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return result;
|
|
838
|
+
} catch {
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function readSkills(claudeDir) {
|
|
843
|
+
const commandsDir = join3(claudeDir, "commands");
|
|
844
|
+
const skillsDir = join3(claudeDir, "skills");
|
|
845
|
+
const [commands, skills] = await Promise.all([
|
|
846
|
+
listFilesRecursive(commandsDir, ".md"),
|
|
847
|
+
listFilesRecursive(skillsDir, ".md")
|
|
848
|
+
]);
|
|
849
|
+
return [...commands, ...skills];
|
|
850
|
+
}
|
|
851
|
+
async function readFileOrNull2(path) {
|
|
852
|
+
try {
|
|
853
|
+
return await readFile3(path, "utf-8");
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
async function listFilesRecursive(dir, ext) {
|
|
859
|
+
try {
|
|
860
|
+
await access2(dir);
|
|
861
|
+
} catch {
|
|
862
|
+
return [];
|
|
863
|
+
}
|
|
864
|
+
const results = [];
|
|
865
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
866
|
+
for (const entry of entries) {
|
|
867
|
+
const fullPath = join3(dir, entry.name);
|
|
868
|
+
if (entry.isDirectory()) {
|
|
869
|
+
const nested = await listFilesRecursive(fullPath, ext);
|
|
870
|
+
results.push(...nested);
|
|
871
|
+
} else if (entry.name.endsWith(ext)) {
|
|
872
|
+
results.push(fullPath);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return results;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/commands/doctor/analyzers/budget.ts
|
|
879
|
+
var BUDGET_WARN = 120;
|
|
880
|
+
var BUDGET_DANGER = 150;
|
|
881
|
+
var BUDGET_CRITICAL = 200;
|
|
882
|
+
async function analyzeBudget(config) {
|
|
883
|
+
const issues = [];
|
|
884
|
+
const count = config.claudeMdInstructionCount;
|
|
885
|
+
if (config.claudeMdContent === null) {
|
|
886
|
+
issues.push({
|
|
887
|
+
analyzer: "Budget",
|
|
888
|
+
severity: "high",
|
|
889
|
+
message: "No CLAUDE.md found",
|
|
890
|
+
fix: "Run `claude-launchpad init` or create CLAUDE.md manually"
|
|
891
|
+
});
|
|
892
|
+
return { name: "Instruction Budget", issues, score: 0 };
|
|
893
|
+
}
|
|
894
|
+
if (count === 0) {
|
|
895
|
+
issues.push({
|
|
896
|
+
analyzer: "Budget",
|
|
897
|
+
severity: "medium",
|
|
898
|
+
message: "CLAUDE.md exists but has no actionable instructions",
|
|
899
|
+
fix: "Add project-specific instructions to CLAUDE.md"
|
|
900
|
+
});
|
|
901
|
+
return { name: "Instruction Budget", issues, score: 30 };
|
|
902
|
+
}
|
|
903
|
+
if (count > BUDGET_CRITICAL) {
|
|
904
|
+
issues.push({
|
|
905
|
+
analyzer: "Budget",
|
|
906
|
+
severity: "critical",
|
|
907
|
+
message: `${count} instructions \u2014 way over the ~150 budget. Compliance drops significantly past 150.`,
|
|
908
|
+
fix: "Move detailed rules to .claude/rules/*.md files. Keep CLAUDE.md to essential project identity."
|
|
909
|
+
});
|
|
910
|
+
} else if (count > BUDGET_DANGER) {
|
|
911
|
+
issues.push({
|
|
912
|
+
analyzer: "Budget",
|
|
913
|
+
severity: "high",
|
|
914
|
+
message: `${count} instructions \u2014 over the ~150 budget. Claude may start ignoring lower-priority rules.`,
|
|
915
|
+
fix: "Move verbose sections (conventions, off-limits details) to .claude/rules/ files."
|
|
916
|
+
});
|
|
917
|
+
} else if (count > BUDGET_WARN) {
|
|
918
|
+
issues.push({
|
|
919
|
+
analyzer: "Budget",
|
|
920
|
+
severity: "medium",
|
|
921
|
+
message: `${count} instructions \u2014 approaching the ~150 budget.`,
|
|
922
|
+
fix: "Consider moving some rules to .claude/rules/ to leave headroom."
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
let score;
|
|
926
|
+
if (count <= BUDGET_WARN) {
|
|
927
|
+
score = 100;
|
|
928
|
+
} else if (count <= BUDGET_DANGER) {
|
|
929
|
+
score = 100 - Math.round((count - BUDGET_WARN) / (BUDGET_DANGER - BUDGET_WARN) * 30);
|
|
930
|
+
} else if (count <= BUDGET_CRITICAL) {
|
|
931
|
+
score = 70 - Math.round((count - BUDGET_DANGER) / (BUDGET_CRITICAL - BUDGET_DANGER) * 40);
|
|
932
|
+
} else {
|
|
933
|
+
score = Math.max(0, 30 - Math.round((count - BUDGET_CRITICAL) / 5));
|
|
934
|
+
}
|
|
935
|
+
return { name: "Instruction Budget", issues, score };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/commands/doctor/analyzers/settings.ts
|
|
939
|
+
async function analyzeSettings(config) {
|
|
940
|
+
const issues = [];
|
|
941
|
+
if (config.settings === null) {
|
|
942
|
+
issues.push({
|
|
943
|
+
analyzer: "Settings",
|
|
944
|
+
severity: "medium",
|
|
945
|
+
message: "No .claude/settings.json found",
|
|
946
|
+
fix: "Run `claude-launchpad init` or create .claude/settings.json"
|
|
947
|
+
});
|
|
948
|
+
return { name: "Settings", issues, score: 40 };
|
|
949
|
+
}
|
|
950
|
+
const plugins = config.settings.enabledPlugins;
|
|
951
|
+
if (!plugins || Object.keys(plugins).length === 0) {
|
|
952
|
+
issues.push({
|
|
953
|
+
analyzer: "Settings",
|
|
954
|
+
severity: "low",
|
|
955
|
+
message: "No plugins enabled",
|
|
956
|
+
fix: "Consider enabling plugins for enhanced capabilities"
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
const permissions = config.settings.permissions;
|
|
960
|
+
if (!permissions) {
|
|
961
|
+
issues.push({
|
|
962
|
+
analyzer: "Settings",
|
|
963
|
+
severity: "low",
|
|
964
|
+
message: "No permission rules configured",
|
|
965
|
+
fix: "Add permission rules to control which tools Claude can use automatically"
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
const env = config.settings.env;
|
|
969
|
+
if (!env) {
|
|
970
|
+
issues.push({
|
|
971
|
+
analyzer: "Settings",
|
|
972
|
+
severity: "info",
|
|
973
|
+
message: "No environment variables defined in settings",
|
|
974
|
+
fix: "Define env vars in settings.json for consistent development environments"
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const score = Math.max(0, 100 - issues.length * 15);
|
|
978
|
+
return { name: "Settings", issues, score };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/commands/doctor/analyzers/hooks.ts
|
|
982
|
+
async function analyzeHooks(config) {
|
|
983
|
+
const issues = [];
|
|
984
|
+
const hooks = config.hooks;
|
|
985
|
+
if (hooks.length === 0) {
|
|
986
|
+
issues.push({
|
|
987
|
+
analyzer: "Hooks",
|
|
988
|
+
severity: "medium",
|
|
989
|
+
message: "No hooks configured \u2014 CLAUDE.md rules are advisory (~80% compliance), hooks are 100%",
|
|
990
|
+
fix: "Add PostToolUse hooks for auto-formatting and PreToolUse for security gates"
|
|
991
|
+
});
|
|
992
|
+
return { name: "Hooks", issues, score: 30 };
|
|
993
|
+
}
|
|
994
|
+
const formatPatterns = ["format", "prettier", "gofmt", "rustfmt", "rubocop", "pint", "ktlint", "swift-format", "dotnet format"];
|
|
995
|
+
const hasPostFormat = hooks.some(
|
|
996
|
+
(h) => h.event === "PostToolUse" && h.matcher?.includes("Write") && formatPatterns.some((p) => h.command?.includes(p))
|
|
997
|
+
);
|
|
998
|
+
if (!hasPostFormat) {
|
|
999
|
+
issues.push({
|
|
1000
|
+
analyzer: "Hooks",
|
|
1001
|
+
severity: "low",
|
|
1002
|
+
message: "No auto-format hook found",
|
|
1003
|
+
fix: "Add a PostToolUse hook that runs your formatter on Write|Edit"
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const hasEnvProtection = hooks.some(
|
|
1007
|
+
(h) => h.event === "PreToolUse" && h.command?.includes(".env")
|
|
1008
|
+
);
|
|
1009
|
+
if (!hasEnvProtection) {
|
|
1010
|
+
issues.push({
|
|
1011
|
+
analyzer: "Hooks",
|
|
1012
|
+
severity: "medium",
|
|
1013
|
+
message: "No .env file protection hook \u2014 Claude could read or write secrets in .env files",
|
|
1014
|
+
fix: "Add a PreToolUse hook on Read|Write|Edit that blocks access to .env files"
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
const hasPreToolUse = hooks.some((h) => h.event === "PreToolUse");
|
|
1018
|
+
if (!hasPreToolUse) {
|
|
1019
|
+
issues.push({
|
|
1020
|
+
analyzer: "Hooks",
|
|
1021
|
+
severity: "medium",
|
|
1022
|
+
message: "No PreToolUse hooks \u2014 this is your security enforcement layer",
|
|
1023
|
+
fix: "Add PreToolUse hooks for file protection and dangerous command blocking"
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const score = Math.max(0, 100 - issues.length * 20);
|
|
1027
|
+
return { name: "Hooks", issues, score };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/commands/doctor/analyzers/rules.ts
|
|
1031
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1032
|
+
import { basename as basename2 } from "path";
|
|
1033
|
+
async function analyzeRules(config) {
|
|
1034
|
+
const issues = [];
|
|
1035
|
+
if (config.rules.length === 0) {
|
|
1036
|
+
issues.push({
|
|
1037
|
+
analyzer: "Rules",
|
|
1038
|
+
severity: "low",
|
|
1039
|
+
message: "No .claude/rules/ files found",
|
|
1040
|
+
fix: "Move detailed conventions from CLAUDE.md to .claude/rules/*.md (auto-loaded, saves budget)"
|
|
1041
|
+
});
|
|
1042
|
+
return { name: "Rules", issues, score: 60 };
|
|
1043
|
+
}
|
|
1044
|
+
for (const rulePath of config.rules) {
|
|
1045
|
+
try {
|
|
1046
|
+
const content = await readFile4(rulePath, "utf-8");
|
|
1047
|
+
const trimmed = content.trim();
|
|
1048
|
+
if (trimmed.length === 0) {
|
|
1049
|
+
issues.push({
|
|
1050
|
+
analyzer: "Rules",
|
|
1051
|
+
severity: "low",
|
|
1052
|
+
message: `Empty rule file: ${basename2(rulePath)}`,
|
|
1053
|
+
fix: `Add content to ${basename2(rulePath)} or delete it`
|
|
1054
|
+
});
|
|
1055
|
+
} else if (trimmed.length < 20) {
|
|
1056
|
+
issues.push({
|
|
1057
|
+
analyzer: "Rules",
|
|
1058
|
+
severity: "info",
|
|
1059
|
+
message: `Very short rule file (${trimmed.length} chars): ${basename2(rulePath)}`
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
issues.push({
|
|
1064
|
+
analyzer: "Rules",
|
|
1065
|
+
severity: "low",
|
|
1066
|
+
message: `Could not read rule file: ${basename2(rulePath)}`
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const score = Math.max(0, 100 - issues.length * 10);
|
|
1071
|
+
return { name: "Rules", issues, score };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/commands/doctor/analyzers/permissions.ts
|
|
1075
|
+
async function analyzePermissions(config) {
|
|
1076
|
+
const issues = [];
|
|
1077
|
+
const hasBashSecurity = config.hooks.some(
|
|
1078
|
+
(h) => h.event === "PreToolUse" && (h.matcher?.includes("Bash") || !h.matcher)
|
|
1079
|
+
);
|
|
1080
|
+
const bashAllowed = config.settings?.allowedTools;
|
|
1081
|
+
const hasBashAutoAllow = bashAllowed?.some(
|
|
1082
|
+
(t) => typeof t === "string" && t.toLowerCase().includes("bash")
|
|
1083
|
+
);
|
|
1084
|
+
if (hasBashAutoAllow && !hasBashSecurity) {
|
|
1085
|
+
issues.push({
|
|
1086
|
+
analyzer: "Permissions",
|
|
1087
|
+
severity: "high",
|
|
1088
|
+
message: "Bash is auto-allowed without a security hook \u2014 dangerous commands could run unchecked",
|
|
1089
|
+
fix: "Add a PreToolUse hook for Bash that blocks destructive commands (rm -rf, git push --force)"
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
const hasForceProtection = config.hooks.some(
|
|
1093
|
+
(h) => h.event === "PreToolUse" && h.command?.includes("force")
|
|
1094
|
+
);
|
|
1095
|
+
if (!hasForceProtection) {
|
|
1096
|
+
issues.push({
|
|
1097
|
+
analyzer: "Permissions",
|
|
1098
|
+
severity: "low",
|
|
1099
|
+
message: "No force-push protection hook",
|
|
1100
|
+
fix: "Add a PreToolUse hook that warns on `git push --force` commands"
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
if (config.claudeMdContent) {
|
|
1104
|
+
const hasOffLimits = config.claudeMdContent.includes("## Off-Limits") || config.claudeMdContent.includes("## off-limits");
|
|
1105
|
+
if (!hasOffLimits) {
|
|
1106
|
+
issues.push({
|
|
1107
|
+
analyzer: "Permissions",
|
|
1108
|
+
severity: "medium",
|
|
1109
|
+
message: "No Off-Limits section in CLAUDE.md \u2014 Claude has no guardrails beyond defaults",
|
|
1110
|
+
fix: "Add an ## Off-Limits section with project-specific restrictions"
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const score = Math.max(0, 100 - issues.length * 20);
|
|
1115
|
+
return { name: "Permissions", issues, score };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/commands/doctor/analyzers/mcp.ts
|
|
1119
|
+
import { access as access3 } from "fs/promises";
|
|
1120
|
+
async function analyzeMcp(config) {
|
|
1121
|
+
const issues = [];
|
|
1122
|
+
const servers = config.mcpServers;
|
|
1123
|
+
if (servers.length === 0) {
|
|
1124
|
+
issues.push({
|
|
1125
|
+
analyzer: "MCP",
|
|
1126
|
+
severity: "info",
|
|
1127
|
+
message: "No MCP servers configured \u2014 Claude can't connect to external tools",
|
|
1128
|
+
fix: "Add MCP servers in .claude/settings.json for GitHub, database, docs, etc."
|
|
1129
|
+
});
|
|
1130
|
+
return { name: "MCP Servers", issues, score: 50 };
|
|
1131
|
+
}
|
|
1132
|
+
for (const server of servers) {
|
|
1133
|
+
if (server.transport === "stdio" && !server.command) {
|
|
1134
|
+
issues.push({
|
|
1135
|
+
analyzer: "MCP",
|
|
1136
|
+
severity: "high",
|
|
1137
|
+
message: `MCP server "${server.name}" uses stdio transport but has no command`,
|
|
1138
|
+
fix: `Add a "command" field to the "${server.name}" MCP server config`
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
if ((server.transport === "sse" || server.transport === "http") && !server.url) {
|
|
1142
|
+
issues.push({
|
|
1143
|
+
analyzer: "MCP",
|
|
1144
|
+
severity: "high",
|
|
1145
|
+
message: `MCP server "${server.name}" uses ${server.transport} transport but has no URL`,
|
|
1146
|
+
fix: `Add a "url" field to the "${server.name}" MCP server config`
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
if (server.transport === "stdio" && server.command) {
|
|
1150
|
+
const executable = server.command.split(" ")[0];
|
|
1151
|
+
if (executable.startsWith("/") || executable.startsWith("./")) {
|
|
1152
|
+
try {
|
|
1153
|
+
await access3(executable);
|
|
1154
|
+
} catch {
|
|
1155
|
+
issues.push({
|
|
1156
|
+
analyzer: "MCP",
|
|
1157
|
+
severity: "medium",
|
|
1158
|
+
message: `MCP server "${server.name}" command not found: ${executable}`,
|
|
1159
|
+
fix: `Verify the path exists or install the required package`
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const score = Math.max(0, 100 - issues.filter((i) => i.severity !== "info").length * 25);
|
|
1166
|
+
return { name: "MCP Servers", issues, score };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/commands/doctor/analyzers/quality.ts
|
|
1170
|
+
var ESSENTIAL_SECTIONS = [
|
|
1171
|
+
{ pattern: /^##\s+Stack/m, name: "Stack", why: "Claude performs worse without knowing the tech stack" },
|
|
1172
|
+
{ pattern: /^##\s+Commands/m, name: "Commands", why: "Claude guesses wrong without explicit dev/build/test commands" },
|
|
1173
|
+
{ pattern: /^##\s+Session Start/m, name: "Session Start", why: "Without this, Claude won't read TASKS.md or maintain continuity" },
|
|
1174
|
+
{ pattern: /^##\s+Off.?Limits/m, name: "Off-Limits", why: "Without guardrails, Claude has no boundaries beyond defaults" },
|
|
1175
|
+
{ pattern: /^##\s+(Architecture|Project Structure)/m, name: "Architecture/Structure", why: "Claude makes better decisions when it understands the codebase shape" }
|
|
1176
|
+
];
|
|
1177
|
+
var VAGUE_PATTERNS = [
|
|
1178
|
+
{ pattern: /write (good|clean|quality|nice) code/i, label: "write good code" },
|
|
1179
|
+
{ pattern: /be (careful|thorough|diligent)/i, label: "be careful" },
|
|
1180
|
+
{ pattern: /follow best practices/i, label: "follow best practices" },
|
|
1181
|
+
{ pattern: /make sure (everything|it) works/i, label: "make sure it works" }
|
|
1182
|
+
];
|
|
1183
|
+
var SECRET_PATTERNS = [
|
|
1184
|
+
{ pattern: /sk-[a-zA-Z0-9]{20,}/, label: "OpenAI API key" },
|
|
1185
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36}/, label: "GitHub personal token" },
|
|
1186
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, label: "AWS access key" },
|
|
1187
|
+
{ pattern: /xoxb-[0-9]+-[a-zA-Z0-9]+/, label: "Slack bot token" }
|
|
1188
|
+
];
|
|
1189
|
+
async function analyzeQuality(config) {
|
|
1190
|
+
const issues = [];
|
|
1191
|
+
const content = config.claudeMdContent;
|
|
1192
|
+
if (content === null) {
|
|
1193
|
+
issues.push({
|
|
1194
|
+
analyzer: "Quality",
|
|
1195
|
+
severity: "high",
|
|
1196
|
+
message: "No CLAUDE.md found",
|
|
1197
|
+
fix: "Run `claude-launchpad init` to generate one"
|
|
1198
|
+
});
|
|
1199
|
+
return { name: "CLAUDE.md Quality", issues, score: 0 };
|
|
1200
|
+
}
|
|
1201
|
+
let sectionsFound = 0;
|
|
1202
|
+
for (const section of ESSENTIAL_SECTIONS) {
|
|
1203
|
+
if (section.pattern.test(content)) {
|
|
1204
|
+
sectionsFound++;
|
|
1205
|
+
} else {
|
|
1206
|
+
issues.push({
|
|
1207
|
+
analyzer: "Quality",
|
|
1208
|
+
severity: "medium",
|
|
1209
|
+
message: `Missing "## ${section.name}" section \u2014 ${section.why}`,
|
|
1210
|
+
fix: `Add a ## ${section.name} section to CLAUDE.md`
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
for (const vague of VAGUE_PATTERNS) {
|
|
1215
|
+
if (vague.pattern.test(content)) {
|
|
1216
|
+
issues.push({
|
|
1217
|
+
analyzer: "Quality",
|
|
1218
|
+
severity: "low",
|
|
1219
|
+
message: `Vague instruction detected: "${vague.label}" \u2014 zero signal, wastes budget`,
|
|
1220
|
+
fix: "Replace with specific, actionable instructions"
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const secret of SECRET_PATTERNS) {
|
|
1225
|
+
if (secret.pattern.test(content)) {
|
|
1226
|
+
issues.push({
|
|
1227
|
+
analyzer: "Quality",
|
|
1228
|
+
severity: "critical",
|
|
1229
|
+
message: `Possible ${secret.label} found in CLAUDE.md \u2014 secrets must never be in config files`,
|
|
1230
|
+
fix: "Remove the secret immediately and rotate it"
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const todoCount = (content.match(/<!--\s*TODO/gi) ?? []).length;
|
|
1235
|
+
if (todoCount > 3) {
|
|
1236
|
+
issues.push({
|
|
1237
|
+
analyzer: "Quality",
|
|
1238
|
+
severity: "medium",
|
|
1239
|
+
message: `${todoCount} TODO placeholders \u2014 CLAUDE.md is mostly unfinished`,
|
|
1240
|
+
fix: "Fill in the TODO sections or remove them"
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
const criticals = issues.filter((i) => i.severity === "critical").length;
|
|
1244
|
+
const highs = issues.filter((i) => i.severity === "high").length;
|
|
1245
|
+
const mediums = issues.filter((i) => i.severity === "medium").length;
|
|
1246
|
+
const lows = issues.filter((i) => i.severity === "low").length;
|
|
1247
|
+
const score = Math.max(0, 100 - criticals * 40 - highs * 30 - mediums * 15 - lows * 5);
|
|
1248
|
+
return { name: "CLAUDE.md Quality", issues, score };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/commands/doctor/fixer.ts
|
|
1252
|
+
import { readFile as readFile5, writeFile as writeFile2, mkdir as mkdir2, access as access4 } from "fs/promises";
|
|
1253
|
+
import { join as join4 } from "path";
|
|
1254
|
+
async function applyFixes(issues, projectRoot) {
|
|
1255
|
+
const detected = await detectProject(projectRoot);
|
|
1256
|
+
let fixed = 0;
|
|
1257
|
+
let skipped = 0;
|
|
1258
|
+
for (const issue of issues) {
|
|
1259
|
+
const applied = await tryFix(issue, projectRoot, detected);
|
|
1260
|
+
if (applied) {
|
|
1261
|
+
fixed++;
|
|
1262
|
+
} else {
|
|
1263
|
+
skipped++;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return { fixed, skipped };
|
|
1267
|
+
}
|
|
1268
|
+
async function tryFix(issue, root, detected) {
|
|
1269
|
+
if (issue.analyzer === "Hooks" && issue.message.includes(".env file protection")) {
|
|
1270
|
+
return addEnvProtectionHook(root);
|
|
1271
|
+
}
|
|
1272
|
+
if (issue.analyzer === "Hooks" && issue.message.includes("auto-format")) {
|
|
1273
|
+
return addAutoFormatHook(root, detected);
|
|
1274
|
+
}
|
|
1275
|
+
if (issue.analyzer === "Hooks" && issue.message.includes("No PreToolUse")) {
|
|
1276
|
+
return addEnvProtectionHook(root);
|
|
1277
|
+
}
|
|
1278
|
+
if (issue.analyzer === "Quality" && issue.message.includes("Architecture")) {
|
|
1279
|
+
return addClaudeMdSection(root, "## Architecture", "<!-- TODO: Describe your codebase structure. Run `claude-launchpad enhance` to auto-fill this. -->");
|
|
1280
|
+
}
|
|
1281
|
+
if (issue.analyzer === "Quality" && issue.message.includes("Off-Limits")) {
|
|
1282
|
+
return addClaudeMdSection(root, "## Off-Limits", "- Never hardcode secrets \u2014 use environment variables\n- Never write to `.env` files\n- Never expose internal error details in API responses");
|
|
1283
|
+
}
|
|
1284
|
+
if (issue.analyzer === "Quality" && issue.message.includes("Commands")) {
|
|
1285
|
+
return addClaudeMdSection(root, "## Commands", "<!-- TODO: Add your dev/build/test commands -->");
|
|
1286
|
+
}
|
|
1287
|
+
if (issue.analyzer === "Quality" && issue.message.includes("Stack")) {
|
|
1288
|
+
const stackContent = detected.language ? `- **Language**: ${detected.language}${detected.framework ? `
|
|
1289
|
+
- **Framework**: ${detected.framework}` : ""}${detected.packageManager ? `
|
|
1290
|
+
- **Package Manager**: ${detected.packageManager}` : ""}` : "<!-- TODO: Define your tech stack -->";
|
|
1291
|
+
return addClaudeMdSection(root, "## Stack", stackContent);
|
|
1292
|
+
}
|
|
1293
|
+
if (issue.analyzer === "Quality" && issue.message.includes("Session Start")) {
|
|
1294
|
+
return addClaudeMdSection(root, "## Session Start", "- ALWAYS read @TASKS.md first \u2014 it tracks progress across sessions\n- Update TASKS.md as you complete work");
|
|
1295
|
+
}
|
|
1296
|
+
if (issue.analyzer === "Rules" && issue.message.includes("No .claude/rules/")) {
|
|
1297
|
+
return createStarterRules(root);
|
|
1298
|
+
}
|
|
1299
|
+
if (issue.analyzer === "Permissions" && issue.message.includes("force-push")) {
|
|
1300
|
+
return addForcePushProtection(root);
|
|
1301
|
+
}
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
async function addEnvProtectionHook(root) {
|
|
1305
|
+
const settings = await readSettingsJson(root);
|
|
1306
|
+
const hooks = settings.hooks ?? {};
|
|
1307
|
+
const preToolUse = hooks.PreToolUse ?? [];
|
|
1308
|
+
const alreadyHas = preToolUse.some((g) => {
|
|
1309
|
+
const nested = g.hooks;
|
|
1310
|
+
return nested?.some((h) => String(h.command ?? "").includes(".env"));
|
|
1311
|
+
});
|
|
1312
|
+
if (alreadyHas) return false;
|
|
1313
|
+
preToolUse.push({
|
|
1314
|
+
matcher: "Read|Write|Edit",
|
|
1315
|
+
hooks: [{
|
|
1316
|
+
type: "command",
|
|
1317
|
+
command: `echo "$TOOL_INPUT_FILE_PATH" | grep -qE '\\.(env|env\\..*)$' && ! echo "$TOOL_INPUT_FILE_PATH" | grep -q '.env.example' && echo 'BLOCKED: .env files contain secrets' && exit 1; exit 0`
|
|
1318
|
+
}]
|
|
1319
|
+
});
|
|
1320
|
+
settings.hooks = { ...hooks, PreToolUse: preToolUse };
|
|
1321
|
+
await writeSettingsJson(root, settings);
|
|
1322
|
+
log.success("Added .env file protection hook (PreToolUse)");
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
async function addAutoFormatHook(root, detected) {
|
|
1326
|
+
if (!detected.language) return false;
|
|
1327
|
+
const formatters = {
|
|
1328
|
+
TypeScript: { extensions: ["ts", "tsx"], command: detected.formatCommand ?? "npx prettier --write" },
|
|
1329
|
+
JavaScript: { extensions: ["js", "jsx"], command: detected.formatCommand ?? "npx prettier --write" },
|
|
1330
|
+
Python: { extensions: ["py"], command: detected.formatCommand ?? "ruff format" },
|
|
1331
|
+
Go: { extensions: ["go"], command: "gofmt -w" },
|
|
1332
|
+
Rust: { extensions: ["rs"], command: "rustfmt" },
|
|
1333
|
+
Ruby: { extensions: ["rb"], command: "rubocop -A" },
|
|
1334
|
+
PHP: { extensions: ["php"], command: detected.formatCommand ?? "vendor/bin/pint" }
|
|
1335
|
+
};
|
|
1336
|
+
const config = formatters[detected.language];
|
|
1337
|
+
if (!config) return false;
|
|
1338
|
+
const settings = await readSettingsJson(root);
|
|
1339
|
+
const hooks = settings.hooks ?? {};
|
|
1340
|
+
const postToolUse = hooks.PostToolUse ?? [];
|
|
1341
|
+
const alreadyHas = postToolUse.some((g) => {
|
|
1342
|
+
const nested = g.hooks;
|
|
1343
|
+
return nested?.some((h) => String(h.command ?? "").includes("format"));
|
|
1344
|
+
});
|
|
1345
|
+
if (alreadyHas) return false;
|
|
1346
|
+
const extChecks = config.extensions.map((ext) => `[ "$ext" = "${ext}" ]`).join(" || ");
|
|
1347
|
+
postToolUse.push({
|
|
1348
|
+
matcher: "Write|Edit",
|
|
1349
|
+
hooks: [{
|
|
1350
|
+
type: "command",
|
|
1351
|
+
command: `ext=\${TOOL_INPUT_FILE_PATH##*.}; (${extChecks}) && ${config.command} "$TOOL_INPUT_FILE_PATH" 2>/dev/null; exit 0`
|
|
1352
|
+
}]
|
|
1353
|
+
});
|
|
1354
|
+
settings.hooks = { ...hooks, PostToolUse: postToolUse };
|
|
1355
|
+
await writeSettingsJson(root, settings);
|
|
1356
|
+
log.success(`Added auto-format hook (PostToolUse \u2192 ${config.command})`);
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
async function addForcePushProtection(root) {
|
|
1360
|
+
const settings = await readSettingsJson(root);
|
|
1361
|
+
const hooks = settings.hooks ?? {};
|
|
1362
|
+
const preToolUse = hooks.PreToolUse ?? [];
|
|
1363
|
+
const alreadyHas = preToolUse.some((g) => {
|
|
1364
|
+
const nested = g.hooks;
|
|
1365
|
+
return nested?.some((h) => String(h.command ?? "").includes("force"));
|
|
1366
|
+
});
|
|
1367
|
+
if (alreadyHas) return false;
|
|
1368
|
+
preToolUse.push({
|
|
1369
|
+
matcher: "Bash",
|
|
1370
|
+
hooks: [{
|
|
1371
|
+
type: "command",
|
|
1372
|
+
command: `echo "$TOOL_INPUT_COMMAND" | grep -qE 'push.*--force|push.*-f' && echo 'WARNING: Force push detected \u2014 this can destroy remote history' && exit 1; exit 0`
|
|
1373
|
+
}]
|
|
1374
|
+
});
|
|
1375
|
+
settings.hooks = { ...hooks, PreToolUse: preToolUse };
|
|
1376
|
+
await writeSettingsJson(root, settings);
|
|
1377
|
+
log.success("Added force-push protection hook (PreToolUse \u2192 Bash)");
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
async function addClaudeMdSection(root, heading, content) {
|
|
1381
|
+
const claudeMdPath = join4(root, "CLAUDE.md");
|
|
1382
|
+
let existing;
|
|
1383
|
+
try {
|
|
1384
|
+
existing = await readFile5(claudeMdPath, "utf-8");
|
|
1385
|
+
} catch {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
if (existing.includes(heading)) return false;
|
|
1389
|
+
const keyDecisionsIdx = existing.indexOf("## Key Decisions");
|
|
1390
|
+
const insertAt = keyDecisionsIdx > -1 ? keyDecisionsIdx : existing.length;
|
|
1391
|
+
const section = `
|
|
1392
|
+
${heading}
|
|
1393
|
+
${content}
|
|
1394
|
+
|
|
1395
|
+
`;
|
|
1396
|
+
const updated = existing.slice(0, insertAt) + section + existing.slice(insertAt);
|
|
1397
|
+
await writeFile2(claudeMdPath, updated);
|
|
1398
|
+
log.success(`Added "${heading}" section to CLAUDE.md`);
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
async function createStarterRules(root) {
|
|
1402
|
+
const rulesDir = join4(root, ".claude", "rules");
|
|
1403
|
+
try {
|
|
1404
|
+
await access4(rulesDir);
|
|
1405
|
+
return false;
|
|
1406
|
+
} catch {
|
|
1407
|
+
}
|
|
1408
|
+
await mkdir2(rulesDir, { recursive: true });
|
|
1409
|
+
await writeFile2(
|
|
1410
|
+
join4(rulesDir, "conventions.md"),
|
|
1411
|
+
`# Project Conventions
|
|
1412
|
+
|
|
1413
|
+
- Use conventional commits (feat:, fix:, docs:, refactor:, test:, chore:)
|
|
1414
|
+
- Keep files under 400 lines, functions under 50 lines
|
|
1415
|
+
- Handle errors explicitly \u2014 no empty catch blocks
|
|
1416
|
+
- Validate input at system boundaries
|
|
1417
|
+
`
|
|
1418
|
+
);
|
|
1419
|
+
log.success("Created .claude/rules/conventions.md with starter rules");
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
async function readSettingsJson(root) {
|
|
1423
|
+
const path = join4(root, ".claude", "settings.json");
|
|
1424
|
+
try {
|
|
1425
|
+
const content = await readFile5(path, "utf-8");
|
|
1426
|
+
return JSON.parse(content);
|
|
1427
|
+
} catch {
|
|
1428
|
+
return {};
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
async function writeSettingsJson(root, settings) {
|
|
1432
|
+
const dir = join4(root, ".claude");
|
|
1433
|
+
await mkdir2(dir, { recursive: true });
|
|
1434
|
+
await writeFile2(join4(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// src/commands/doctor/index.ts
|
|
1438
|
+
function createDoctorCommand() {
|
|
1439
|
+
return new Command2("doctor").description("Diagnose your Claude Code configuration and report issues").option("-p, --path <path>", "Project root path", process.cwd()).option("--json", "Output as JSON").option("--min-score <n>", "Exit non-zero if overall score is below this threshold (for CI)").option("--fix", "Auto-apply deterministic fixes for detected issues").action(async (opts) => {
|
|
1440
|
+
printBanner();
|
|
1441
|
+
log.step("Scanning Claude Code configuration...");
|
|
1442
|
+
log.blank();
|
|
1443
|
+
const config = await parseClaudeConfig(opts.path);
|
|
1444
|
+
if (config.claudeMdContent === null && config.settings === null) {
|
|
1445
|
+
log.error("No Claude Code configuration found in this directory.");
|
|
1446
|
+
log.info("Run `claude-launchpad init` to set up a project, or cd into a configured project.");
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
const results = await Promise.all([
|
|
1450
|
+
analyzeBudget(config),
|
|
1451
|
+
analyzeQuality(config),
|
|
1452
|
+
analyzeSettings(config),
|
|
1453
|
+
analyzeHooks(config),
|
|
1454
|
+
analyzeRules(config),
|
|
1455
|
+
analyzePermissions(config),
|
|
1456
|
+
analyzeMcp(config)
|
|
1457
|
+
]);
|
|
1458
|
+
if (opts.json) {
|
|
1459
|
+
const overallScore = Math.round(
|
|
1460
|
+
results.reduce((sum, r) => sum + r.score, 0) / results.length
|
|
1461
|
+
);
|
|
1462
|
+
console.log(JSON.stringify({ overallScore, analyzers: results, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
renderReport(results);
|
|
1466
|
+
if (opts.fix) {
|
|
1467
|
+
const allIssues = results.flatMap((r) => r.issues);
|
|
1468
|
+
const fixable = allIssues.filter((i) => i.severity !== "info");
|
|
1469
|
+
if (fixable.length > 0) {
|
|
1470
|
+
log.blank();
|
|
1471
|
+
log.step("Applying fixes...");
|
|
1472
|
+
log.blank();
|
|
1473
|
+
const { fixed, skipped } = await applyFixes(fixable, opts.path);
|
|
1474
|
+
log.blank();
|
|
1475
|
+
if (fixed > 0) {
|
|
1476
|
+
log.success(`Applied ${fixed} fix(es). Run \`claude-launchpad doctor\` again to see your new score.`);
|
|
1477
|
+
}
|
|
1478
|
+
if (skipped > 0) {
|
|
1479
|
+
log.info(`${skipped} issue(s) require manual intervention.`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (opts.minScore) {
|
|
1484
|
+
const overallScore = Math.round(
|
|
1485
|
+
results.reduce((sum, r) => sum + r.score, 0) / results.length
|
|
1486
|
+
);
|
|
1487
|
+
const threshold = parseInt(opts.minScore, 10);
|
|
1488
|
+
if (overallScore < threshold) {
|
|
1489
|
+
process.exit(1);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
function renderReport(results) {
|
|
1495
|
+
const overallScore = Math.round(
|
|
1496
|
+
results.reduce((sum, r) => sum + r.score, 0) / results.length
|
|
1497
|
+
);
|
|
1498
|
+
for (const result of results) {
|
|
1499
|
+
printScoreCard(result.name, result.score);
|
|
1500
|
+
}
|
|
1501
|
+
log.blank();
|
|
1502
|
+
printScoreCard("Overall", overallScore);
|
|
1503
|
+
log.blank();
|
|
1504
|
+
const allIssues = results.flatMap((r) => r.issues);
|
|
1505
|
+
const actionable = allIssues.filter((i) => i.severity !== "info");
|
|
1506
|
+
if (actionable.length === 0) {
|
|
1507
|
+
log.success("No issues found. Your configuration looks solid.");
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
for (const issue of sortBySeverity(actionable)) {
|
|
1511
|
+
printIssue(issue.severity, issue.analyzer, issue.message, issue.fix);
|
|
1512
|
+
}
|
|
1513
|
+
log.info(`${actionable.length} issue(s) found. Fix critical/high first.`);
|
|
1514
|
+
}
|
|
1515
|
+
function sortBySeverity(issues) {
|
|
1516
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
1517
|
+
return [...issues].sort((a, b) => (order[a.severity] ?? 4) - (order[b.severity] ?? 4));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// src/commands/eval/index.ts
|
|
1521
|
+
import { Command as Command3 } from "commander";
|
|
1522
|
+
import ora from "ora";
|
|
1523
|
+
import chalk2 from "chalk";
|
|
1524
|
+
|
|
1525
|
+
// src/commands/eval/loader.ts
|
|
1526
|
+
import { readFile as readFile6, readdir as readdir2, access as access5 } from "fs/promises";
|
|
1527
|
+
import { join as join5, resolve as resolve2, dirname } from "path";
|
|
1528
|
+
import { fileURLToPath } from "url";
|
|
1529
|
+
import { parse as parseYaml } from "yaml";
|
|
1530
|
+
|
|
1531
|
+
// src/commands/eval/schema.ts
|
|
1532
|
+
function validateScenario(raw, filePath) {
|
|
1533
|
+
if (!raw || typeof raw !== "object") {
|
|
1534
|
+
throw new ScenarioError(filePath, "Scenario must be a YAML object");
|
|
1535
|
+
}
|
|
1536
|
+
const obj = raw;
|
|
1537
|
+
const name = requireString(obj, "name", filePath);
|
|
1538
|
+
const description = requireString(obj, "description", filePath);
|
|
1539
|
+
const prompt = requireString(obj, "prompt", filePath);
|
|
1540
|
+
const setup = validateSetup(obj.setup, filePath);
|
|
1541
|
+
const checks = validateChecks(obj.checks, filePath);
|
|
1542
|
+
const passingScore = requireNumber(obj, "passingScore", filePath);
|
|
1543
|
+
const runs = optionalNumber(obj, "runs") ?? 3;
|
|
1544
|
+
return { name, description, setup, prompt, checks, passingScore, runs };
|
|
1545
|
+
}
|
|
1546
|
+
function validateSetup(raw, filePath) {
|
|
1547
|
+
if (!raw || typeof raw !== "object") {
|
|
1548
|
+
throw new ScenarioError(filePath, '"setup" must be an object with a "files" array');
|
|
1549
|
+
}
|
|
1550
|
+
const obj = raw;
|
|
1551
|
+
const files = obj.files;
|
|
1552
|
+
if (!Array.isArray(files)) {
|
|
1553
|
+
throw new ScenarioError(filePath, '"setup.files" must be an array');
|
|
1554
|
+
}
|
|
1555
|
+
const validatedFiles = files.map((f, i) => {
|
|
1556
|
+
if (!f || typeof f !== "object") {
|
|
1557
|
+
throw new ScenarioError(filePath, `setup.files[${i}] must be an object`);
|
|
1558
|
+
}
|
|
1559
|
+
const file = f;
|
|
1560
|
+
if (typeof file.path !== "string" || typeof file.content !== "string") {
|
|
1561
|
+
throw new ScenarioError(filePath, `setup.files[${i}] must have "path" and "content" strings`);
|
|
1562
|
+
}
|
|
1563
|
+
return { path: file.path, content: file.content };
|
|
1564
|
+
});
|
|
1565
|
+
const instructions = typeof obj.instructions === "string" ? obj.instructions : void 0;
|
|
1566
|
+
return { files: validatedFiles, instructions };
|
|
1567
|
+
}
|
|
1568
|
+
function validateChecks(raw, filePath) {
|
|
1569
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
1570
|
+
throw new ScenarioError(filePath, '"checks" must be a non-empty array');
|
|
1571
|
+
}
|
|
1572
|
+
return raw.map((c, i) => {
|
|
1573
|
+
if (!c || typeof c !== "object") {
|
|
1574
|
+
throw new ScenarioError(filePath, `checks[${i}] must be an object`);
|
|
1575
|
+
}
|
|
1576
|
+
const check = c;
|
|
1577
|
+
const validTypes = ["grep", "file-exists", "file-absent", "custom"];
|
|
1578
|
+
if (!validTypes.includes(check.type)) {
|
|
1579
|
+
throw new ScenarioError(filePath, `checks[${i}].type must be one of: ${validTypes.join(", ")}`);
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof check.target !== "string") {
|
|
1582
|
+
throw new ScenarioError(filePath, `checks[${i}].target must be a string`);
|
|
1583
|
+
}
|
|
1584
|
+
const validExpect = ["present", "absent"];
|
|
1585
|
+
if (!validExpect.includes(check.expect)) {
|
|
1586
|
+
throw new ScenarioError(filePath, `checks[${i}].expect must be "present" or "absent"`);
|
|
1587
|
+
}
|
|
1588
|
+
if (typeof check.points !== "number" || check.points < 0) {
|
|
1589
|
+
throw new ScenarioError(filePath, `checks[${i}].points must be a non-negative number`);
|
|
1590
|
+
}
|
|
1591
|
+
if (typeof check.label !== "string") {
|
|
1592
|
+
throw new ScenarioError(filePath, `checks[${i}].label must be a string`);
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
type: check.type,
|
|
1596
|
+
pattern: typeof check.pattern === "string" ? check.pattern : void 0,
|
|
1597
|
+
target: check.target,
|
|
1598
|
+
expect: check.expect,
|
|
1599
|
+
points: check.points,
|
|
1600
|
+
label: check.label
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
function requireString(obj, key, filePath) {
|
|
1605
|
+
if (typeof obj[key] !== "string" || obj[key] === "") {
|
|
1606
|
+
throw new ScenarioError(filePath, `"${key}" must be a non-empty string`);
|
|
1607
|
+
}
|
|
1608
|
+
return obj[key];
|
|
1609
|
+
}
|
|
1610
|
+
function requireNumber(obj, key, filePath) {
|
|
1611
|
+
if (typeof obj[key] !== "number") {
|
|
1612
|
+
throw new ScenarioError(filePath, `"${key}" must be a number`);
|
|
1613
|
+
}
|
|
1614
|
+
return obj[key];
|
|
1615
|
+
}
|
|
1616
|
+
function optionalNumber(obj, key) {
|
|
1617
|
+
if (obj[key] === void 0) return void 0;
|
|
1618
|
+
if (typeof obj[key] !== "number") return void 0;
|
|
1619
|
+
return obj[key];
|
|
1620
|
+
}
|
|
1621
|
+
var ScenarioError = class extends Error {
|
|
1622
|
+
constructor(filePath, message) {
|
|
1623
|
+
super(`Invalid scenario ${filePath}: ${message}`);
|
|
1624
|
+
this.name = "ScenarioError";
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
// src/commands/eval/loader.ts
|
|
1629
|
+
async function findScenariosDir() {
|
|
1630
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
1631
|
+
const devPath = resolve2(thisDir, "../../../scenarios");
|
|
1632
|
+
if (await dirExists(devPath)) return devPath;
|
|
1633
|
+
const bundledPath = resolve2(thisDir, "../scenarios");
|
|
1634
|
+
if (await dirExists(bundledPath)) return bundledPath;
|
|
1635
|
+
const rootPath = resolve2(thisDir, "../../scenarios");
|
|
1636
|
+
if (await dirExists(rootPath)) return rootPath;
|
|
1637
|
+
return devPath;
|
|
1638
|
+
}
|
|
1639
|
+
async function dirExists(path) {
|
|
1640
|
+
try {
|
|
1641
|
+
await access5(path);
|
|
1642
|
+
return true;
|
|
1643
|
+
} catch {
|
|
1644
|
+
return false;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
async function loadScenarios(options) {
|
|
1648
|
+
const { suite, customPath } = options;
|
|
1649
|
+
const scenarioDir = customPath ? resolve2(customPath) : await findScenariosDir();
|
|
1650
|
+
const dirs = suite ? [join5(scenarioDir, suite)] : await getSubdirectories(scenarioDir);
|
|
1651
|
+
const allDirs = [scenarioDir, ...dirs];
|
|
1652
|
+
const scenarios = [];
|
|
1653
|
+
for (const dir of allDirs) {
|
|
1654
|
+
const files = await listYamlFiles(dir);
|
|
1655
|
+
for (const file of files) {
|
|
1656
|
+
try {
|
|
1657
|
+
const content = await readFile6(file, "utf-8");
|
|
1658
|
+
const raw = parseYaml(content);
|
|
1659
|
+
const scenario = validateScenario(raw, file);
|
|
1660
|
+
scenarios.push(scenario);
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1663
|
+
console.warn(` Warning: Skipping ${file}: ${msg}`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return scenarios;
|
|
1668
|
+
}
|
|
1669
|
+
async function getSubdirectories(dir) {
|
|
1670
|
+
try {
|
|
1671
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
1672
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join5(dir, e.name));
|
|
1673
|
+
} catch {
|
|
1674
|
+
return [];
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
async function listYamlFiles(dir) {
|
|
1678
|
+
try {
|
|
1679
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
1680
|
+
return entries.filter((e) => e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))).map((e) => join5(dir, e.name));
|
|
1681
|
+
} catch {
|
|
1682
|
+
return [];
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/commands/eval/runner.ts
|
|
1687
|
+
import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile7, rm, cp } from "fs/promises";
|
|
1688
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
1689
|
+
import { tmpdir } from "os";
|
|
1690
|
+
import { randomUUID } from "crypto";
|
|
1691
|
+
import { execFile } from "child_process";
|
|
1692
|
+
import { promisify } from "util";
|
|
1693
|
+
var exec = promisify(execFile);
|
|
1694
|
+
async function runScenario(scenario, options) {
|
|
1695
|
+
const sandboxDir = join6(tmpdir(), `claude-eval-${randomUUID()}`);
|
|
1696
|
+
try {
|
|
1697
|
+
await mkdir3(sandboxDir, { recursive: true });
|
|
1698
|
+
for (const file of scenario.setup.files) {
|
|
1699
|
+
const filePath = join6(sandboxDir, file.path);
|
|
1700
|
+
await mkdir3(dirname2(filePath), { recursive: true });
|
|
1701
|
+
await writeFile3(filePath, file.content);
|
|
1702
|
+
}
|
|
1703
|
+
const claudeDir = join6(options.projectRoot, ".claude");
|
|
1704
|
+
const sandboxClaudeDir = join6(sandboxDir, ".claude");
|
|
1705
|
+
try {
|
|
1706
|
+
await cp(claudeDir, sandboxClaudeDir, { recursive: true });
|
|
1707
|
+
} catch {
|
|
1708
|
+
}
|
|
1709
|
+
if (scenario.setup.instructions) {
|
|
1710
|
+
await writeFile3(
|
|
1711
|
+
join6(sandboxDir, "CLAUDE.md"),
|
|
1712
|
+
`# Eval Scenario
|
|
1713
|
+
|
|
1714
|
+
${scenario.setup.instructions}
|
|
1715
|
+
`
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
await exec("git", ["init", "-q"], { cwd: sandboxDir });
|
|
1719
|
+
await exec("git", ["add", "-A"], { cwd: sandboxDir });
|
|
1720
|
+
await exec("git", [
|
|
1721
|
+
"-c",
|
|
1722
|
+
"user.name=eval",
|
|
1723
|
+
"-c",
|
|
1724
|
+
"user.email=eval@test",
|
|
1725
|
+
"commit",
|
|
1726
|
+
"-q",
|
|
1727
|
+
"-m",
|
|
1728
|
+
"eval setup"
|
|
1729
|
+
], { cwd: sandboxDir });
|
|
1730
|
+
await runClaude(sandboxDir, scenario.prompt, options.timeout);
|
|
1731
|
+
const checkResults = await evaluateChecks(scenario.checks, sandboxDir);
|
|
1732
|
+
const score = checkResults.filter((c) => c.passed).reduce((sum, c) => sum + c.points, 0);
|
|
1733
|
+
const maxScore = scenario.checks.reduce((sum, c) => sum + c.points, 0);
|
|
1734
|
+
return {
|
|
1735
|
+
scenario: scenario.name,
|
|
1736
|
+
score,
|
|
1737
|
+
maxScore,
|
|
1738
|
+
passed: score >= scenario.passingScore,
|
|
1739
|
+
checks: checkResults
|
|
1740
|
+
};
|
|
1741
|
+
} finally {
|
|
1742
|
+
if (options.debug) {
|
|
1743
|
+
console.log(` DEBUG: Sandbox preserved at ${sandboxDir}`);
|
|
1744
|
+
} else {
|
|
1745
|
+
await rm(sandboxDir, { recursive: true, force: true }).catch(() => {
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async function runScenarioWithRetries(scenario, options) {
|
|
1751
|
+
const results = [];
|
|
1752
|
+
for (let i = 0; i < scenario.runs; i++) {
|
|
1753
|
+
const result = await runScenario(scenario, options);
|
|
1754
|
+
results.push(result);
|
|
1755
|
+
}
|
|
1756
|
+
const sorted = [...results].sort((a, b) => a.score - b.score);
|
|
1757
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
1758
|
+
}
|
|
1759
|
+
async function runClaude(cwd, prompt, timeout) {
|
|
1760
|
+
try {
|
|
1761
|
+
return await exec(
|
|
1762
|
+
"claude",
|
|
1763
|
+
[
|
|
1764
|
+
"-p",
|
|
1765
|
+
prompt,
|
|
1766
|
+
"--output-format",
|
|
1767
|
+
"text",
|
|
1768
|
+
"--max-turns",
|
|
1769
|
+
"20",
|
|
1770
|
+
"--dangerously-skip-permissions",
|
|
1771
|
+
"--allowedTools",
|
|
1772
|
+
"Read",
|
|
1773
|
+
"Write",
|
|
1774
|
+
"Edit",
|
|
1775
|
+
"Bash",
|
|
1776
|
+
"Glob",
|
|
1777
|
+
"Grep"
|
|
1778
|
+
],
|
|
1779
|
+
{
|
|
1780
|
+
cwd,
|
|
1781
|
+
timeout,
|
|
1782
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1783
|
+
}
|
|
1784
|
+
);
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
if (error && typeof error === "object" && "stdout" in error) {
|
|
1787
|
+
return {
|
|
1788
|
+
stdout: String(error.stdout ?? ""),
|
|
1789
|
+
stderr: String(error.stderr ?? "")
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
throw error;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
async function evaluateChecks(checks, sandboxDir) {
|
|
1796
|
+
const results = [];
|
|
1797
|
+
for (const check of checks) {
|
|
1798
|
+
const passed = await evaluateSingleCheck(check, sandboxDir);
|
|
1799
|
+
results.push({ label: check.label, passed, points: check.points });
|
|
1800
|
+
}
|
|
1801
|
+
return results;
|
|
1802
|
+
}
|
|
1803
|
+
async function evaluateSingleCheck(check, sandboxDir) {
|
|
1804
|
+
switch (check.type) {
|
|
1805
|
+
case "grep": {
|
|
1806
|
+
if (!check.pattern) return false;
|
|
1807
|
+
try {
|
|
1808
|
+
const content = await readFile7(join6(sandboxDir, check.target), "utf-8");
|
|
1809
|
+
const found = new RegExp(check.pattern).test(content);
|
|
1810
|
+
return check.expect === "present" ? found : !found;
|
|
1811
|
+
} catch {
|
|
1812
|
+
return check.expect === "absent";
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
case "file-exists": {
|
|
1816
|
+
try {
|
|
1817
|
+
await readFile7(join6(sandboxDir, check.target));
|
|
1818
|
+
return check.expect === "present";
|
|
1819
|
+
} catch {
|
|
1820
|
+
return check.expect === "absent";
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
case "file-absent": {
|
|
1824
|
+
try {
|
|
1825
|
+
await readFile7(join6(sandboxDir, check.target));
|
|
1826
|
+
return check.expect === "absent";
|
|
1827
|
+
} catch {
|
|
1828
|
+
return check.expect === "present";
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
case "custom":
|
|
1832
|
+
return false;
|
|
1833
|
+
default:
|
|
1834
|
+
return false;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/commands/eval/index.ts
|
|
1839
|
+
function createEvalCommand() {
|
|
1840
|
+
return new Command3("eval").description("Test your Claude Code config against eval scenarios").option("-s, --suite <suite>", "Eval suite to run (e.g., security, conventions, workflow)").option("-p, --path <path>", "Project root path", process.cwd()).option("--scenarios <path>", "Custom scenarios directory").option("--runs <n>", "Runs per scenario (default: 3)", "3").option("--timeout <ms>", "Timeout per run in ms (default: 120000)", "120000").option("--json", "Output as JSON").option("--debug", "Keep sandbox directories for inspection").action(async (opts) => {
|
|
1841
|
+
printBanner();
|
|
1842
|
+
const claudeAvailable = await checkClaudeCli();
|
|
1843
|
+
if (!claudeAvailable) {
|
|
1844
|
+
log.error("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code");
|
|
1845
|
+
log.info("The eval command runs Claude headless against scenarios \u2014 it requires the CLI.");
|
|
1846
|
+
process.exit(1);
|
|
1847
|
+
}
|
|
1848
|
+
log.step("Loading eval scenarios...");
|
|
1849
|
+
const scenarios = await loadScenarios({
|
|
1850
|
+
suite: opts.suite,
|
|
1851
|
+
customPath: opts.scenarios
|
|
1852
|
+
});
|
|
1853
|
+
if (scenarios.length === 0) {
|
|
1854
|
+
log.warn("No matching scenarios found.");
|
|
1855
|
+
if (opts.suite) {
|
|
1856
|
+
log.info(`Check that the suite "${opts.suite}" exists in the scenarios directory.`);
|
|
1857
|
+
}
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
log.success(`Loaded ${scenarios.length} scenario(s)`);
|
|
1861
|
+
log.blank();
|
|
1862
|
+
const runs = parseInt(opts.runs, 10);
|
|
1863
|
+
const timeout = parseInt(opts.timeout, 10);
|
|
1864
|
+
const results = [];
|
|
1865
|
+
for (const scenario of scenarios) {
|
|
1866
|
+
const spinner = ora({
|
|
1867
|
+
text: `Running: ${scenario.name} (${runs} run${runs > 1 ? "s" : ""})`,
|
|
1868
|
+
prefixText: " "
|
|
1869
|
+
}).start();
|
|
1870
|
+
try {
|
|
1871
|
+
const result = await runScenarioWithRetries(
|
|
1872
|
+
{ ...scenario, runs },
|
|
1873
|
+
{ projectRoot: opts.path, timeout, debug: opts.debug }
|
|
1874
|
+
);
|
|
1875
|
+
results.push(result);
|
|
1876
|
+
if (result.passed) {
|
|
1877
|
+
spinner.succeed(`${scenario.name} ${result.score}/${result.maxScore}`);
|
|
1878
|
+
} else {
|
|
1879
|
+
spinner.fail(`${scenario.name} ${result.score}/${result.maxScore}`);
|
|
1880
|
+
}
|
|
1881
|
+
} catch (error) {
|
|
1882
|
+
spinner.fail(`${scenario.name} ERROR`);
|
|
1883
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1884
|
+
log.error(` ${msg}`);
|
|
1885
|
+
results.push({
|
|
1886
|
+
scenario: scenario.name,
|
|
1887
|
+
score: 0,
|
|
1888
|
+
maxScore: scenario.checks.reduce((s, c) => s + c.points, 0),
|
|
1889
|
+
passed: false,
|
|
1890
|
+
checks: scenario.checks.map((c) => ({ label: c.label, passed: false, points: c.points }))
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
log.blank();
|
|
1895
|
+
if (opts.json) {
|
|
1896
|
+
const overallScore = results.reduce((s, r) => s + r.score, 0);
|
|
1897
|
+
const overallMax = results.reduce((s, r) => s + r.maxScore, 0);
|
|
1898
|
+
console.log(JSON.stringify({
|
|
1899
|
+
results,
|
|
1900
|
+
overallScore,
|
|
1901
|
+
overallMax,
|
|
1902
|
+
passed: overallScore >= overallMax * 0.8,
|
|
1903
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1904
|
+
}, null, 2));
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
renderEvalReport(results);
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
function renderEvalReport(results) {
|
|
1911
|
+
for (const result of results) {
|
|
1912
|
+
const icon = result.passed ? chalk2.green("\u2713") : chalk2.red("\u2717");
|
|
1913
|
+
const status = result.passed ? chalk2.green("PASS") : chalk2.red("FAIL");
|
|
1914
|
+
const score = `${result.score}/${result.maxScore}`;
|
|
1915
|
+
console.log(` ${icon} ${chalk2.bold(result.scenario)} ${score} ${status}`);
|
|
1916
|
+
const failedChecks = result.checks.filter((c) => !c.passed);
|
|
1917
|
+
for (const check of failedChecks) {
|
|
1918
|
+
console.log(` ${chalk2.dim("\u2500")} ${chalk2.dim(check.label)}`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
log.blank();
|
|
1922
|
+
const totalScore = results.reduce((s, r) => s + r.score, 0);
|
|
1923
|
+
const totalMax = results.reduce((s, r) => s + r.maxScore, 0);
|
|
1924
|
+
const pct = totalMax > 0 ? Math.round(totalScore / totalMax * 100) : 0;
|
|
1925
|
+
printScoreCard("Config Eval Score", pct);
|
|
1926
|
+
log.blank();
|
|
1927
|
+
const passed = results.filter((r) => r.passed).length;
|
|
1928
|
+
const failed = results.length - passed;
|
|
1929
|
+
if (failed === 0) {
|
|
1930
|
+
log.success(`All ${passed} scenario(s) passed.`);
|
|
1931
|
+
} else {
|
|
1932
|
+
log.warn(`${passed} passed, ${failed} failed out of ${results.length} scenario(s).`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
async function checkClaudeCli() {
|
|
1936
|
+
const { execFile: execFile3 } = await import("child_process");
|
|
1937
|
+
const { promisify: promisify3 } = await import("util");
|
|
1938
|
+
const exec2 = promisify3(execFile3);
|
|
1939
|
+
try {
|
|
1940
|
+
await exec2("claude", ["--version"]);
|
|
1941
|
+
return true;
|
|
1942
|
+
} catch {
|
|
1943
|
+
return false;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/commands/enhance/index.ts
|
|
1948
|
+
import { Command as Command4 } from "commander";
|
|
1949
|
+
import { spawn, execFile as execFile2 } from "child_process";
|
|
1950
|
+
import { promisify as promisify2 } from "util";
|
|
1951
|
+
import { access as access6 } from "fs/promises";
|
|
1952
|
+
import { join as join7 } from "path";
|
|
1953
|
+
var execAsync = promisify2(execFile2);
|
|
1954
|
+
var ENHANCE_PROMPT = `Read CLAUDE.md and the project's codebase, then update CLAUDE.md to fill in missing or incomplete sections:
|
|
1955
|
+
|
|
1956
|
+
1. **## Architecture** or **## Project Structure** \u2014 describe the actual codebase structure (directories, key files, data flow)
|
|
1957
|
+
2. **## Conventions** \u2014 add project-specific patterns you observe (naming, imports, state management, API patterns)
|
|
1958
|
+
3. **## Off-Limits** \u2014 add guardrails based on what you see (protected files, patterns to avoid, things that should never change)
|
|
1959
|
+
4. **## Key Decisions** \u2014 document any architectural decisions visible in the code
|
|
1960
|
+
|
|
1961
|
+
Rules:
|
|
1962
|
+
- Keep CLAUDE.md under 150 instructions (lines of actionable content)
|
|
1963
|
+
- Don't remove existing content \u2014 only add or improve
|
|
1964
|
+
- Be specific to THIS project, not generic advice
|
|
1965
|
+
- Use bullet points, not paragraphs`;
|
|
1966
|
+
function createEnhanceCommand() {
|
|
1967
|
+
return new Command4("enhance").description("Use Claude to analyze your codebase and complete CLAUDE.md").option("-p, --path <path>", "Project root path", process.cwd()).action(async (opts) => {
|
|
1968
|
+
printBanner();
|
|
1969
|
+
const root = opts.path;
|
|
1970
|
+
const claudeMdPath = join7(root, "CLAUDE.md");
|
|
1971
|
+
try {
|
|
1972
|
+
await access6(claudeMdPath);
|
|
1973
|
+
} catch {
|
|
1974
|
+
log.error("No CLAUDE.md found. Run `claude-launchpad init` first.");
|
|
1975
|
+
process.exit(1);
|
|
1976
|
+
}
|
|
1977
|
+
try {
|
|
1978
|
+
await execAsync("claude", ["--version"]);
|
|
1979
|
+
} catch {
|
|
1980
|
+
log.error("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code");
|
|
1981
|
+
process.exit(1);
|
|
1982
|
+
}
|
|
1983
|
+
log.step("Launching Claude to enhance your CLAUDE.md...");
|
|
1984
|
+
log.blank();
|
|
1985
|
+
const child = spawn(
|
|
1986
|
+
"claude",
|
|
1987
|
+
[ENHANCE_PROMPT],
|
|
1988
|
+
{ cwd: root, stdio: "inherit" }
|
|
1989
|
+
);
|
|
1990
|
+
await new Promise((resolve3) => {
|
|
1991
|
+
child.on("close", (code) => resolve3(code ?? 0));
|
|
1992
|
+
});
|
|
1993
|
+
log.blank();
|
|
1994
|
+
log.success("Run `claude-launchpad doctor` to check your updated score.");
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/cli.ts
|
|
1999
|
+
var program = new Command5().name("claude-launchpad").description("CLI toolkit that makes Claude Code setups measurably good").version("0.1.0").action(async () => {
|
|
2000
|
+
const hasConfig = await fileExists3(join8(process.cwd(), "CLAUDE.md")) || await fileExists3(join8(process.cwd(), ".claude", "settings.json"));
|
|
2001
|
+
if (hasConfig) {
|
|
2002
|
+
await program.commands.find((c) => c.name() === "doctor")?.parseAsync([], { from: "user" });
|
|
2003
|
+
} else {
|
|
2004
|
+
printBanner();
|
|
2005
|
+
log.info("No Claude Code config found in this directory.");
|
|
2006
|
+
log.blank();
|
|
2007
|
+
log.step("Run `claude-launchpad init` to set up your project");
|
|
2008
|
+
log.step("Run `claude-launchpad doctor` to diagnose an existing config");
|
|
2009
|
+
log.step("Run `claude-launchpad eval` to test your config quality");
|
|
2010
|
+
log.blank();
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
program.addCommand(createInitCommand());
|
|
2014
|
+
program.addCommand(createDoctorCommand());
|
|
2015
|
+
program.addCommand(createEnhanceCommand());
|
|
2016
|
+
program.addCommand(createEvalCommand());
|
|
2017
|
+
program.parse();
|
|
2018
|
+
async function fileExists3(path) {
|
|
2019
|
+
try {
|
|
2020
|
+
await access7(path);
|
|
2021
|
+
return true;
|
|
2022
|
+
} catch {
|
|
2023
|
+
return false;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
//# sourceMappingURL=cli.js.map
|