@spardutti/claude-skills 2.32.0 → 2.32.2
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/bin/cli.mjs +4 -158
- package/lib/auto-mode.mjs +85 -0
- package/lib/frontmatter.mjs +49 -0
- package/lib/github.mjs +1 -33
- package/lib/hook-settings.mjs +140 -0
- package/lib/local.mjs +3 -134
- package/lib/post-install.mjs +163 -0
- package/lib/setup-hook.mjs +3 -200
- package/lib/tool-needs.mjs +123 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { confirm
|
|
3
|
+
import { confirm } from "@inquirer/prompts";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,16 +8,8 @@ import { dirname, join } from "node:path";
|
|
|
8
8
|
import { fetchSkills, fetchCommands, fetchAgents } from "../lib/github.mjs";
|
|
9
9
|
import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../lib/prompt.mjs";
|
|
10
10
|
import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
detectStack,
|
|
14
|
-
writeGauntletConf,
|
|
15
|
-
autoModeRuleStatus,
|
|
16
|
-
writeAutoModeRule,
|
|
17
|
-
} from "../lib/setup-hook.mjs";
|
|
18
|
-
import { makeLocalSource, reportToolNeeds } from "../lib/local.mjs";
|
|
19
|
-
import { scaffoldStryker } from "../lib/scaffold-stryker.mjs";
|
|
20
|
-
import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
|
|
11
|
+
import { makeLocalSource } from "../lib/local.mjs";
|
|
12
|
+
import { runPostInstall } from "../lib/post-install.mjs";
|
|
21
13
|
import {
|
|
22
14
|
readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
|
|
23
15
|
MANIFEST_FILE,
|
|
@@ -165,153 +157,7 @@ async function main() {
|
|
|
165
157
|
process.exit(0);
|
|
166
158
|
}
|
|
167
159
|
|
|
168
|
-
|
|
169
|
-
// Offered whenever anything was installed: the skill gates need skills, but the
|
|
170
|
-
// gauntlet guards any repo with a test runner, commands-only installs included.
|
|
171
|
-
if (selectedSkills.length > 0 || selectedCommands.length > 0) {
|
|
172
|
-
console.log();
|
|
173
|
-
const shouldSetup = await confirm({
|
|
174
|
-
message: "Set up hooks — skill evaluation before edits, verification after? (Recommended)",
|
|
175
|
-
default: true,
|
|
176
|
-
});
|
|
177
|
-
if (shouldSetup) {
|
|
178
|
-
console.log();
|
|
179
|
-
await setupHook();
|
|
180
|
-
await setupClaudeMd();
|
|
181
|
-
|
|
182
|
-
// In auto mode the classifier denies the gate's ack, and it only reads
|
|
183
|
-
// autoMode.allow from the user's own ~/.claude/settings.json. Without this
|
|
184
|
-
// the gate cannot be satisfied at all, and the session cannot fix it
|
|
185
|
-
// either — an agent editing permission settings is denied by the same
|
|
186
|
-
// classifier. The installer is the only thing here running as the user.
|
|
187
|
-
const autoModeStatus = await autoModeRuleStatus();
|
|
188
|
-
if (autoModeStatus === "unreadable") {
|
|
189
|
-
console.log();
|
|
190
|
-
console.log(` ${chalk.yellow("!")} ${chalk.dim("~/.claude/settings.json is not valid JSON — skipping the auto mode rule.")}`);
|
|
191
|
-
} else if (autoModeStatus !== "current") {
|
|
192
|
-
console.log();
|
|
193
|
-
console.log(chalk.dim(" Auto mode's classifier denies the gate's acknowledgement files, and"));
|
|
194
|
-
console.log(chalk.dim(" clearing that needs a rule in your own ~/.claude/settings.json. Claude"));
|
|
195
|
-
console.log(chalk.dim(" cannot add it — editing permission settings is denied to agents too."));
|
|
196
|
-
const addRule = await confirm({
|
|
197
|
-
message:
|
|
198
|
-
autoModeStatus === "stale"
|
|
199
|
-
? "Update the auto mode allow rule in ~/.claude/settings.json? (Recommended)"
|
|
200
|
-
: "Add an auto mode allow rule to ~/.claude/settings.json? (Recommended)",
|
|
201
|
-
default: true,
|
|
202
|
-
});
|
|
203
|
-
if (addRule) {
|
|
204
|
-
try {
|
|
205
|
-
const written = await writeAutoModeRule();
|
|
206
|
-
console.log(` Settings updated: ${written}`);
|
|
207
|
-
} catch (err) {
|
|
208
|
-
console.log(` ${chalk.yellow("!")} ${chalk.dim(err.message)}`);
|
|
209
|
-
}
|
|
210
|
-
} else {
|
|
211
|
-
console.log(` ${chalk.dim("Skipped. In auto mode the gate will block until you add it via /permissions.")}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
216
|
-
// This asks the installed hook itself — never a second copy of its logic.
|
|
217
|
-
const gates = await detectStack(CWD);
|
|
218
|
-
if (gates) {
|
|
219
|
-
for (const gate of gates.split(";").map((g) => g.trim()).filter(Boolean)) {
|
|
220
|
-
console.log(` ${chalk.dim(`Gauntlet gate: ${gate}`)}`);
|
|
221
|
-
}
|
|
222
|
-
} else {
|
|
223
|
-
console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
|
|
224
|
-
console.log();
|
|
225
|
-
const wantConf = await confirm({
|
|
226
|
-
message: "Point it at your test command now?",
|
|
227
|
-
default: false,
|
|
228
|
-
});
|
|
229
|
-
if (wantConf) {
|
|
230
|
-
const cmd = (await input({ message: "Test command:" })).trim();
|
|
231
|
-
if (cmd) {
|
|
232
|
-
const written = await writeGauntletConf(CWD, cmd);
|
|
233
|
-
console.log(` Config written: ${written.replace(CWD + "/", "")}`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// --- What the installed commands need that this project does not have ---
|
|
241
|
-
// /ship's mutation check is per project, so a monorepo needs a tool per project.
|
|
242
|
-
if (selectedCommands.some((c) => c.fileName === "ship.md")) {
|
|
243
|
-
const needs = await reportToolNeeds(CWD);
|
|
244
|
-
console.log();
|
|
245
|
-
if (needs.length === 0) {
|
|
246
|
-
console.log(` ${chalk.dim("/ship's mutation check has a tool in every project here.")}`);
|
|
247
|
-
} else {
|
|
248
|
-
console.log(` ${chalk.yellow("!")} ${chalk.bold("/ship needs a mutation tool per project")} ${chalk.dim("— without one it reports UNPROVEN, and never blocks.")}`);
|
|
249
|
-
if (!selectedSkills.some((s) => s.dirName === "testing-best-practices")) {
|
|
250
|
-
console.log(` ${chalk.dim("Setting these up has traps — install the testing-best-practices skill for MUTATION-TESTING.md.")}`);
|
|
251
|
-
}
|
|
252
|
-
for (const n of needs.filter((n) => !n.ignorerOnly)) {
|
|
253
|
-
console.log(` ${chalk.bold(n.label)} needs ${n.tool}`);
|
|
254
|
-
console.log(` ${chalk.cyan(n.install)}`);
|
|
255
|
-
console.log(` ${chalk.dim(n.config)}`);
|
|
256
|
-
for (const line of n.also ?? []) console.log(` ${chalk.dim(line)}`);
|
|
257
|
-
}
|
|
258
|
-
for (const n of needs.filter((n) => n.ignorerOnly)) {
|
|
259
|
-
console.log(` ${chalk.bold(n.label)} runs ${n.tool} without the class-name ignorer`);
|
|
260
|
-
console.log(` ${chalk.dim("~40% of a first React run is mutants on className strings, which no test can honestly kill")}`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Printing five steps meant every project was set up by hand, and the one
|
|
264
|
-
// step people skipped was the class-name ignorer — the reason a first run
|
|
265
|
-
// comes back with dozens of mutants on className strings that no test can
|
|
266
|
-
// honestly kill. Write the two files instead of describing them.
|
|
267
|
-
const stryker = needs.filter((n) => n.tool === "Stryker");
|
|
268
|
-
if (stryker.length > 0) {
|
|
269
|
-
console.log();
|
|
270
|
-
const doScaffold = await confirm({
|
|
271
|
-
message: `Write Stryker's config and the class-name ignorer for ${stryker.length} project(s)?`,
|
|
272
|
-
default: true,
|
|
273
|
-
});
|
|
274
|
-
if (doScaffold) {
|
|
275
|
-
const installs = [];
|
|
276
|
-
const apiInstalls = [];
|
|
277
|
-
const vitest = [];
|
|
278
|
-
for (const n of stryker) {
|
|
279
|
-
const r = await scaffoldStryker(CWD, n.rel);
|
|
280
|
-
for (const f of r.wrote) console.log(` Wrote: ${f}`);
|
|
281
|
-
for (const f of r.patched) console.log(` Added the ignorer to: ${f}`);
|
|
282
|
-
for (const f of r.kept) console.log(` ${chalk.dim(`Kept yours: ${f}`)}`);
|
|
283
|
-
for (const f of r.unreadable) {
|
|
284
|
-
console.log(` ${chalk.yellow("!")} ${f} is not readable as JSON — left alone, add the ignorer by hand`);
|
|
285
|
-
}
|
|
286
|
-
// A project that already runs Stryker needs neither the install nor
|
|
287
|
-
// the vitest exclude repeated at it; it only lacked the ignorer.
|
|
288
|
-
if (n.ignorerOnly) {
|
|
289
|
-
apiInstalls.push(r.apiInstall);
|
|
290
|
-
} else {
|
|
291
|
-
installs.push(r.install);
|
|
292
|
-
vitest.push(r.vitest);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
// Left to the reader on purpose: installing touches the lockfile and
|
|
296
|
-
// the network, and editing an existing vitest config means parsing
|
|
297
|
-
// someone's plugins and aliases, where being wrong breaks their tests.
|
|
298
|
-
if (installs.length > 0) {
|
|
299
|
-
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("Two steps left, per project:")}`);
|
|
300
|
-
for (const c of installs) console.log(` ${chalk.cyan(c)}`);
|
|
301
|
-
for (const v of vitest) console.log(` ${chalk.dim(v)}`);
|
|
302
|
-
}
|
|
303
|
-
// The ignorer imports @stryker-mutator/api, which npm hoists by
|
|
304
|
-
// accident and pnpm does not. Missing, it prints one WARN PluginLoader
|
|
305
|
-
// line, loads nothing, and every className mutant comes back survived.
|
|
306
|
-
if (apiInstalls.length > 0) {
|
|
307
|
-
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("The ignorer needs its own dependency:")}`);
|
|
308
|
-
for (const c of apiInstalls) console.log(` ${chalk.cyan(c)}`);
|
|
309
|
-
console.log(` ${chalk.dim("without it the ignorer silently does not load")}`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
160
|
+
await runPostInstall({ CWD, selectedSkills, selectedCommands });
|
|
315
161
|
|
|
316
162
|
// --- Record what is now installed ---
|
|
317
163
|
await writeManifest(CWD, {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The permissions rule that lets the skill gates clear themselves under auto
|
|
2
|
+
// mode. Separate from installing the hooks: this writes the USER's global
|
|
3
|
+
// settings, that writes a project's, and only one of them can be done for you.
|
|
4
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// The project-level permissions.allow written above does not clear auto mode.
|
|
10
|
+
// The classifier reads autoMode.allow, and only from the user's own
|
|
11
|
+
// ~/.claude/settings.json — it deliberately ignores a project's settings so a
|
|
12
|
+
// cloned repo cannot loosen it. So the gate's ack was denied, and the remedy the
|
|
13
|
+
// deny message named could not be applied by the agent either: the classifier
|
|
14
|
+
// refuses an agent editing permission settings by any route. That left a
|
|
15
|
+
// deadlock nothing in the session could break. The installer can, because npx
|
|
16
|
+
// runs as the user, not as an agent — but only ever behind an explicit prompt,
|
|
17
|
+
// since this is the user's global file.
|
|
18
|
+
export const AUTO_MODE_RULE =
|
|
19
|
+
"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-* " +
|
|
20
|
+
"and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for the " +
|
|
21
|
+
"user's own PreToolUse skill gates. Creating one satisfies a workflow gate the user " +
|
|
22
|
+
"installed, grants no permission and executes no code, and is a false positive for " +
|
|
23
|
+
"Auto-Mode Bypass.";
|
|
24
|
+
|
|
25
|
+
// Any entry naming a marker path is ours, whatever wording it shipped with. An
|
|
26
|
+
// upgrade must correct that entry rather than add a second one beside it —
|
|
27
|
+
// a fresh-install test would never see the difference.
|
|
28
|
+
const AUTO_MODE_RULE_MARK = "/tmp/claude-skill-acked-";
|
|
29
|
+
|
|
30
|
+
function globalSettingsPath(homeDir) {
|
|
31
|
+
return join(homeDir ?? homedir(), ".claude", "settings.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readGlobalSettings(homeDir) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await readFile(globalSettingsPath(homeDir), "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// "current" — the rule is present and matches. "stale" — an older wording is
|
|
43
|
+
// there. "missing" — nothing of ours. "unreadable" — the file exists but does
|
|
44
|
+
// not parse, so we must not rewrite it.
|
|
45
|
+
export async function autoModeRuleStatus(homeDir) {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = await readFile(globalSettingsPath(homeDir), "utf-8");
|
|
49
|
+
} catch {
|
|
50
|
+
return "missing";
|
|
51
|
+
}
|
|
52
|
+
let settings;
|
|
53
|
+
try {
|
|
54
|
+
settings = JSON.parse(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return "unreadable";
|
|
57
|
+
}
|
|
58
|
+
const allow = settings?.autoMode?.allow;
|
|
59
|
+
if (!Array.isArray(allow)) return "missing";
|
|
60
|
+
if (allow.includes(AUTO_MODE_RULE)) return "current";
|
|
61
|
+
if (allow.some((r) => typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK))) return "stale";
|
|
62
|
+
return "missing";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Writes the rule into ~/.claude/settings.json, keeping "$defaults" first —
|
|
66
|
+
// dropping it discards every built-in allow. Everything else in the file is
|
|
67
|
+
// preserved. Refuses an unparseable file rather than replacing it.
|
|
68
|
+
export async function writeAutoModeRule(homeDir) {
|
|
69
|
+
if ((await autoModeRuleStatus(homeDir)) === "unreadable") {
|
|
70
|
+
throw new Error(`${globalSettingsPath(homeDir)} is not valid JSON — fix it, then re-run.`);
|
|
71
|
+
}
|
|
72
|
+
const settings = (await readGlobalSettings(homeDir)) ?? {};
|
|
73
|
+
if (!settings.autoMode || typeof settings.autoMode !== "object") settings.autoMode = {};
|
|
74
|
+
const existing = Array.isArray(settings.autoMode.allow) ? settings.autoMode.allow : [];
|
|
75
|
+
|
|
76
|
+
const kept = existing.filter(
|
|
77
|
+
(r) => r !== "$defaults" && !(typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK)),
|
|
78
|
+
);
|
|
79
|
+
settings.autoMode.allow = ["$defaults", ...kept, AUTO_MODE_RULE];
|
|
80
|
+
|
|
81
|
+
const path = globalSettingsPath(homeDir);
|
|
82
|
+
await mkdir(dirname(path), { recursive: true });
|
|
83
|
+
await writeFile(path, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
84
|
+
return path;
|
|
85
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The one parser for a SKILL.md / command / agent frontmatter block.
|
|
2
|
+
//
|
|
3
|
+
// There used to be two — one in github.mjs, one in local.mjs — written
|
|
4
|
+
// differently, and they disagreed on four of five inputs:
|
|
5
|
+
//
|
|
6
|
+
// description: "d" github kept the quotes, local stripped them
|
|
7
|
+
// category: "Q" same
|
|
8
|
+
// requires-agents: p, q github returned [], local returned ["p","q"]
|
|
9
|
+
//
|
|
10
|
+
// So `--local` and a real install read the same file differently. The quote
|
|
11
|
+
// difference was masked downstream by a stripQuotes() in prompt.mjs, which is
|
|
12
|
+
// what let it live: the picker looked right, so nobody looked further.
|
|
13
|
+
//
|
|
14
|
+
// Behaviour kept here is local.mjs's, because it was the correct one on every
|
|
15
|
+
// case: quotes stripped, and both `[a, b]` and `a, b` accepted for a list.
|
|
16
|
+
export function parseFrontmatter(content, fallbackName) {
|
|
17
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return { name: fallbackName, description: "", category: "General", requiresAgents: [] };
|
|
20
|
+
}
|
|
21
|
+
const block = match[1];
|
|
22
|
+
|
|
23
|
+
// A value may be quoted or bare. Descriptions are quoted in every skill this
|
|
24
|
+
// repo ships, so returning the quotes is wrong for the common case, not the
|
|
25
|
+
// edge one.
|
|
26
|
+
const pick = (key) => {
|
|
27
|
+
const m = block.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
28
|
+
return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: pick("name") || fallbackName,
|
|
33
|
+
description: pick("description"),
|
|
34
|
+
category: pick("category") || "General",
|
|
35
|
+
requiresAgents: parseAgentList(pick("requires-agents")),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `requires-agents: [a, b]` is what every command in this repo writes, but the
|
|
40
|
+
// brackets are YAML flow syntax rather than part of the value, so a bare
|
|
41
|
+
// `a, b` means the same thing and must parse the same way.
|
|
42
|
+
export function parseAgentList(raw) {
|
|
43
|
+
if (!raw) return [];
|
|
44
|
+
return raw
|
|
45
|
+
.replace(/[[\]]/g, "")
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
package/lib/github.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
1
2
|
import { execSync } from "node:child_process";
|
|
2
3
|
|
|
3
4
|
const REPO_OWNER = "Spardutti";
|
|
@@ -172,36 +173,3 @@ export function fetchAgents() {
|
|
|
172
173
|
},
|
|
173
174
|
});
|
|
174
175
|
}
|
|
175
|
-
|
|
176
|
-
function parseFrontmatter(content, fallbackName) {
|
|
177
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
178
|
-
if (!match) {
|
|
179
|
-
return { name: fallbackName, description: "", category: "General", requiresAgents: [] };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const block = match[1];
|
|
183
|
-
const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
|
|
184
|
-
const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() || "";
|
|
185
|
-
const category = block.match(/^category:\s*(.+)$/m)?.[1]?.trim() || "General";
|
|
186
|
-
const requiresAgents = parseAgentList(block);
|
|
187
|
-
|
|
188
|
-
return { name, description, category, requiresAgents };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function parseAgentList(block) {
|
|
192
|
-
const inline = block.match(/^requires-agents:\s*\[([^\]]*)\]\s*$/m);
|
|
193
|
-
if (inline) {
|
|
194
|
-
return inline[1]
|
|
195
|
-
.split(",")
|
|
196
|
-
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
197
|
-
.filter(Boolean);
|
|
198
|
-
}
|
|
199
|
-
const multiline = block.match(/^requires-agents:\s*\n((?:\s{2,}-\s*.+\n?)+)/m);
|
|
200
|
-
if (multiline) {
|
|
201
|
-
return multiline[1]
|
|
202
|
-
.split("\n")
|
|
203
|
-
.map((l) => l.replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, ""))
|
|
204
|
-
.filter(Boolean);
|
|
205
|
-
}
|
|
206
|
-
return [];
|
|
207
|
-
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Registering the hooks in .claude/settings.json — which is a different job
|
|
2
|
+
// from writing the hook files, and the one with all the upgrade traps: a hook
|
|
3
|
+
// already registered under an old matcher stays on it unless corrected, and a
|
|
4
|
+
// legacy entry from an earlier version has to be removed rather than ignored.
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
const GATE_FILENAME = "skill-gate.sh";
|
|
8
|
+
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
9
|
+
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
10
|
+
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
11
|
+
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
12
|
+
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
13
|
+
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
14
|
+
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
15
|
+
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
// Register the entry, or CORRECT the one already there. Skipping when a hook of
|
|
19
|
+
// this name exists leaves an existing install pinned to whatever matcher it was
|
|
20
|
+
// first written with — which is how the skill gates kept watching only
|
|
21
|
+
// Write|Edit|MultiEdit after they learned to cover Bash.
|
|
22
|
+
function upsert(list, filename, entry) {
|
|
23
|
+
const existing = list.find((e) => e.hooks?.some((h) => h.command?.endsWith(filename)));
|
|
24
|
+
if (!existing) return list.push(entry);
|
|
25
|
+
if (entry.matcher === undefined) delete existing.matcher;
|
|
26
|
+
else existing.matcher = entry.matcher;
|
|
27
|
+
existing.hooks = entry.hooks;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function registerHooks(settingsPath) {
|
|
31
|
+
let settings = {};
|
|
32
|
+
try {
|
|
33
|
+
settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
|
34
|
+
} catch {
|
|
35
|
+
// missing or invalid — start fresh
|
|
36
|
+
}
|
|
37
|
+
if (!settings.hooks) settings.hooks = {};
|
|
38
|
+
|
|
39
|
+
// Clean up legacy UserPromptSubmit eval hook (replaced by the gate).
|
|
40
|
+
if (Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
41
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
42
|
+
(entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_EVAL_FILENAME))
|
|
43
|
+
);
|
|
44
|
+
if (settings.hooks.UserPromptSubmit.length === 0) {
|
|
45
|
+
delete settings.hooks.UserPromptSubmit;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Clean up legacy PreToolUse audit-runner hook (2.3.x — removed in 2.4.0).
|
|
50
|
+
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
51
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
52
|
+
(entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_AUDIT_RUNNER_FILENAME))
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Register PreToolUse gate.
|
|
57
|
+
const gateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GATE_FILENAME}`;
|
|
58
|
+
const gateEntry = {
|
|
59
|
+
matcher: "Write|Edit|MultiEdit|Bash",
|
|
60
|
+
hooks: [{ type: "command", command: gateCommand }],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
64
|
+
upsert(settings.hooks.PreToolUse, GATE_FILENAME, gateEntry);
|
|
65
|
+
} else {
|
|
66
|
+
settings.hooks.PreToolUse = [gateEntry];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Register PreToolUse application gate (after the loading gate).
|
|
70
|
+
const applicationCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${APPLICATION_GATE_FILENAME}`;
|
|
71
|
+
const applicationEntry = {
|
|
72
|
+
matcher: "Write|Edit|MultiEdit|Bash",
|
|
73
|
+
hooks: [{ type: "command", command: applicationCommand }],
|
|
74
|
+
};
|
|
75
|
+
upsert(settings.hooks.PreToolUse, APPLICATION_GATE_FILENAME, applicationEntry);
|
|
76
|
+
|
|
77
|
+
// Register PreToolUse ship-gate receipt check on Bash.
|
|
78
|
+
const shipGateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SHIP_GATE_HOOK_FILENAME}`;
|
|
79
|
+
const shipGateEntry = {
|
|
80
|
+
matcher: "Bash",
|
|
81
|
+
hooks: [{ type: "command", command: shipGateCommand }],
|
|
82
|
+
};
|
|
83
|
+
upsert(settings.hooks.PreToolUse, SHIP_GATE_HOOK_FILENAME, shipGateEntry);
|
|
84
|
+
|
|
85
|
+
// Register PostToolUse auto-mark on Skill.
|
|
86
|
+
const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
|
|
87
|
+
const autoMarkEntry = {
|
|
88
|
+
matcher: "Skill",
|
|
89
|
+
hooks: [{ type: "command", command: autoMarkCommand }],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(settings.hooks.PostToolUse)) {
|
|
93
|
+
upsert(settings.hooks.PostToolUse, AUTO_MARK_FILENAME, autoMarkEntry);
|
|
94
|
+
} else {
|
|
95
|
+
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Register SessionStart version check. No matcher: startup and resume both
|
|
99
|
+
// want it, and the script's own daily stamp keeps a burst of resumes quiet.
|
|
100
|
+
const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
|
|
101
|
+
const versionCheckEntry = { hooks: [{ type: "command", command: versionCheckCommand }] };
|
|
102
|
+
|
|
103
|
+
if (Array.isArray(settings.hooks.SessionStart)) {
|
|
104
|
+
upsert(settings.hooks.SessionStart, VERSION_CHECK_FILENAME, versionCheckEntry);
|
|
105
|
+
} else {
|
|
106
|
+
settings.hooks.SessionStart = [versionCheckEntry];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Register Stop gauntlet hook.
|
|
110
|
+
const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
|
|
111
|
+
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
|
112
|
+
|
|
113
|
+
if (Array.isArray(settings.hooks.Stop)) {
|
|
114
|
+
upsert(settings.hooks.Stop, GAUNTLET_FILENAME, gauntletEntry);
|
|
115
|
+
} else {
|
|
116
|
+
settings.hooks.Stop = [gauntletEntry];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Both gates clear by touching a marker under /tmp. The hook scripts let those
|
|
120
|
+
// commands through themselves, but auto mode's permission classifier does not:
|
|
121
|
+
// a touch whose only purpose is to unlock a gate is exactly what it refuses.
|
|
122
|
+
// So in auto mode the application gate could not be satisfied at all, and the
|
|
123
|
+
// model was reduced to asking its user to run the touch by hand — which is
|
|
124
|
+
// indistinguishable from the laundering the classifier exists to stop.
|
|
125
|
+
// Allowlisting the three marker paths is what makes the gates workable there.
|
|
126
|
+
// Only these literal prefixes are granted, and an existing entry is not
|
|
127
|
+
// duplicated on reinstall.
|
|
128
|
+
const MARKER_ALLOW = [
|
|
129
|
+
"Bash(touch /tmp/claude-skill-gate-*)",
|
|
130
|
+
"Bash(touch /tmp/claude-skill-acked-*)",
|
|
131
|
+
"Bash(touch /tmp/claude-skill-loaded-*)",
|
|
132
|
+
];
|
|
133
|
+
if (!settings.permissions) settings.permissions = {};
|
|
134
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
135
|
+
for (const rule of MARKER_ALLOW) {
|
|
136
|
+
if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
140
|
+
}
|
package/lib/local.mjs
CHANGED
|
@@ -3,54 +3,11 @@
|
|
|
3
3
|
// publishing it first.
|
|
4
4
|
import { readdir, readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import { promisify } from "node:util";
|
|
6
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function parseFrontmatter(content, fallbackName) {
|
|
12
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
13
|
-
if (!match) return { name: fallbackName, description: "", category: "General", requiresAgents: [] };
|
|
14
|
-
const fm = match[1];
|
|
15
|
-
const pick = (key) => {
|
|
16
|
-
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
17
|
-
return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
|
|
18
|
-
};
|
|
19
|
-
const raw = pick("requires-agents");
|
|
20
|
-
return {
|
|
21
|
-
name: pick("name") || fallbackName,
|
|
22
|
-
description: pick("description"),
|
|
23
|
-
category: pick("category") || "General",
|
|
24
|
-
requiresAgents: raw ? raw.replace(/[[\]]/g, "").split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function listDir(dir) {
|
|
8
|
+
export async function listDir(dir) {
|
|
29
9
|
try { return await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
30
10
|
}
|
|
31
|
-
|
|
32
|
-
// Build output is not a project. Next.js writes a package.json into .next/,
|
|
33
|
-
// .next/dev/ and .next/standalone/, so a scan that skipped only a hardcoded
|
|
34
|
-
// list reported three phantom projects on a plain Next app — and then filtered
|
|
35
|
-
// the real repo root out as "a root that delegates to a workspace". A second
|
|
36
|
-
// list of build dirs drifts from .gitignore the moment a framework adds one, so
|
|
37
|
-
// ask git the way ship-gate.sh already does. --directory collapses a fully
|
|
38
|
-
// ignored tree to its own name, which is what keeps this output small.
|
|
39
|
-
async function gitIgnoredDirs(projectDir) {
|
|
40
|
-
try {
|
|
41
|
-
const { stdout } = await exec(
|
|
42
|
-
"git",
|
|
43
|
-
["-C", projectDir, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
|
|
44
|
-
{ maxBuffer: 10 * 1024 * 1024 },
|
|
45
|
-
);
|
|
46
|
-
return new Set(
|
|
47
|
-
stdout.split("\n").filter((l) => l.endsWith("/")).map((l) => l.slice(0, -1)),
|
|
48
|
-
);
|
|
49
|
-
} catch {
|
|
50
|
-
return new Set();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
11
|
export function makeLocalSource(root) {
|
|
55
12
|
return {
|
|
56
13
|
async fetchSkills() {
|
|
@@ -125,92 +82,4 @@ async function pythonAdvice(dir, rel, entries) {
|
|
|
125
82
|
install,
|
|
126
83
|
config: `${rel ? rel + "/" : ""}pyproject.toml [tool.mutmut] source_paths=["${source}/"] pytest_add_cli_args_test_selection=["${tests}/"]`,
|
|
127
84
|
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// What the installed commands need that this project does not have yet.
|
|
131
|
-
// ship-gate.sh's own detection drives a single-package repo; a monorepo whose
|
|
132
|
-
// tools need a workspace prefix or a container sets GAUNTLET_MUTATE instead. Only
|
|
133
|
-
// /ship needs anything: a mutation tool, per project, since a monorepo can hold
|
|
134
|
-
// several and they do not share one.
|
|
135
|
-
export async function reportToolNeeds(projectDir) {
|
|
136
|
-
const needs = [];
|
|
137
|
-
const seen = new Set();
|
|
138
|
-
// Whether a nested project of each kind exists at all — not whether it still
|
|
139
|
-
// needs a tool. A root that delegates is not the place to install, even once
|
|
140
|
-
// the workspace it delegates to is already set up.
|
|
141
|
-
const nested = { Stryker: false, mutmut: false };
|
|
142
|
-
// Empty outside a git repo, so the hardcoded list below still carries those.
|
|
143
|
-
const ignored = await gitIgnoredDirs(projectDir);
|
|
144
|
-
|
|
145
|
-
async function scan(rel, depth) {
|
|
146
|
-
if (depth > 2) return;
|
|
147
|
-
const dir = rel ? join(projectDir, rel) : projectDir;
|
|
148
|
-
const entries = await listDir(dir);
|
|
149
|
-
const label = rel ? `${rel}/` : "<repo root>";
|
|
150
|
-
|
|
151
|
-
const pkg = entries.find((e) => e.isFile() && e.name === "package.json");
|
|
152
|
-
const pyp = entries.find((e) => e.isFile() && (e.name === "pyproject.toml" || e.name === "pytest.ini"));
|
|
153
|
-
|
|
154
|
-
if (pkg && rel) nested.Stryker = true;
|
|
155
|
-
if (pyp && rel) nested.mutmut = true;
|
|
156
|
-
|
|
157
|
-
if (pkg && !seen.has(label)) {
|
|
158
|
-
seen.add(label);
|
|
159
|
-
const text = await readFile(join(dir, "package.json"), "utf-8");
|
|
160
|
-
if (!text.includes("@stryker-mutator/core")) {
|
|
161
|
-
const at = rel ? rel + "/" : "";
|
|
162
|
-
needs.push({
|
|
163
|
-
label,
|
|
164
|
-
rel,
|
|
165
|
-
tool: "Stryker",
|
|
166
|
-
install: `npm --prefix ${rel || "."} i -D @stryker-mutator/core @stryker-mutator/vitest-runner`,
|
|
167
|
-
config: `${at}stryker.config.json {"testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"perTest"}`,
|
|
168
|
-
// Stryker copies the whole project into .stryker-tmp/sandbox-*/, so
|
|
169
|
-
// every test file briefly exists twice and the runner collects both.
|
|
170
|
-
// The duplicates pass, so a doubled test count is the only symptom.
|
|
171
|
-
// Spread configDefaults.exclude — assigning it drops node_modules.
|
|
172
|
-
also: [
|
|
173
|
-
`${at}vitest.config.ts exclude: [...configDefaults.exclude, "**/.stryker-tmp/**"]`,
|
|
174
|
-
`${at}.gitignore .stryker-tmp/`,
|
|
175
|
-
],
|
|
176
|
-
});
|
|
177
|
-
} else {
|
|
178
|
-
// Stryker is installed, so this project is not missing a tool — but the
|
|
179
|
-
// projects that already run it are the ones grinding through mutants on
|
|
180
|
-
// className strings, and a setup done before the ignorer existed has no
|
|
181
|
-
// way to learn about it. Offer it here or it never reaches them.
|
|
182
|
-
let cfg = "";
|
|
183
|
-
try {
|
|
184
|
-
cfg = await readFile(join(dir, "stryker.config.json"), "utf-8");
|
|
185
|
-
} catch {
|
|
186
|
-
// no config of its own — the scaffolder writes one
|
|
187
|
-
}
|
|
188
|
-
if (!cfg.includes("tailwind-classnames")) {
|
|
189
|
-
needs.push({ label, rel, tool: "Stryker", ignorerOnly: true });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (pyp && !seen.has(label + ":py")) {
|
|
194
|
-
seen.add(label + ":py");
|
|
195
|
-
const text = pyp.name === "pyproject.toml" ? await readFile(join(dir, "pyproject.toml"), "utf-8") : "";
|
|
196
|
-
if (!text.includes("[tool.mutmut]")) {
|
|
197
|
-
const advice = await pythonAdvice(dir, rel, entries);
|
|
198
|
-
needs.push({ label, tool: "mutmut", ...advice });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
for (const e of entries) {
|
|
203
|
-
if (!e.isDirectory()) continue;
|
|
204
|
-
if (["node_modules", ".git", "dist", "build", ".venv", "venv", ".claude"].includes(e.name)) continue;
|
|
205
|
-
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
206
|
-
if (ignored.has(child)) continue;
|
|
207
|
-
await scan(child, depth + 1);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
await scan("", 0);
|
|
212
|
-
|
|
213
|
-
// A root package.json that delegates to a workspace (npm --prefix web run test)
|
|
214
|
-
// has no tests of its own, so asking for a mutation tool there is noise.
|
|
215
|
-
return needs.filter((n) => n.label !== "<repo root>" || !nested[n.tool]);
|
|
216
|
-
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Everything the installer does AFTER the user has chosen what to install:
|
|
2
|
+
// offer the hooks, then report what the installed commands need that this
|
|
3
|
+
// project does not have. Lifted out of a 270-line main() — these two are a
|
|
4
|
+
// phase of their own, they run only when something was installed, and neither
|
|
5
|
+
// decides what gets installed.
|
|
6
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { setupHook, detectStack, writeGauntletConf } from "./setup-hook.mjs";
|
|
9
|
+
import { autoModeRuleStatus, writeAutoModeRule } from "./auto-mode.mjs";
|
|
10
|
+
import { reportToolNeeds } from "./tool-needs.mjs";
|
|
11
|
+
import { scaffoldStryker } from "./scaffold-stryker.mjs";
|
|
12
|
+
import { setupClaudeMd } from "./setup-claude-md.mjs";
|
|
13
|
+
|
|
14
|
+
export async function runPostInstall({ CWD, selectedSkills, selectedCommands }) {
|
|
15
|
+
// --- Hook setup ---
|
|
16
|
+
// Offered whenever anything was installed: the skill gates need skills, but the
|
|
17
|
+
// gauntlet guards any repo with a test runner, commands-only installs included.
|
|
18
|
+
if (selectedSkills.length > 0 || selectedCommands.length > 0) {
|
|
19
|
+
console.log();
|
|
20
|
+
const shouldSetup = await confirm({
|
|
21
|
+
message: "Set up hooks — skill evaluation before edits, verification after? (Recommended)",
|
|
22
|
+
default: true,
|
|
23
|
+
});
|
|
24
|
+
if (shouldSetup) {
|
|
25
|
+
console.log();
|
|
26
|
+
await setupHook();
|
|
27
|
+
await setupClaudeMd();
|
|
28
|
+
|
|
29
|
+
// In auto mode the classifier denies the gate's ack, and it only reads
|
|
30
|
+
// autoMode.allow from the user's own ~/.claude/settings.json. Without this
|
|
31
|
+
// the gate cannot be satisfied at all, and the session cannot fix it
|
|
32
|
+
// either — an agent editing permission settings is denied by the same
|
|
33
|
+
// classifier. The installer is the only thing here running as the user.
|
|
34
|
+
const autoModeStatus = await autoModeRuleStatus();
|
|
35
|
+
if (autoModeStatus === "unreadable") {
|
|
36
|
+
console.log();
|
|
37
|
+
console.log(` ${chalk.yellow("!")} ${chalk.dim("~/.claude/settings.json is not valid JSON — skipping the auto mode rule.")}`);
|
|
38
|
+
} else if (autoModeStatus !== "current") {
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(chalk.dim(" Auto mode's classifier denies the gate's acknowledgement files, and"));
|
|
41
|
+
console.log(chalk.dim(" clearing that needs a rule in your own ~/.claude/settings.json. Claude"));
|
|
42
|
+
console.log(chalk.dim(" cannot add it — editing permission settings is denied to agents too."));
|
|
43
|
+
const addRule = await confirm({
|
|
44
|
+
message:
|
|
45
|
+
autoModeStatus === "stale"
|
|
46
|
+
? "Update the auto mode allow rule in ~/.claude/settings.json? (Recommended)"
|
|
47
|
+
: "Add an auto mode allow rule to ~/.claude/settings.json? (Recommended)",
|
|
48
|
+
default: true,
|
|
49
|
+
});
|
|
50
|
+
if (addRule) {
|
|
51
|
+
try {
|
|
52
|
+
const written = await writeAutoModeRule();
|
|
53
|
+
console.log(` Settings updated: ${written}`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(` ${chalk.yellow("!")} ${chalk.dim(err.message)}`);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.log(` ${chalk.dim("Skipped. In auto mode the gate will block until you add it via /permissions.")}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
63
|
+
// This asks the installed hook itself — never a second copy of its logic.
|
|
64
|
+
const gates = await detectStack(CWD);
|
|
65
|
+
if (gates) {
|
|
66
|
+
for (const gate of gates.split(";").map((g) => g.trim()).filter(Boolean)) {
|
|
67
|
+
console.log(` ${chalk.dim(`Gauntlet gate: ${gate}`)}`);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
|
|
71
|
+
console.log();
|
|
72
|
+
const wantConf = await confirm({
|
|
73
|
+
message: "Point it at your test command now?",
|
|
74
|
+
default: false,
|
|
75
|
+
});
|
|
76
|
+
if (wantConf) {
|
|
77
|
+
const cmd = (await input({ message: "Test command:" })).trim();
|
|
78
|
+
if (cmd) {
|
|
79
|
+
const written = await writeGauntletConf(CWD, cmd);
|
|
80
|
+
console.log(` Config written: ${written.replace(CWD + "/", "")}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- What the installed commands need that this project does not have ---
|
|
88
|
+
// /ship's mutation check is per project, so a monorepo needs a tool per project.
|
|
89
|
+
if (selectedCommands.some((c) => c.fileName === "ship.md")) {
|
|
90
|
+
const needs = await reportToolNeeds(CWD);
|
|
91
|
+
console.log();
|
|
92
|
+
if (needs.length === 0) {
|
|
93
|
+
console.log(` ${chalk.dim("/ship's mutation check has a tool in every project here.")}`);
|
|
94
|
+
} else {
|
|
95
|
+
console.log(` ${chalk.yellow("!")} ${chalk.bold("/ship needs a mutation tool per project")} ${chalk.dim("— without one it reports UNPROVEN, and never blocks.")}`);
|
|
96
|
+
if (!selectedSkills.some((s) => s.dirName === "testing-best-practices")) {
|
|
97
|
+
console.log(` ${chalk.dim("Setting these up has traps — install the testing-best-practices skill for MUTATION-TESTING.md.")}`);
|
|
98
|
+
}
|
|
99
|
+
for (const n of needs.filter((n) => !n.ignorerOnly)) {
|
|
100
|
+
console.log(` ${chalk.bold(n.label)} needs ${n.tool}`);
|
|
101
|
+
console.log(` ${chalk.cyan(n.install)}`);
|
|
102
|
+
console.log(` ${chalk.dim(n.config)}`);
|
|
103
|
+
for (const line of n.also ?? []) console.log(` ${chalk.dim(line)}`);
|
|
104
|
+
}
|
|
105
|
+
for (const n of needs.filter((n) => n.ignorerOnly)) {
|
|
106
|
+
console.log(` ${chalk.bold(n.label)} runs ${n.tool} without the class-name ignorer`);
|
|
107
|
+
console.log(` ${chalk.dim("~40% of a first React run is mutants on className strings, which no test can honestly kill")}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Printing five steps meant every project was set up by hand, and the one
|
|
111
|
+
// step people skipped was the class-name ignorer — the reason a first run
|
|
112
|
+
// comes back with dozens of mutants on className strings that no test can
|
|
113
|
+
// honestly kill. Write the two files instead of describing them.
|
|
114
|
+
const stryker = needs.filter((n) => n.tool === "Stryker");
|
|
115
|
+
if (stryker.length > 0) {
|
|
116
|
+
console.log();
|
|
117
|
+
const doScaffold = await confirm({
|
|
118
|
+
message: `Write Stryker's config and the class-name ignorer for ${stryker.length} project(s)?`,
|
|
119
|
+
default: true,
|
|
120
|
+
});
|
|
121
|
+
if (doScaffold) {
|
|
122
|
+
const installs = [];
|
|
123
|
+
const apiInstalls = [];
|
|
124
|
+
const vitest = [];
|
|
125
|
+
for (const n of stryker) {
|
|
126
|
+
const r = await scaffoldStryker(CWD, n.rel);
|
|
127
|
+
for (const f of r.wrote) console.log(` Wrote: ${f}`);
|
|
128
|
+
for (const f of r.patched) console.log(` Added the ignorer to: ${f}`);
|
|
129
|
+
for (const f of r.kept) console.log(` ${chalk.dim(`Kept yours: ${f}`)}`);
|
|
130
|
+
for (const f of r.unreadable) {
|
|
131
|
+
console.log(` ${chalk.yellow("!")} ${f} is not readable as JSON — left alone, add the ignorer by hand`);
|
|
132
|
+
}
|
|
133
|
+
// A project that already runs Stryker needs neither the install nor
|
|
134
|
+
// the vitest exclude repeated at it; it only lacked the ignorer.
|
|
135
|
+
if (n.ignorerOnly) {
|
|
136
|
+
apiInstalls.push(r.apiInstall);
|
|
137
|
+
} else {
|
|
138
|
+
installs.push(r.install);
|
|
139
|
+
vitest.push(r.vitest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Left to the reader on purpose: installing touches the lockfile and
|
|
143
|
+
// the network, and editing an existing vitest config means parsing
|
|
144
|
+
// someone's plugins and aliases, where being wrong breaks their tests.
|
|
145
|
+
if (installs.length > 0) {
|
|
146
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("Two steps left, per project:")}`);
|
|
147
|
+
for (const c of installs) console.log(` ${chalk.cyan(c)}`);
|
|
148
|
+
for (const v of vitest) console.log(` ${chalk.dim(v)}`);
|
|
149
|
+
}
|
|
150
|
+
// The ignorer imports @stryker-mutator/api, which npm hoists by
|
|
151
|
+
// accident and pnpm does not. Missing, it prints one WARN PluginLoader
|
|
152
|
+
// line, loads nothing, and every className mutant comes back survived.
|
|
153
|
+
if (apiInstalls.length > 0) {
|
|
154
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("The ignorer needs its own dependency:")}`);
|
|
155
|
+
for (const c of apiInstalls) console.log(` ${chalk.cyan(c)}`);
|
|
156
|
+
console.log(` ${chalk.dim("without it the ignorer silently does not load")}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerHooks } from "./hook-settings.mjs";
|
|
1
2
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
2
3
|
import { join, resolve, dirname } from "node:path";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
@@ -90,18 +91,6 @@ export async function writeGauntletConf(targetDir, testCommand) {
|
|
|
90
91
|
return confPath;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
// Register the entry, or CORRECT the one already there. Skipping when a hook of
|
|
94
|
-
// this name exists leaves an existing install pinned to whatever matcher it was
|
|
95
|
-
// first written with — which is how the skill gates kept watching only
|
|
96
|
-
// Write|Edit|MultiEdit after they learned to cover Bash.
|
|
97
|
-
function upsert(list, filename, entry) {
|
|
98
|
-
const existing = list.find((e) => e.hooks?.some((h) => h.command?.endsWith(filename)));
|
|
99
|
-
if (!existing) return list.push(entry);
|
|
100
|
-
if (entry.matcher === undefined) delete existing.matcher;
|
|
101
|
-
else existing.matcher = entry.matcher;
|
|
102
|
-
existing.hooks = entry.hooks;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
94
|
export async function setupHook(targetDir = process.cwd()) {
|
|
106
95
|
const resolved = resolve(targetDir);
|
|
107
96
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
@@ -129,115 +118,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
129
118
|
// not present — fine
|
|
130
119
|
}
|
|
131
120
|
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
|
135
|
-
} catch {
|
|
136
|
-
// missing or invalid — start fresh
|
|
137
|
-
}
|
|
138
|
-
if (!settings.hooks) settings.hooks = {};
|
|
139
|
-
|
|
140
|
-
// Clean up legacy UserPromptSubmit eval hook (replaced by the gate).
|
|
141
|
-
if (Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
142
|
-
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
143
|
-
(entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_EVAL_FILENAME))
|
|
144
|
-
);
|
|
145
|
-
if (settings.hooks.UserPromptSubmit.length === 0) {
|
|
146
|
-
delete settings.hooks.UserPromptSubmit;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Clean up legacy PreToolUse audit-runner hook (2.3.x — removed in 2.4.0).
|
|
151
|
-
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
152
|
-
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
153
|
-
(entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_AUDIT_RUNNER_FILENAME))
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Register PreToolUse gate.
|
|
158
|
-
const gateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GATE_FILENAME}`;
|
|
159
|
-
const gateEntry = {
|
|
160
|
-
matcher: "Write|Edit|MultiEdit|Bash",
|
|
161
|
-
hooks: [{ type: "command", command: gateCommand }],
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
165
|
-
upsert(settings.hooks.PreToolUse, GATE_FILENAME, gateEntry);
|
|
166
|
-
} else {
|
|
167
|
-
settings.hooks.PreToolUse = [gateEntry];
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Register PreToolUse application gate (after the loading gate).
|
|
171
|
-
const applicationCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${APPLICATION_GATE_FILENAME}`;
|
|
172
|
-
const applicationEntry = {
|
|
173
|
-
matcher: "Write|Edit|MultiEdit|Bash",
|
|
174
|
-
hooks: [{ type: "command", command: applicationCommand }],
|
|
175
|
-
};
|
|
176
|
-
upsert(settings.hooks.PreToolUse, APPLICATION_GATE_FILENAME, applicationEntry);
|
|
177
|
-
|
|
178
|
-
// Register PreToolUse ship-gate receipt check on Bash.
|
|
179
|
-
const shipGateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SHIP_GATE_HOOK_FILENAME}`;
|
|
180
|
-
const shipGateEntry = {
|
|
181
|
-
matcher: "Bash",
|
|
182
|
-
hooks: [{ type: "command", command: shipGateCommand }],
|
|
183
|
-
};
|
|
184
|
-
upsert(settings.hooks.PreToolUse, SHIP_GATE_HOOK_FILENAME, shipGateEntry);
|
|
185
|
-
|
|
186
|
-
// Register PostToolUse auto-mark on Skill.
|
|
187
|
-
const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
|
|
188
|
-
const autoMarkEntry = {
|
|
189
|
-
matcher: "Skill",
|
|
190
|
-
hooks: [{ type: "command", command: autoMarkCommand }],
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
if (Array.isArray(settings.hooks.PostToolUse)) {
|
|
194
|
-
upsert(settings.hooks.PostToolUse, AUTO_MARK_FILENAME, autoMarkEntry);
|
|
195
|
-
} else {
|
|
196
|
-
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Register SessionStart version check. No matcher: startup and resume both
|
|
200
|
-
// want it, and the script's own daily stamp keeps a burst of resumes quiet.
|
|
201
|
-
const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
|
|
202
|
-
const versionCheckEntry = { hooks: [{ type: "command", command: versionCheckCommand }] };
|
|
203
|
-
|
|
204
|
-
if (Array.isArray(settings.hooks.SessionStart)) {
|
|
205
|
-
upsert(settings.hooks.SessionStart, VERSION_CHECK_FILENAME, versionCheckEntry);
|
|
206
|
-
} else {
|
|
207
|
-
settings.hooks.SessionStart = [versionCheckEntry];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Register Stop gauntlet hook.
|
|
211
|
-
const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
|
|
212
|
-
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
|
213
|
-
|
|
214
|
-
if (Array.isArray(settings.hooks.Stop)) {
|
|
215
|
-
upsert(settings.hooks.Stop, GAUNTLET_FILENAME, gauntletEntry);
|
|
216
|
-
} else {
|
|
217
|
-
settings.hooks.Stop = [gauntletEntry];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Both gates clear by touching a marker under /tmp. The hook scripts let those
|
|
221
|
-
// commands through themselves, but auto mode's permission classifier does not:
|
|
222
|
-
// a touch whose only purpose is to unlock a gate is exactly what it refuses.
|
|
223
|
-
// So in auto mode the application gate could not be satisfied at all, and the
|
|
224
|
-
// model was reduced to asking its user to run the touch by hand — which is
|
|
225
|
-
// indistinguishable from the laundering the classifier exists to stop.
|
|
226
|
-
// Allowlisting the three marker paths is what makes the gates workable there.
|
|
227
|
-
// Only these literal prefixes are granted, and an existing entry is not
|
|
228
|
-
// duplicated on reinstall.
|
|
229
|
-
const MARKER_ALLOW = [
|
|
230
|
-
"Bash(touch /tmp/claude-skill-gate-*)",
|
|
231
|
-
"Bash(touch /tmp/claude-skill-acked-*)",
|
|
232
|
-
"Bash(touch /tmp/claude-skill-loaded-*)",
|
|
233
|
-
];
|
|
234
|
-
if (!settings.permissions) settings.permissions = {};
|
|
235
|
-
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
236
|
-
for (const rule of MARKER_ALLOW) {
|
|
237
|
-
if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
121
|
+
await registerHooks(settingsPath);
|
|
241
122
|
|
|
242
123
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
243
124
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
@@ -246,82 +127,4 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
246
127
|
console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
|
|
247
128
|
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
248
129
|
console.log(` Settings updated: .claude/settings.json`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// The project-level permissions.allow written above does not clear auto mode.
|
|
252
|
-
// The classifier reads autoMode.allow, and only from the user's own
|
|
253
|
-
// ~/.claude/settings.json — it deliberately ignores a project's settings so a
|
|
254
|
-
// cloned repo cannot loosen it. So the gate's ack was denied, and the remedy the
|
|
255
|
-
// deny message named could not be applied by the agent either: the classifier
|
|
256
|
-
// refuses an agent editing permission settings by any route. That left a
|
|
257
|
-
// deadlock nothing in the session could break. The installer can, because npx
|
|
258
|
-
// runs as the user, not as an agent — but only ever behind an explicit prompt,
|
|
259
|
-
// since this is the user's global file.
|
|
260
|
-
export const AUTO_MODE_RULE =
|
|
261
|
-
"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-* " +
|
|
262
|
-
"and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for the " +
|
|
263
|
-
"user's own PreToolUse skill gates. Creating one satisfies a workflow gate the user " +
|
|
264
|
-
"installed, grants no permission and executes no code, and is a false positive for " +
|
|
265
|
-
"Auto-Mode Bypass.";
|
|
266
|
-
|
|
267
|
-
// Any entry naming a marker path is ours, whatever wording it shipped with. An
|
|
268
|
-
// upgrade must correct that entry rather than add a second one beside it —
|
|
269
|
-
// a fresh-install test would never see the difference.
|
|
270
|
-
const AUTO_MODE_RULE_MARK = "/tmp/claude-skill-acked-";
|
|
271
|
-
|
|
272
|
-
function globalSettingsPath(homeDir) {
|
|
273
|
-
return join(homeDir ?? homedir(), ".claude", "settings.json");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function readGlobalSettings(homeDir) {
|
|
277
|
-
try {
|
|
278
|
-
return JSON.parse(await readFile(globalSettingsPath(homeDir), "utf-8"));
|
|
279
|
-
} catch {
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// "current" — the rule is present and matches. "stale" — an older wording is
|
|
285
|
-
// there. "missing" — nothing of ours. "unreadable" — the file exists but does
|
|
286
|
-
// not parse, so we must not rewrite it.
|
|
287
|
-
export async function autoModeRuleStatus(homeDir) {
|
|
288
|
-
let raw;
|
|
289
|
-
try {
|
|
290
|
-
raw = await readFile(globalSettingsPath(homeDir), "utf-8");
|
|
291
|
-
} catch {
|
|
292
|
-
return "missing";
|
|
293
|
-
}
|
|
294
|
-
let settings;
|
|
295
|
-
try {
|
|
296
|
-
settings = JSON.parse(raw);
|
|
297
|
-
} catch {
|
|
298
|
-
return "unreadable";
|
|
299
|
-
}
|
|
300
|
-
const allow = settings?.autoMode?.allow;
|
|
301
|
-
if (!Array.isArray(allow)) return "missing";
|
|
302
|
-
if (allow.includes(AUTO_MODE_RULE)) return "current";
|
|
303
|
-
if (allow.some((r) => typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK))) return "stale";
|
|
304
|
-
return "missing";
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Writes the rule into ~/.claude/settings.json, keeping "$defaults" first —
|
|
308
|
-
// dropping it discards every built-in allow. Everything else in the file is
|
|
309
|
-
// preserved. Refuses an unparseable file rather than replacing it.
|
|
310
|
-
export async function writeAutoModeRule(homeDir) {
|
|
311
|
-
if ((await autoModeRuleStatus(homeDir)) === "unreadable") {
|
|
312
|
-
throw new Error(`${globalSettingsPath(homeDir)} is not valid JSON — fix it, then re-run.`);
|
|
313
|
-
}
|
|
314
|
-
const settings = (await readGlobalSettings(homeDir)) ?? {};
|
|
315
|
-
if (!settings.autoMode || typeof settings.autoMode !== "object") settings.autoMode = {};
|
|
316
|
-
const existing = Array.isArray(settings.autoMode.allow) ? settings.autoMode.allow : [];
|
|
317
|
-
|
|
318
|
-
const kept = existing.filter(
|
|
319
|
-
(r) => r !== "$defaults" && !(typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK)),
|
|
320
|
-
);
|
|
321
|
-
settings.autoMode.allow = ["$defaults", ...kept, AUTO_MODE_RULE];
|
|
322
|
-
|
|
323
|
-
const path = globalSettingsPath(homeDir);
|
|
324
|
-
await mkdir(dirname(path), { recursive: true });
|
|
325
|
-
await writeFile(path, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
326
|
-
return path;
|
|
327
|
-
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What the installed commands need that this project does not have — a report,
|
|
2
|
+
// not an install. Nothing to do with reading a local catalog, which is what the
|
|
3
|
+
// rest of local.mjs was for; they shared a file and nothing else.
|
|
4
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { listDir } from "./local.mjs";
|
|
9
|
+
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// Build output is not a project. Next.js writes a package.json into .next/,
|
|
14
|
+
// .next/dev/ and .next/standalone/, so a scan that skipped only a hardcoded
|
|
15
|
+
// list reported three phantom projects on a plain Next app — and then filtered
|
|
16
|
+
// the real repo root out as "a root that delegates to a workspace". A second
|
|
17
|
+
// list of build dirs drifts from .gitignore the moment a framework adds one, so
|
|
18
|
+
// ask git the way ship-gate.sh already does. --directory collapses a fully
|
|
19
|
+
// ignored tree to its own name, which is what keeps this output small.
|
|
20
|
+
async function gitIgnoredDirs(projectDir) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await exec(
|
|
23
|
+
"git",
|
|
24
|
+
["-C", projectDir, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
|
|
25
|
+
{ maxBuffer: 10 * 1024 * 1024 },
|
|
26
|
+
);
|
|
27
|
+
return new Set(
|
|
28
|
+
stdout.split("\n").filter((l) => l.endsWith("/")).map((l) => l.slice(0, -1)),
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// What the installed commands need that this project does not have yet.
|
|
38
|
+
// ship-gate.sh's own detection drives a single-package repo; a monorepo whose
|
|
39
|
+
// tools need a workspace prefix or a container sets GAUNTLET_MUTATE instead. Only
|
|
40
|
+
// /ship needs anything: a mutation tool, per project, since a monorepo can hold
|
|
41
|
+
// several and they do not share one.
|
|
42
|
+
export async function reportToolNeeds(projectDir) {
|
|
43
|
+
const needs = [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
// Whether a nested project of each kind exists at all — not whether it still
|
|
46
|
+
// needs a tool. A root that delegates is not the place to install, even once
|
|
47
|
+
// the workspace it delegates to is already set up.
|
|
48
|
+
const nested = { Stryker: false, mutmut: false };
|
|
49
|
+
// Empty outside a git repo, so the hardcoded list below still carries those.
|
|
50
|
+
const ignored = await gitIgnoredDirs(projectDir);
|
|
51
|
+
|
|
52
|
+
async function scan(rel, depth) {
|
|
53
|
+
if (depth > 2) return;
|
|
54
|
+
const dir = rel ? join(projectDir, rel) : projectDir;
|
|
55
|
+
const entries = await listDir(dir);
|
|
56
|
+
const label = rel ? `${rel}/` : "<repo root>";
|
|
57
|
+
|
|
58
|
+
const pkg = entries.find((e) => e.isFile() && e.name === "package.json");
|
|
59
|
+
const pyp = entries.find((e) => e.isFile() && (e.name === "pyproject.toml" || e.name === "pytest.ini"));
|
|
60
|
+
|
|
61
|
+
if (pkg && rel) nested.Stryker = true;
|
|
62
|
+
if (pyp && rel) nested.mutmut = true;
|
|
63
|
+
|
|
64
|
+
if (pkg && !seen.has(label)) {
|
|
65
|
+
seen.add(label);
|
|
66
|
+
const text = await readFile(join(dir, "package.json"), "utf-8");
|
|
67
|
+
if (!text.includes("@stryker-mutator/core")) {
|
|
68
|
+
const at = rel ? rel + "/" : "";
|
|
69
|
+
needs.push({
|
|
70
|
+
label,
|
|
71
|
+
rel,
|
|
72
|
+
tool: "Stryker",
|
|
73
|
+
install: `npm --prefix ${rel || "."} i -D @stryker-mutator/core @stryker-mutator/vitest-runner`,
|
|
74
|
+
config: `${at}stryker.config.json {"testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"perTest"}`,
|
|
75
|
+
// Stryker copies the whole project into .stryker-tmp/sandbox-*/, so
|
|
76
|
+
// every test file briefly exists twice and the runner collects both.
|
|
77
|
+
// The duplicates pass, so a doubled test count is the only symptom.
|
|
78
|
+
// Spread configDefaults.exclude — assigning it drops node_modules.
|
|
79
|
+
also: [
|
|
80
|
+
`${at}vitest.config.ts exclude: [...configDefaults.exclude, "**/.stryker-tmp/**"]`,
|
|
81
|
+
`${at}.gitignore .stryker-tmp/`,
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
// Stryker is installed, so this project is not missing a tool — but the
|
|
86
|
+
// projects that already run it are the ones grinding through mutants on
|
|
87
|
+
// className strings, and a setup done before the ignorer existed has no
|
|
88
|
+
// way to learn about it. Offer it here or it never reaches them.
|
|
89
|
+
let cfg = "";
|
|
90
|
+
try {
|
|
91
|
+
cfg = await readFile(join(dir, "stryker.config.json"), "utf-8");
|
|
92
|
+
} catch {
|
|
93
|
+
// no config of its own — the scaffolder writes one
|
|
94
|
+
}
|
|
95
|
+
if (!cfg.includes("tailwind-classnames")) {
|
|
96
|
+
needs.push({ label, rel, tool: "Stryker", ignorerOnly: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (pyp && !seen.has(label + ":py")) {
|
|
101
|
+
seen.add(label + ":py");
|
|
102
|
+
const text = pyp.name === "pyproject.toml" ? await readFile(join(dir, "pyproject.toml"), "utf-8") : "";
|
|
103
|
+
if (!text.includes("[tool.mutmut]")) {
|
|
104
|
+
const advice = await pythonAdvice(dir, rel, entries);
|
|
105
|
+
needs.push({ label, tool: "mutmut", ...advice });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
if (!e.isDirectory()) continue;
|
|
111
|
+
if (["node_modules", ".git", "dist", "build", ".venv", "venv", ".claude"].includes(e.name)) continue;
|
|
112
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
113
|
+
if (ignored.has(child)) continue;
|
|
114
|
+
await scan(child, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await scan("", 0);
|
|
119
|
+
|
|
120
|
+
// A root package.json that delegates to a workspace (npm --prefix web run test)
|
|
121
|
+
// has no tests of its own, so asking for a mutation tool there is noise.
|
|
122
|
+
return needs.filter((n) => n.label !== "<repo root>" || !nested[n.tool]);
|
|
123
|
+
}
|