@spardutti/claude-skills 2.20.2 → 2.23.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 +5 -2
- package/bin/cli.mjs +93 -2
- package/lib/local.mjs +15 -0
- package/lib/scaffold-stryker.mjs +193 -0
- package/lib/setup-hook.mjs +121 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ Cross-cutting craft — applies to any stack, any language.
|
|
|
88
88
|
|-------|----------------|
|
|
89
89
|
| `code-structure` 📦 | Single Responsibility (when to split) + Avoid Hasty Abstractions (when *not* to extract) — hard size limits, separation of concerns, the Rule of Three |
|
|
90
90
|
| `debugging` | Root cause before patching — reproduce, trace the failure backward to its origin, one hypothesis at a time, the 3-attempts-then-question-the-design rule |
|
|
91
|
+
| `git-merge-conflicts` | Resolve a merge/rebase conflict by intent, not by side — `zdiff3` to see the merge base, the inverted meaning of `ours`/`theirs` during a rebase, the semantic and tree conflicts git never marks, lockfiles and migrations you regenerate instead of merging |
|
|
92
|
+
| `research` | Open the source, don't recall it — route to the tool that owns the answer, primary sources only, pin the version you're actually on, run the validator instead of guessing, and mark every claim VERIFIED / INFERRED / UNVERIFIED |
|
|
91
93
|
| `typescript-best-practices` | TypeScript 7.x — type design, generics, type guards, `satisfies`, `using`, error handling, `tsconfig` |
|
|
92
94
|
| `testing-best-practices` 📦 | Arrange-Act-Assert, factory-based test data, isolation, mocking boundaries, a pyramid-balanced suite; bundle covers mutation testing — Stryker and mutmut setup, diff-scoped runs, the traps, and working score thresholds |
|
|
93
95
|
| `security-practices` | OWASP Top 10 prevention, input validation, auth, SQL injection, XSS, CSRF, secure defaults |
|
|
@@ -98,14 +100,15 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
98
100
|
|
|
99
101
|
| Command | What it does |
|
|
100
102
|
|---------|--------------|
|
|
101
|
-
| `/ship` | Unified delivery pipeline —
|
|
102
|
-
| `/discover` | Find the right problem before deciding what to build — diverges first:
|
|
103
|
+
| `/ship` | Unified delivery pipeline — commit → gate → PR → merge → release. The gate is the one enforcement moment: it checks the diff's file lengths, audits it against the skills installed in the project, and mutation-tests the changed lines to prove the tests would catch a break — **fixing what it finds** rather than handing you a list. It runs before the **PR**, not before every commit: its scope is the whole branch, so gating each commit re-mutated every file the branch had ever touched. `--force` skips it. No argument steps through interactively; `/ship pr` runs through PR creation; `/ship release` runs the full pipeline |
|
|
104
|
+
| `/discover` | Find the right problem before deciding what to build — diverges first: generates competing framings through blind subagents under forced constraints, stress-tests the winner with a blind critic, and refuses to converge until every open question is answered or deferred. Run before `/preplan` |
|
|
103
105
|
| `/preplan` | Resolve a fuzzy feature idea into concrete decisions — 6 fixed phases, one question at a time, ends with a decision log. Run before `/plan-feature` |
|
|
104
106
|
| `/plan-feature` | Integration-first feature planning — 3 parallel subagents scan for reusable code, patterns, and touch points before producing a short plan |
|
|
105
107
|
| `/refactor` | Detect size / complexity / duplication / coupling issues via 4 parallel subagents, then refactor |
|
|
106
108
|
| `/deep-review` | Multi-agent deep code review — 5 parallel subagents catch guard bypasses, lost async state, wrong-table queries, dead references, protocol violations |
|
|
107
109
|
| `/test-review` | Write-then-verify test review — scopes to the diff, runs red-green + mutation gates, then an isolated read-only subagent proves each test would catch a regression instead of rubber-stamping it |
|
|
108
110
|
| `/lockdown` | Per-repo supply-chain hardening — detects the package managers, Dockerfiles, and CI in use, then guides you through and applies install-time, deploy, and pipeline hardening for npm/pnpm, pip/uv, Docker, and GitHub Actions |
|
|
111
|
+
| `/handoff` | Write a handoff doc so a fresh session picks the work up — captures what auto-compaction loses: decisions made and rejected, dead ends already ruled out, why the tree is dirty, and the one next step. Links to specs, commits, and PRs instead of restating them |
|
|
109
112
|
|
|
110
113
|
## How It Works
|
|
111
114
|
|
package/bin/cli.mjs
CHANGED
|
@@ -8,8 +8,15 @@ 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 {
|
|
11
|
+
import {
|
|
12
|
+
setupHook,
|
|
13
|
+
detectStack,
|
|
14
|
+
writeGauntletConf,
|
|
15
|
+
autoModeRuleStatus,
|
|
16
|
+
writeAutoModeRule,
|
|
17
|
+
} from "../lib/setup-hook.mjs";
|
|
12
18
|
import { makeLocalSource, reportToolNeeds } from "../lib/local.mjs";
|
|
19
|
+
import { scaffoldStryker } from "../lib/scaffold-stryker.mjs";
|
|
13
20
|
import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
|
|
14
21
|
import {
|
|
15
22
|
readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
|
|
@@ -172,6 +179,39 @@ async function main() {
|
|
|
172
179
|
await setupHook();
|
|
173
180
|
await setupClaudeMd();
|
|
174
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
|
+
|
|
175
215
|
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
176
216
|
// This asks the installed hook itself — never a second copy of its logic.
|
|
177
217
|
const gates = await detectStack(CWD);
|
|
@@ -209,12 +249,63 @@ async function main() {
|
|
|
209
249
|
if (!selectedSkills.some((s) => s.dirName === "testing-best-practices")) {
|
|
210
250
|
console.log(` ${chalk.dim("Setting these up has traps — install the testing-best-practices skill for MUTATION-TESTING.md.")}`);
|
|
211
251
|
}
|
|
212
|
-
for (const n of needs) {
|
|
252
|
+
for (const n of needs.filter((n) => !n.ignorerOnly)) {
|
|
213
253
|
console.log(` ${chalk.bold(n.label)} needs ${n.tool}`);
|
|
214
254
|
console.log(` ${chalk.cyan(n.install)}`);
|
|
215
255
|
console.log(` ${chalk.dim(n.config)}`);
|
|
216
256
|
for (const line of n.also ?? []) console.log(` ${chalk.dim(line)}`);
|
|
217
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 vitest = [];
|
|
277
|
+
for (const n of stryker) {
|
|
278
|
+
const r = await scaffoldStryker(CWD, n.rel);
|
|
279
|
+
for (const f of r.wrote) console.log(` Wrote: ${f}`);
|
|
280
|
+
for (const f of r.patched) console.log(` Added the ignorer to: ${f}`);
|
|
281
|
+
for (const f of r.kept) console.log(` ${chalk.dim(`Kept yours: ${f}`)}`);
|
|
282
|
+
for (const f of r.unreadable) {
|
|
283
|
+
console.log(` ${chalk.yellow("!")} ${f} is not readable as JSON — left alone, add the ignorer by hand`);
|
|
284
|
+
}
|
|
285
|
+
// A project that already runs Stryker needs neither the install nor
|
|
286
|
+
// the vitest exclude repeated at it; it only lacked the ignorer.
|
|
287
|
+
if (!n.ignorerOnly) {
|
|
288
|
+
installs.push(r.install);
|
|
289
|
+
vitest.push(r.vitest);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Left to the reader on purpose: installing touches the lockfile and
|
|
293
|
+
// the network, and editing an existing vitest config means parsing
|
|
294
|
+
// someone's plugins and aliases, where being wrong breaks their tests.
|
|
295
|
+
if (installs.length > 0) {
|
|
296
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("Two steps left, per project:")}`);
|
|
297
|
+
for (const c of installs) console.log(` ${chalk.cyan(c)}`);
|
|
298
|
+
for (const v of vitest) console.log(` ${chalk.dim(v)}`);
|
|
299
|
+
}
|
|
300
|
+
// The ignorer imports @stryker-mutator/api, which npm hoists by
|
|
301
|
+
// accident and pnpm does not. Missing, it prints one WARN PluginLoader
|
|
302
|
+
// line, loads nothing, and every className mutant comes back survived.
|
|
303
|
+
if (stryker.some((n) => n.ignorerOnly)) {
|
|
304
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("The ignorer needs its own dependency:")}`);
|
|
305
|
+
console.log(` ${chalk.cyan("add @stryker-mutator/api as a dev dependency")} ${chalk.dim("— pnpm does not hoist it, and without it the ignorer silently does not load")}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
218
309
|
}
|
|
219
310
|
}
|
|
220
311
|
|
package/lib/local.mjs
CHANGED
|
@@ -161,6 +161,7 @@ export async function reportToolNeeds(projectDir) {
|
|
|
161
161
|
const at = rel ? rel + "/" : "";
|
|
162
162
|
needs.push({
|
|
163
163
|
label,
|
|
164
|
+
rel,
|
|
164
165
|
tool: "Stryker",
|
|
165
166
|
install: `npm --prefix ${rel || "."} i -D @stryker-mutator/core @stryker-mutator/vitest-runner`,
|
|
166
167
|
config: `${at}stryker.config.json {"testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"perTest"}`,
|
|
@@ -173,6 +174,20 @@ export async function reportToolNeeds(projectDir) {
|
|
|
173
174
|
`${at}.gitignore .stryker-tmp/`,
|
|
174
175
|
],
|
|
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
|
+
}
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
if (pyp && !seen.has(label + ":py")) {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Stryker setup, written rather than printed.
|
|
2
|
+
//
|
|
3
|
+
// The CLI used to print five steps and leave them to the reader. Every project
|
|
4
|
+
// then started with the same 40% of a first React run being class-name noise —
|
|
5
|
+
// mutants on `className` strings and `cn()` ternaries that cannot be killed
|
|
6
|
+
// without asserting on the stylesheet. Un-closeable findings are what teach
|
|
7
|
+
// people to --force, so the ignorer is not an optional extra; it is the
|
|
8
|
+
// difference between a gate that gets used and one that gets bypassed.
|
|
9
|
+
//
|
|
10
|
+
// Two things stay printed, deliberately. Installing the packages touches the
|
|
11
|
+
// lockfile and the network. Editing an existing vitest config means parsing
|
|
12
|
+
// someone's plugins, aliases and projects array, and getting it wrong breaks
|
|
13
|
+
// their test run rather than ours.
|
|
14
|
+
import { readFile, writeFile, access } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const CONFIG = {
|
|
18
|
+
$schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
|
19
|
+
testRunner: "vitest",
|
|
20
|
+
plugins: ["@stryker-mutator/vitest-runner", "./stryker-classname-ignorer.mjs"],
|
|
21
|
+
ignorers: ["tailwind-classnames"],
|
|
22
|
+
coverageAnalysis: "perTest",
|
|
23
|
+
tempDirName: ".stryker-tmp",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Bumped whenever IGNORER changes. A project scaffolded before a rule existed
|
|
27
|
+
// has no other way to learn about it, and the file is generated, not authored —
|
|
28
|
+
// so a missing marker means overwrite, not keep.
|
|
29
|
+
const IGNORER_VERSION = "v2";
|
|
30
|
+
|
|
31
|
+
// Exported and then serialised into the generated ignorer below, rather than
|
|
32
|
+
// written twice. A copy in the template and a copy under test would agree with
|
|
33
|
+
// each other and disagree with the shipped file.
|
|
34
|
+
export function inDataTable(path) {
|
|
35
|
+
let found = false;
|
|
36
|
+
for (let p = path; p; p = p.parentPath) {
|
|
37
|
+
if (p.isFunction()) return false;
|
|
38
|
+
if (!found && p.isVariableDeclarator()) {
|
|
39
|
+
const init = p.node.init;
|
|
40
|
+
if (!init) return false;
|
|
41
|
+
if (init.type !== "ArrayExpression" && init.type !== "ObjectExpression") return false;
|
|
42
|
+
found = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// .mjs, not .js: an app without "type": "module" in its package.json cannot
|
|
49
|
+
// load ESM from a .js file, and the plugin is ESM.
|
|
50
|
+
const IGNORER = `// Generated by @spardutti/claude-skills — ignorer ${IGNORER_VERSION}. Edits are
|
|
51
|
+
// overwritten when the rules change.
|
|
52
|
+
//
|
|
53
|
+
// Ignore by WHERE the mutant sits, not by what kind it is. Disabling the
|
|
54
|
+
// StringLiteral mutator globally would also stop mutating labels, keys and
|
|
55
|
+
// separators — strings whose value is behaviour.
|
|
56
|
+
import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
|
|
57
|
+
|
|
58
|
+
// A string in a module-level array or object literal is copy: a heading, a
|
|
59
|
+
// label, a term row. Mutating it produces 'Pizzas' -> 'Stryker was here!',
|
|
60
|
+
// which can only be killed by asserting the table against a copy of itself —
|
|
61
|
+
// the tautology the testing skill exists to stop. One table of 11 headings and
|
|
62
|
+
// 16 rows produced 57 such survivors and a fifteen-minute gate.
|
|
63
|
+
//
|
|
64
|
+
// Any function between the string and the top disqualifies it: that is code
|
|
65
|
+
// building a value, not a table of content. A non-string mutant in the same
|
|
66
|
+
// table is left alone, because a number or a flag in a config object is
|
|
67
|
+
// behaviour even when it sits in data.
|
|
68
|
+
${inDataTable.toString()}
|
|
69
|
+
|
|
70
|
+
export const strykerPlugins = [
|
|
71
|
+
declareValuePlugin(PluginKind.Ignore, "tailwind-classnames", {
|
|
72
|
+
shouldIgnore(path) {
|
|
73
|
+
if (path.find((p) => p.isJSXAttribute() && p.node.name.name === "className"))
|
|
74
|
+
return "styling, not behaviour";
|
|
75
|
+
if (path.find((p) => p.isCallExpression() && p.node.callee.name === "cn"))
|
|
76
|
+
return "styling, not behaviour";
|
|
77
|
+
if (path.isStringLiteral() && inDataTable(path))
|
|
78
|
+
return "copy in a data table, not behaviour";
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
async function exists(p) {
|
|
85
|
+
try {
|
|
86
|
+
await access(p);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The ignorer imports @stryker-mutator/api, but nothing depends on it directly:
|
|
94
|
+
// npm's flat node_modules hoists it so the import resolves by accident, and
|
|
95
|
+
// pnpm's isolated store does not. The failure is one WARN PluginLoader line and
|
|
96
|
+
// an ignorer that never loads, so every class-name mutant comes back survived.
|
|
97
|
+
async function installCommand(dir, rel) {
|
|
98
|
+
const pnpm = await exists(join(dir, "pnpm-lock.yaml"));
|
|
99
|
+
const pkgs = "@stryker-mutator/core @stryker-mutator/vitest-runner @stryker-mutator/api";
|
|
100
|
+
return pnpm
|
|
101
|
+
? `pnpm --dir ${rel || "."} add -D ${pkgs}`
|
|
102
|
+
: `npm --prefix ${rel || "."} i -D ${pkgs}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Writes only what is absent. A project that already configured Stryker its own
|
|
106
|
+
// way keeps it — reporting the skip is the point, so nobody thinks a file they
|
|
107
|
+
// are looking at came from here.
|
|
108
|
+
export async function scaffoldStryker(projectDir, rel) {
|
|
109
|
+
const dir = rel ? join(projectDir, rel) : projectDir;
|
|
110
|
+
const at = rel ? `${rel}/` : "";
|
|
111
|
+
const wrote = [];
|
|
112
|
+
const kept = [];
|
|
113
|
+
const patched = [];
|
|
114
|
+
const unreadable = [];
|
|
115
|
+
|
|
116
|
+
// A project that already runs Stryker keeps every setting it chose. The two
|
|
117
|
+
// entries that wire the ignorer in are added, because a config written before
|
|
118
|
+
// the ignorer existed has no other way to learn about it — and that project is
|
|
119
|
+
// the one grinding through className mutants. A config this cannot parse is
|
|
120
|
+
// reported and left alone rather than rewritten from a guess.
|
|
121
|
+
const configPath = join(dir, "stryker.config.json");
|
|
122
|
+
if (await exists(configPath)) {
|
|
123
|
+
let cfg = null;
|
|
124
|
+
try {
|
|
125
|
+
cfg = JSON.parse(await readFile(configPath, "utf-8"));
|
|
126
|
+
} catch {
|
|
127
|
+
cfg = null;
|
|
128
|
+
}
|
|
129
|
+
if (cfg === null) {
|
|
130
|
+
unreadable.push(`${at}stryker.config.json`);
|
|
131
|
+
} else if ((cfg.ignorers ?? []).includes("tailwind-classnames")) {
|
|
132
|
+
kept.push(`${at}stryker.config.json`);
|
|
133
|
+
} else {
|
|
134
|
+
cfg.plugins = [...new Set([...(cfg.plugins ?? []), "./stryker-classname-ignorer.mjs"])];
|
|
135
|
+
cfg.ignorers = [...new Set([...(cfg.ignorers ?? []), "tailwind-classnames"])];
|
|
136
|
+
await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
137
|
+
patched.push(`${at}stryker.config.json`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
await writeFile(configPath, JSON.stringify(CONFIG, null, 2) + "\n");
|
|
141
|
+
wrote.push(`${at}stryker.config.json`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Testing the fresh install here would have missed the whole point: a project
|
|
145
|
+
// that already had the v1 ignorer would have kept it forever and never gained
|
|
146
|
+
// the data-table rule, which is the rule this release exists for.
|
|
147
|
+
const ignorerPath = join(dir, "stryker-classname-ignorer.mjs");
|
|
148
|
+
let existingIgnorer = null;
|
|
149
|
+
try {
|
|
150
|
+
existingIgnorer = await readFile(ignorerPath, "utf-8");
|
|
151
|
+
} catch {
|
|
152
|
+
// not scaffolded yet
|
|
153
|
+
}
|
|
154
|
+
if (existingIgnorer === null) {
|
|
155
|
+
await writeFile(ignorerPath, IGNORER);
|
|
156
|
+
wrote.push(`${at}stryker-classname-ignorer.mjs`);
|
|
157
|
+
} else if (existingIgnorer.includes(`ignorer ${IGNORER_VERSION}`)) {
|
|
158
|
+
kept.push(`${at}stryker-classname-ignorer.mjs`);
|
|
159
|
+
} else {
|
|
160
|
+
await writeFile(ignorerPath, IGNORER);
|
|
161
|
+
patched.push(`${at}stryker-classname-ignorer.mjs`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A crashed run leaves a full second copy of the project behind, so this one
|
|
165
|
+
// matters even though it is one line.
|
|
166
|
+
const gitignorePath = join(dir, ".gitignore");
|
|
167
|
+
let gitignore = "";
|
|
168
|
+
try {
|
|
169
|
+
gitignore = await readFile(gitignorePath, "utf-8");
|
|
170
|
+
} catch {
|
|
171
|
+
// no .gitignore yet — one gets written below
|
|
172
|
+
}
|
|
173
|
+
if (/^\.stryker-tmp\/?$/m.test(gitignore)) {
|
|
174
|
+
kept.push(`${at}.gitignore`);
|
|
175
|
+
} else {
|
|
176
|
+
const sep = gitignore === "" || gitignore.endsWith("\n") ? "" : "\n";
|
|
177
|
+
await writeFile(gitignorePath, `${gitignore}${sep}.stryker-tmp/\n`);
|
|
178
|
+
wrote.push(`${at}.gitignore`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
wrote,
|
|
183
|
+
kept,
|
|
184
|
+
patched,
|
|
185
|
+
unreadable,
|
|
186
|
+
install: await installCommand(dir, rel),
|
|
187
|
+
// Stryker copies the whole project into .stryker-tmp/sandbox-*/, so every
|
|
188
|
+
// test file briefly exists twice and the runner collects both. They pass,
|
|
189
|
+
// so a doubled test count is the only symptom. Spread configDefaults.exclude
|
|
190
|
+
// rather than assigning it, or node_modules stops being excluded.
|
|
191
|
+
vitest: `${at}vitest.config.ts exclude: [...configDefaults.exclude, "**/.stryker-tmp/**"]`,
|
|
192
|
+
};
|
|
193
|
+
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, dirname } from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
|
|
5
6
|
// PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
|
|
6
7
|
// a per-session marker file exists at /tmp/claude-skill-gate-<SESSION_ID>.
|
|
@@ -52,6 +53,9 @@ if [ "$TOOL" != "Bash" ]; then
|
|
|
52
53
|
# The structured tools name their target outright.
|
|
53
54
|
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
54
55
|
case "$TARGET" in
|
|
56
|
+
# The settings file that configures the escape hatch cannot sit behind the
|
|
57
|
+
# gate, or a denied ack has no way to be un-denied.
|
|
58
|
+
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
55
59
|
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
56
60
|
esac
|
|
57
61
|
fi
|
|
@@ -61,6 +65,7 @@ if [ "$TOOL" = "Bash" ]; then
|
|
|
61
65
|
# Never gate the command that clears the gate, or this deadlocks.
|
|
62
66
|
case "$CMD" in
|
|
63
67
|
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
68
|
+
*.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
|
|
64
69
|
esac
|
|
65
70
|
|
|
66
71
|
# /dev/null redirects are not file writes; drop them before looking for one.
|
|
@@ -155,6 +160,9 @@ if [ "$TOOL" != "Bash" ]; then
|
|
|
155
160
|
# The structured tools name their target outright.
|
|
156
161
|
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
157
162
|
case "$TARGET" in
|
|
163
|
+
# The settings file that configures the escape hatch cannot sit behind the
|
|
164
|
+
# gate, or a denied ack has no way to be un-denied.
|
|
165
|
+
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
158
166
|
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
159
167
|
esac
|
|
160
168
|
fi
|
|
@@ -162,6 +170,7 @@ if [ "$TOOL" = "Bash" ]; then
|
|
|
162
170
|
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
163
171
|
case "$CMD" in
|
|
164
172
|
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
173
|
+
*.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
|
|
165
174
|
esac
|
|
166
175
|
|
|
167
176
|
# A command that names a prose file and no code file is writing prose.
|
|
@@ -235,15 +244,20 @@ Before this Write/Edit, you must:
|
|
|
235
244
|
One ack per skill per session. After acking, retry the Write/Edit.
|
|
236
245
|
|
|
237
246
|
If that Bash call is denied by the auto mode classifier, STOP. Do not ask your
|
|
238
|
-
user to run it for you,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
user to run it for you, do not look for another route to create the files, and
|
|
248
|
+
do not try to add the allow rule yourself — an agent editing permission settings
|
|
249
|
+
is denied by the same classifier, so that attempt fails too and costs a round
|
|
250
|
+
trip that ends in the same place. All three are what the classifier is there to
|
|
251
|
+
stop, and asking a human to run a command you were just denied makes them the
|
|
252
|
+
tool that performs it. Report this to them instead and let them decide:
|
|
242
253
|
|
|
243
254
|
Auto mode's built-in Auto-Mode Bypass rule counts a hook's ack file as working
|
|
244
255
|
around the permission system. It is a soft rule, so an allow entry clears it.
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
The user has to add it — you cannot. The shortest route is for them to re-run
|
|
257
|
+
npx @spardutti/claude-skills and accept the auto mode prompt, which writes it.
|
|
258
|
+
Otherwise /permissions, Auto mode tab. Editing the file by hand works too, and
|
|
259
|
+
it has to be ~/.claude/settings.json — the classifier does not read a
|
|
260
|
+
project's .claude/settings.json. Keep the
|
|
247
261
|
\\\$defaults line or every built-in allow rule is discarded:
|
|
248
262
|
|
|
249
263
|
\\"autoMode\\": { \\"allow\\": [ \\"\\\$defaults\\",
|
|
@@ -1035,7 +1049,7 @@ exit $STATUS
|
|
|
1035
1049
|
// PreToolUse on Bash: refuses git commit/push without a ship-gate receipt.
|
|
1036
1050
|
// Canonical source: scripts/ship-gate-hook.sh.
|
|
1037
1051
|
const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
1038
|
-
# ship-gate-hook.sh — PreToolUse on Bash. Refuses
|
|
1052
|
+
# ship-gate-hook.sh — PreToolUse on Bash. Refuses the commands that publish work
|
|
1039
1053
|
# unless ship-gate.sh has left a receipt for exactly these changes.
|
|
1040
1054
|
#
|
|
1041
1055
|
# A gate written as an instruction is a gate an agent can decide is not worth the
|
|
@@ -1046,21 +1060,38 @@ const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
|
1046
1060
|
# The receipt key covers everything about to ship, so it survives \`git commit\`
|
|
1047
1061
|
# and still matches at push time, and it changes the moment any file is edited —
|
|
1048
1062
|
# a fix has to be re-gated instead of riding on the previous verdict.
|
|
1063
|
+
#
|
|
1064
|
+
# It gated \`git commit\` and \`git push\` until 2.23.0, and that was the wrong
|
|
1065
|
+
# moment. The gate's scope is merge-base..HEAD — the whole branch, because the
|
|
1066
|
+
# whole branch is what a PR ships — so a branch touching 46 files re-mutated all
|
|
1067
|
+
# 46 on every commit. Fifteen minutes, twenty times, for one branch. Neither a
|
|
1068
|
+
# commit nor a push to a feature branch publishes anything, so neither is gated
|
|
1069
|
+
# now: the cost is paid once, at the point work actually ships.
|
|
1049
1070
|
|
|
1050
1071
|
INPUT=$(cat)
|
|
1051
1072
|
|
|
1052
1073
|
CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \\
|
|
1053
1074
|
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
1054
1075
|
|
|
1055
|
-
|
|
1076
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
1077
|
+
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
1078
|
+
|
|
1079
|
+
# Opening or merging a PR publishes. So does pushing while standing on a
|
|
1080
|
+
# protected branch — that is a direct ship with no PR in front of it. The branch
|
|
1081
|
+
# is read from git rather than parsed out of the command, because \`git push\` with
|
|
1082
|
+
# no arguments names no branch at all.
|
|
1056
1083
|
case "$CMD" in
|
|
1057
|
-
*"
|
|
1084
|
+
*"gh pr create"*|*"gh pr merge"*) ;;
|
|
1085
|
+
*"git push"*)
|
|
1086
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
1087
|
+
case "$BRANCH" in
|
|
1088
|
+
main|master|develop|development|dev) ;;
|
|
1089
|
+
*) exit 0 ;;
|
|
1090
|
+
esac
|
|
1091
|
+
;;
|
|
1058
1092
|
*) exit 0 ;;
|
|
1059
1093
|
esac
|
|
1060
1094
|
|
|
1061
|
-
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
1062
|
-
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
1063
|
-
|
|
1064
1095
|
GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
|
|
1065
1096
|
[ -x "$GATE" ] || exit 0
|
|
1066
1097
|
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
@@ -1414,3 +1445,81 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
1414
1445
|
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
1415
1446
|
console.log(` Settings updated: .claude/settings.json`);
|
|
1416
1447
|
}
|
|
1448
|
+
|
|
1449
|
+
// The project-level permissions.allow written above does not clear auto mode.
|
|
1450
|
+
// The classifier reads autoMode.allow, and only from the user's own
|
|
1451
|
+
// ~/.claude/settings.json — it deliberately ignores a project's settings so a
|
|
1452
|
+
// cloned repo cannot loosen it. So the gate's ack was denied, and the remedy the
|
|
1453
|
+
// deny message named could not be applied by the agent either: the classifier
|
|
1454
|
+
// refuses an agent editing permission settings by any route. That left a
|
|
1455
|
+
// deadlock nothing in the session could break. The installer can, because npx
|
|
1456
|
+
// runs as the user, not as an agent — but only ever behind an explicit prompt,
|
|
1457
|
+
// since this is the user's global file.
|
|
1458
|
+
export const AUTO_MODE_RULE =
|
|
1459
|
+
"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-* " +
|
|
1460
|
+
"and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for the " +
|
|
1461
|
+
"user's own PreToolUse skill gates. Creating one satisfies a workflow gate the user " +
|
|
1462
|
+
"installed, grants no permission and executes no code, and is a false positive for " +
|
|
1463
|
+
"Auto-Mode Bypass.";
|
|
1464
|
+
|
|
1465
|
+
// Any entry naming a marker path is ours, whatever wording it shipped with. An
|
|
1466
|
+
// upgrade must correct that entry rather than add a second one beside it —
|
|
1467
|
+
// a fresh-install test would never see the difference.
|
|
1468
|
+
const AUTO_MODE_RULE_MARK = "/tmp/claude-skill-acked-";
|
|
1469
|
+
|
|
1470
|
+
function globalSettingsPath(homeDir) {
|
|
1471
|
+
return join(homeDir ?? homedir(), ".claude", "settings.json");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function readGlobalSettings(homeDir) {
|
|
1475
|
+
try {
|
|
1476
|
+
return JSON.parse(await readFile(globalSettingsPath(homeDir), "utf-8"));
|
|
1477
|
+
} catch {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// "current" — the rule is present and matches. "stale" — an older wording is
|
|
1483
|
+
// there. "missing" — nothing of ours. "unreadable" — the file exists but does
|
|
1484
|
+
// not parse, so we must not rewrite it.
|
|
1485
|
+
export async function autoModeRuleStatus(homeDir) {
|
|
1486
|
+
let raw;
|
|
1487
|
+
try {
|
|
1488
|
+
raw = await readFile(globalSettingsPath(homeDir), "utf-8");
|
|
1489
|
+
} catch {
|
|
1490
|
+
return "missing";
|
|
1491
|
+
}
|
|
1492
|
+
let settings;
|
|
1493
|
+
try {
|
|
1494
|
+
settings = JSON.parse(raw);
|
|
1495
|
+
} catch {
|
|
1496
|
+
return "unreadable";
|
|
1497
|
+
}
|
|
1498
|
+
const allow = settings?.autoMode?.allow;
|
|
1499
|
+
if (!Array.isArray(allow)) return "missing";
|
|
1500
|
+
if (allow.includes(AUTO_MODE_RULE)) return "current";
|
|
1501
|
+
if (allow.some((r) => typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK))) return "stale";
|
|
1502
|
+
return "missing";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Writes the rule into ~/.claude/settings.json, keeping "$defaults" first —
|
|
1506
|
+
// dropping it discards every built-in allow. Everything else in the file is
|
|
1507
|
+
// preserved. Refuses an unparseable file rather than replacing it.
|
|
1508
|
+
export async function writeAutoModeRule(homeDir) {
|
|
1509
|
+
if ((await autoModeRuleStatus(homeDir)) === "unreadable") {
|
|
1510
|
+
throw new Error(`${globalSettingsPath(homeDir)} is not valid JSON — fix it, then re-run.`);
|
|
1511
|
+
}
|
|
1512
|
+
const settings = (await readGlobalSettings(homeDir)) ?? {};
|
|
1513
|
+
if (!settings.autoMode || typeof settings.autoMode !== "object") settings.autoMode = {};
|
|
1514
|
+
const existing = Array.isArray(settings.autoMode.allow) ? settings.autoMode.allow : [];
|
|
1515
|
+
|
|
1516
|
+
const kept = existing.filter(
|
|
1517
|
+
(r) => r !== "$defaults" && !(typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK)),
|
|
1518
|
+
);
|
|
1519
|
+
settings.autoMode.allow = ["$defaults", ...kept, AUTO_MODE_RULE];
|
|
1520
|
+
|
|
1521
|
+
const path = globalSettingsPath(homeDir);
|
|
1522
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1523
|
+
await writeFile(path, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
1524
|
+
return path;
|
|
1525
|
+
}
|