@spardutti/claude-skills 2.22.0 → 2.24.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 CHANGED
@@ -100,8 +100,8 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
100
100
 
101
101
  | Command | What it does |
102
102
  |---------|--------------|
103
- | `/ship` | Unified delivery pipeline — gate → commit → 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. `--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: names competing interpretations of what you said, stress-tests the framing with a blind critic subagent, then converges on a mental model. Run before `/preplan` |
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` |
105
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` |
106
106
  | `/plan-feature` | Integration-first feature planning — 3 parallel subagents scan for reusable code, patterns, and touch points before producing a short plan |
107
107
  | `/refactor` | Detect size / complexity / duplication / coupling issues via 4 parallel subagents, then refactor |
package/bin/cli.mjs CHANGED
@@ -8,7 +8,13 @@ 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 { setupHook, detectStack, writeGauntletConf } from "../lib/setup-hook.mjs";
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";
13
19
  import { scaffoldStryker } from "../lib/scaffold-stryker.mjs";
14
20
  import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
@@ -173,6 +179,39 @@ async function main() {
173
179
  await setupHook();
174
180
  await setupClaudeMd();
175
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
+
176
215
  // Report what the gauntlet will actually run, so a silent no-op is visible.
177
216
  // This asks the installed hook itself — never a second copy of its logic.
178
217
  const gates = await detectStack(CWD);
@@ -14,20 +14,43 @@
14
14
  import { readFile, writeFile, access } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
16
 
17
+ // ignoreStatic is the whole reason a first run is survivable. A string in a
18
+ // module-level array or object is a static mutant, evaluated once at file load,
19
+ // and Stryker's docs say testing one "often requires running all tests". A table
20
+ // of 11 headings and 16 term rows produced 57 survivors and a gate that timed
21
+ // out past 600s; with this set the same gate ran in 1m33s and the survivors were
22
+ // gone without a single test written for them. It requires perTest coverage
23
+ // analysis, which is why the two belong together.
24
+ //
25
+ // A shipped attempt to solve this in the ignorer — skip string mutants inside a
26
+ // module-level array or object literal — was exactly the definition of a static
27
+ // mutant, so it did the same job worse. It is gone; this option replaces it.
17
28
  const CONFIG = {
18
29
  $schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
19
30
  testRunner: "vitest",
20
31
  plugins: ["@stryker-mutator/vitest-runner", "./stryker-classname-ignorer.mjs"],
21
32
  ignorers: ["tailwind-classnames"],
22
33
  coverageAnalysis: "perTest",
34
+ ignoreStatic: true,
23
35
  tempDirName: ".stryker-tmp",
24
36
  };
25
37
 
38
+ // Bumped whenever IGNORER changes. A project scaffolded before a rule existed
39
+ // has no other way to learn about it, and the file is generated, not authored —
40
+ // so a missing marker means overwrite, not keep.
41
+ const IGNORER_VERSION = "v3";
42
+
26
43
  // .mjs, not .js: an app without "type": "module" in its package.json cannot
27
44
  // load ESM from a .js file, and the plugin is ESM.
28
- const IGNORER = `// Ignore by WHERE the mutant sits, not by what kind it is. Disabling the
45
+ const IGNORER = `// Generated by @spardutti/claude-skills — ignorer ${IGNORER_VERSION}. Edits are
46
+ // overwritten when the rules change.
47
+ //
48
+ // Ignore by WHERE the mutant sits, not by what kind it is. Disabling the
29
49
  // StringLiteral mutator globally would also stop mutating labels, keys and
30
50
  // separators — strings whose value is behaviour.
51
+ //
52
+ // Content in a module-level table is NOT handled here. Those are static mutants,
53
+ // and \`ignoreStatic\` in stryker.config.json covers them for less.
31
54
  import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
32
55
 
33
56
  export const strykerPlugins = [
@@ -89,25 +112,55 @@ export async function scaffoldStryker(projectDir, rel) {
89
112
  }
90
113
  if (cfg === null) {
91
114
  unreadable.push(`${at}stryker.config.json`);
92
- } else if ((cfg.ignorers ?? []).includes("tailwind-classnames")) {
93
- kept.push(`${at}stryker.config.json`);
94
115
  } else {
95
- cfg.plugins = [...new Set([...(cfg.plugins ?? []), "./stryker-classname-ignorer.mjs"])];
96
- cfg.ignorers = [...new Set([...(cfg.ignorers ?? []), "tailwind-classnames"])];
97
- await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
98
- patched.push(`${at}stryker.config.json`);
116
+ const needsIgnorer = !(cfg.ignorers ?? []).includes("tailwind-classnames");
117
+
118
+ // ignoreStatic is only correct under perTest coverage analysis. Absent, we
119
+ // set both. Already perTest, we add the flag. Anything else was a
120
+ // deliberate choice and stays — silently switching it would change which
121
+ // mutants the project reports, which is not ours to decide.
122
+ const coverage = cfg.coverageAnalysis;
123
+ const canSetStatic = coverage === undefined || coverage === "perTest";
124
+ const needsStatic = cfg.ignoreStatic !== true && canSetStatic;
125
+
126
+ if (!needsIgnorer && !needsStatic) {
127
+ kept.push(`${at}stryker.config.json`);
128
+ } else {
129
+ if (needsIgnorer) {
130
+ cfg.plugins = [...new Set([...(cfg.plugins ?? []), "./stryker-classname-ignorer.mjs"])];
131
+ cfg.ignorers = [...new Set([...(cfg.ignorers ?? []), "tailwind-classnames"])];
132
+ }
133
+ if (needsStatic) {
134
+ cfg.coverageAnalysis = "perTest";
135
+ cfg.ignoreStatic = true;
136
+ }
137
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
138
+ patched.push(`${at}stryker.config.json`);
139
+ }
99
140
  }
100
141
  } else {
101
142
  await writeFile(configPath, JSON.stringify(CONFIG, null, 2) + "\n");
102
143
  wrote.push(`${at}stryker.config.json`);
103
144
  }
104
145
 
146
+ // Testing the fresh install here would have missed the whole point: a project
147
+ // that already had the v1 ignorer would have kept it forever and never gained
148
+ // the data-table rule, which is the rule this release exists for.
105
149
  const ignorerPath = join(dir, "stryker-classname-ignorer.mjs");
106
- if (await exists(ignorerPath)) {
150
+ let existingIgnorer = null;
151
+ try {
152
+ existingIgnorer = await readFile(ignorerPath, "utf-8");
153
+ } catch {
154
+ // not scaffolded yet
155
+ }
156
+ if (existingIgnorer === null) {
157
+ await writeFile(ignorerPath, IGNORER);
158
+ wrote.push(`${at}stryker-classname-ignorer.mjs`);
159
+ } else if (existingIgnorer.includes(`ignorer ${IGNORER_VERSION}`)) {
107
160
  kept.push(`${at}stryker-classname-ignorer.mjs`);
108
161
  } else {
109
162
  await writeFile(ignorerPath, IGNORER);
110
- wrote.push(`${at}stryker-classname-ignorer.mjs`);
163
+ patched.push(`${at}stryker-classname-ignorer.mjs`);
111
164
  }
112
165
 
113
166
  // A crashed run leaves a full second copy of the project behind, so this one
@@ -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, and do not look for another route to create the files.
239
- Both are what the classifier is there to stop, and asking a human to run a
240
- command you were just denied makes them the tool that performs it. Report this
241
- to them instead and let them decide:
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
- It has to go in ~/.claude/settings.json — the classifier does not read a
246
- project's .claude/settings.json — or in /permissions, Auto mode tab. Keep the
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\\",
@@ -649,6 +663,13 @@ GAUNTLET_MUTATE=""
649
663
  GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
650
664
  GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|json|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
651
665
  GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */*.config.*"
666
+ # Length-checked and skill-audited like everything else, but not mutated.
667
+ # Mutation pays on logic and burns time on presentation: a component's mutants
668
+ # are class names, copy and JSX shape, none of which is behaviour, and one
669
+ # session spent an evening killing them. Logic that lives inside a component is
670
+ # logic that wants extracting — which the 200-line rule already pushes toward.
671
+ # Override in .claude/gauntlet.conf to mutate components too.
672
+ GAUNTLET_NO_MUTATE='\\.(tsx|jsx)$'
652
673
  [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
653
674
  [ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
654
675
 
@@ -811,7 +832,7 @@ for owner in $OWNERS; do
811
832
  [ "$owner" = "." ] && label="<repo root>" || label="$owner/"
812
833
  OWNED=$(while IFS= read -r f; do
813
834
  [ "$(owner_of "$f")" = "$owner" ] && printf '%s\\n' "$f"
814
- done <<< "$FILES")
835
+ done <<< "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" || true)
815
836
  [ -z "$OWNED" ] && continue
816
837
 
817
838
  # ONE comma-joined --mutate value, never a flag per range. Stryker's CLI
@@ -1035,7 +1056,7 @@ exit $STATUS
1035
1056
  // PreToolUse on Bash: refuses git commit/push without a ship-gate receipt.
1036
1057
  // Canonical source: scripts/ship-gate-hook.sh.
1037
1058
  const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
1038
- # ship-gate-hook.sh — PreToolUse on Bash. Refuses \`git commit\` and \`git push\`
1059
+ # ship-gate-hook.sh — PreToolUse on Bash. Refuses the commands that publish work
1039
1060
  # unless ship-gate.sh has left a receipt for exactly these changes.
1040
1061
  #
1041
1062
  # A gate written as an instruction is a gate an agent can decide is not worth the
@@ -1046,21 +1067,38 @@ const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
1046
1067
  # The receipt key covers everything about to ship, so it survives \`git commit\`
1047
1068
  # and still matches at push time, and it changes the moment any file is edited —
1048
1069
  # a fix has to be re-gated instead of riding on the previous verdict.
1070
+ #
1071
+ # It gated \`git commit\` and \`git push\` until 2.23.0, and that was the wrong
1072
+ # moment. The gate's scope is merge-base..HEAD — the whole branch, because the
1073
+ # whole branch is what a PR ships — so a branch touching 46 files re-mutated all
1074
+ # 46 on every commit. Fifteen minutes, twenty times, for one branch. Neither a
1075
+ # commit nor a push to a feature branch publishes anything, so neither is gated
1076
+ # now: the cost is paid once, at the point work actually ships.
1049
1077
 
1050
1078
  INPUT=$(cat)
1051
1079
 
1052
1080
  CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \\
1053
1081
  | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
1054
1082
 
1055
- # Only the two commands that publish work. Everything else passes untouched.
1083
+ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
1084
+ cd "$PROJECT_DIR" 2>/dev/null || exit 0
1085
+
1086
+ # Opening or merging a PR publishes. So does pushing while standing on a
1087
+ # protected branch — that is a direct ship with no PR in front of it. The branch
1088
+ # is read from git rather than parsed out of the command, because \`git push\` with
1089
+ # no arguments names no branch at all.
1056
1090
  case "$CMD" in
1057
- *"git commit"*|*"git push"*) ;;
1091
+ *"gh pr create"*|*"gh pr merge"*) ;;
1092
+ *"git push"*)
1093
+ BRANCH=$(git branch --show-current 2>/dev/null)
1094
+ case "$BRANCH" in
1095
+ main|master|develop|development|dev) ;;
1096
+ *) exit 0 ;;
1097
+ esac
1098
+ ;;
1058
1099
  *) exit 0 ;;
1059
1100
  esac
1060
1101
 
1061
- PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
1062
- cd "$PROJECT_DIR" 2>/dev/null || exit 0
1063
-
1064
1102
  GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
1065
1103
  [ -x "$GATE" ] || exit 0
1066
1104
  git rev-parse --git-dir >/dev/null 2>&1 || exit 0
@@ -1414,3 +1452,81 @@ export async function setupHook(targetDir = process.cwd()) {
1414
1452
  console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
1415
1453
  console.log(` Settings updated: .claude/settings.json`);
1416
1454
  }
1455
+
1456
+ // The project-level permissions.allow written above does not clear auto mode.
1457
+ // The classifier reads autoMode.allow, and only from the user's own
1458
+ // ~/.claude/settings.json — it deliberately ignores a project's settings so a
1459
+ // cloned repo cannot loosen it. So the gate's ack was denied, and the remedy the
1460
+ // deny message named could not be applied by the agent either: the classifier
1461
+ // refuses an agent editing permission settings by any route. That left a
1462
+ // deadlock nothing in the session could break. The installer can, because npx
1463
+ // runs as the user, not as an agent — but only ever behind an explicit prompt,
1464
+ // since this is the user's global file.
1465
+ export const AUTO_MODE_RULE =
1466
+ "Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-* " +
1467
+ "and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for the " +
1468
+ "user's own PreToolUse skill gates. Creating one satisfies a workflow gate the user " +
1469
+ "installed, grants no permission and executes no code, and is a false positive for " +
1470
+ "Auto-Mode Bypass.";
1471
+
1472
+ // Any entry naming a marker path is ours, whatever wording it shipped with. An
1473
+ // upgrade must correct that entry rather than add a second one beside it —
1474
+ // a fresh-install test would never see the difference.
1475
+ const AUTO_MODE_RULE_MARK = "/tmp/claude-skill-acked-";
1476
+
1477
+ function globalSettingsPath(homeDir) {
1478
+ return join(homeDir ?? homedir(), ".claude", "settings.json");
1479
+ }
1480
+
1481
+ async function readGlobalSettings(homeDir) {
1482
+ try {
1483
+ return JSON.parse(await readFile(globalSettingsPath(homeDir), "utf-8"));
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+
1489
+ // "current" — the rule is present and matches. "stale" — an older wording is
1490
+ // there. "missing" — nothing of ours. "unreadable" — the file exists but does
1491
+ // not parse, so we must not rewrite it.
1492
+ export async function autoModeRuleStatus(homeDir) {
1493
+ let raw;
1494
+ try {
1495
+ raw = await readFile(globalSettingsPath(homeDir), "utf-8");
1496
+ } catch {
1497
+ return "missing";
1498
+ }
1499
+ let settings;
1500
+ try {
1501
+ settings = JSON.parse(raw);
1502
+ } catch {
1503
+ return "unreadable";
1504
+ }
1505
+ const allow = settings?.autoMode?.allow;
1506
+ if (!Array.isArray(allow)) return "missing";
1507
+ if (allow.includes(AUTO_MODE_RULE)) return "current";
1508
+ if (allow.some((r) => typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK))) return "stale";
1509
+ return "missing";
1510
+ }
1511
+
1512
+ // Writes the rule into ~/.claude/settings.json, keeping "$defaults" first —
1513
+ // dropping it discards every built-in allow. Everything else in the file is
1514
+ // preserved. Refuses an unparseable file rather than replacing it.
1515
+ export async function writeAutoModeRule(homeDir) {
1516
+ if ((await autoModeRuleStatus(homeDir)) === "unreadable") {
1517
+ throw new Error(`${globalSettingsPath(homeDir)} is not valid JSON — fix it, then re-run.`);
1518
+ }
1519
+ const settings = (await readGlobalSettings(homeDir)) ?? {};
1520
+ if (!settings.autoMode || typeof settings.autoMode !== "object") settings.autoMode = {};
1521
+ const existing = Array.isArray(settings.autoMode.allow) ? settings.autoMode.allow : [];
1522
+
1523
+ const kept = existing.filter(
1524
+ (r) => r !== "$defaults" && !(typeof r === "string" && r.includes(AUTO_MODE_RULE_MARK)),
1525
+ );
1526
+ settings.autoMode.allow = ["$defaults", ...kept, AUTO_MODE_RULE];
1527
+
1528
+ const path = globalSettingsPath(homeDir);
1529
+ await mkdir(dirname(path), { recursive: true });
1530
+ await writeFile(path, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1531
+ return path;
1532
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.22.0",
3
+ "version": "2.24.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "repository": {