@spardutti/claude-skills 2.33.8 → 2.35.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
@@ -131,7 +131,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
131
131
 
132
132
  | Command | What it does |
133
133
  |---------|--------------|
134
- | `/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 |
134
+ | `/ship` | Unified delivery pipeline — commit → gate → PR → merge → release. The gate is the one enforcement moment: it checks the diff's file lengths and that new files sit in their kind folder, 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 |
135
135
  | `/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, then drafts scope, non-goals, edge cases and success criteria for you to correct. Run before `/plan-feature` |
136
136
  | `/plan-feature` | Integration-first feature planning, then building — 3 parallel subagents scan for reusable code, patterns and touch points, grounded clarifying questions follow, and on your go the same agent builds the plan |
137
137
  | `/refactor` | Detect size / complexity / duplication / coupling issues via 4 parallel subagents, then refactor |
@@ -169,7 +169,13 @@ Every install writes a manifest at `.claude/.claude-skills.json` recording what
169
169
  npx @spardutti/claude-skills --sync
170
170
  ```
171
171
 
172
- `--sync` refreshes every tracked item to the latest catalog and prunes stale ones in one shot — no menu. For a project that predates the manifest, the first normal run offers a one-time cleanup of `.claude/` content no longer in the catalog.
172
+ `--sync` refreshes every tracked item, and any hooks the project already has, to the latest catalog and prunes stale ones in one shot — no menu. For a project that predates the manifest, the first normal run offers a one-time cleanup of `.claude/` content no longer in the catalog.
173
+
174
+ `--sync-all[=dir]` does the same for every project under `dir` (default: your home folder) that has a manifest, fetching the catalog once:
175
+
176
+ ```bash
177
+ npx @spardutti/claude-skills --sync-all
178
+ ```
173
179
 
174
180
  `--local[=path]` reads the catalog from a working copy instead of GitHub, so an unreleased change can be installed and tried without publishing it first:
175
181
 
@@ -185,7 +191,7 @@ commands/ Slash commands installed to .claude/commands/
185
191
  agents/ Subagent definitions — commands declare which they need via requires-agents
186
192
  scripts/ validate-skills.mjs — checks skill length caps and reference integrity
187
193
  gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
188
- ship-gate.sh — /ship's file-length and mutation checks, behind an exit code
194
+ ship-gate.sh — /ship's file-length, folder-structure and mutation checks, behind an exit code
189
195
  ship-gate-hook.sh — refuses gh pr create/merge without a ship-gate receipt
190
196
  version-check.sh — SessionStart nudge when a newer catalog is published
191
197
  gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
package/bin/cli.mjs CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  import { confirm } from "@inquirer/prompts";
4
4
  import chalk from "chalk";
5
- import { readFileSync, existsSync } from "node:fs";
5
+ import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
- import { dirname, join } from "node:path";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { homedir } from "node:os";
8
9
  import { fetchSkills, fetchCommands, fetchAgents } from "../lib/github.mjs";
9
10
  import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../lib/prompt.mjs";
10
11
  import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
11
12
  import { makeLocalSource } from "../lib/local.mjs";
12
13
  import { runPostInstall } from "../lib/post-install.mjs";
13
- import { setupHook } from "../lib/setup-hook.mjs";
14
+ import { runSync, runSyncAll } from "../lib/sync.mjs";
14
15
  import {
15
16
  readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
16
17
  MANIFEST_FILE,
@@ -20,38 +21,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
21
22
  const CWD = process.cwd();
22
23
 
23
- // `--sync`: re-install everything the manifest records (refreshed to the latest
24
- // catalog), refresh hooks the project already has, and prune anything removed upstream.
25
- async function runSync(manifest, catalog) {
26
- if (!manifest) {
27
- console.log(` ${chalk.yellow("Nothing to sync")} — no manifest in this project. Run without --sync first.\n`);
28
- return;
29
- }
30
- const orphans = computeOrphans(manifest, catalog);
31
- const orphanCount = orphans.skills.length + orphans.commands.length + orphans.agents.length;
32
- if (orphanCount > 0) {
33
- await removeArtifacts(CWD, orphans);
34
- console.log(` ${chalk.green("✔")} Pruned ${orphanCount} item(s) removed from the catalog.`);
35
- }
36
-
37
- const skills = catalog.skills.filter((s) => manifest.skills.includes(s.dirName));
38
- const commands = catalog.commands.filter((c) => manifest.commands.includes(c.fileName));
39
- if (skills.length > 0) { console.log(); await installSkills(skills); }
40
- if (commands.length > 0) { console.log(); await installCommands(commands); }
41
- const { installed } = await installRequiredAgents(commands, catalog.agents, CWD);
42
- if (existsSync(join(CWD, ".claude", "hooks", "skill-gate.sh"))) { console.log(); await setupHook(CWD); }
43
-
44
- await writeManifest(CWD, {
45
- catalogVersion: pkg.version,
46
- skills: skills.map((s) => s.dirName),
47
- commands: commands.map((c) => c.fileName),
48
- agents: installed.map((a) => a.fileName),
49
- });
50
- console.log(`\n ${chalk.green("✔")} ${chalk.bold(`Synced to catalog v${pkg.version}.`)}\n`);
51
- }
52
-
53
24
  async function main() {
54
25
  const isSync = process.argv.includes("--sync");
26
+ // --sync-all[=dir] syncs every project under dir (default: home) from one catalog fetch.
27
+ const syncAllArg = process.argv.find((a) => a === "--sync-all" || a.startsWith("--sync-all="));
28
+ const syncAllRoot = syncAllArg ? resolve(syncAllArg.split("=")[1] || homedir()) : null;
55
29
 
56
30
  // --local[=path] reads the catalog from a working copy instead of GitHub, so an
57
31
  // unreleased change can be installed and tried without publishing it first.
@@ -71,10 +45,10 @@ async function main() {
71
45
  fetchers.fetchSkills(), fetchers.fetchCommands(), fetchers.fetchAgents(),
72
46
  ]);
73
47
  const catalog = { skills, commands, agents };
48
+ if (syncAllRoot) return runSyncAll(syncAllRoot, catalog, pkg.version);
49
+ if (isSync) return runSync(CWD, catalog, pkg.version);
74
50
  const manifest = await readManifest(CWD);
75
51
 
76
- if (isSync) return runSync(manifest, catalog);
77
-
78
52
  // --- Prune items renamed or removed from the catalog upstream ---
79
53
  const orphans = computeOrphans(manifest, catalog);
80
54
  const orphanNames = [...orphans.skills, ...orphans.commands, ...orphans.agents];
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by ship-gate.sh: a new file must sit in the folder named for its kind.
3
+ # Only ADDED files are judged, so a repo laid out the old way cannot get worse and need not move.
4
+
5
+ STRUCTURE_JS_KINDS="components|hooks|api|queries|schemas|types|utils|stores|test"
6
+ STRUCTURE_KIND_FILE='(^|[._])(router|routes|controller|service|schema|model)s?\.[cm]?[jt]s$|(Router|Routes|Controller|Service|Schema|Model)s?\.[cm]?[jt]s$'
7
+
8
+ # --no-renames: a move must show up as an add, or renaming a misplaced file would slip through.
9
+ added_files() {
10
+ { git diff --no-renames --diff-filter=A --name-only "$BASE"...HEAD 2>/dev/null
11
+ git diff --no-renames --diff-filter=A --name-only HEAD 2>/dev/null
12
+ git ls-files --others --exclude-standard 2>/dev/null; } | sort -u
13
+ }
14
+
15
+ has_subject() { # has_subject <test path>: a file the test is named for, in the same folder
16
+ d=$(dirname "$1")
17
+ stem=$(basename "$1" | sed -E 's/\.(test|spec)\.[^.]+$//')
18
+ while :; do
19
+ for e in ts tsx js jsx mjs cjs astro; do [ -f "$d/$stem.$e" ] && return 0; done
20
+ case "$stem" in *.*) stem=${stem%.*} ;; *) return 1 ;; esac
21
+ done
22
+ }
23
+
24
+ feature_misplaced() { # feature_misplaced <path below features/<name>/>
25
+ case "$1" in
26
+ index.*) ;;
27
+ */*) printf '%s' "${1%%/*}" | grep -qxE "$STRUCTURE_JS_KINDS" \
28
+ || echo "${1%%/*}/ is not a kind folder ($STRUCTURE_JS_KINDS)" ;;
29
+ *) echo "loose at the feature root, where only index.* belongs" ;;
30
+ esac
31
+ }
32
+
33
+ js_misplaced() { # js_misplaced <path> <owner>
34
+ f=$1; b=${f##*/}; parent=$(basename "$(dirname "$f")")
35
+ if printf '%s' "$b" | grep -qE '\.(test|spec)\.[^.]+$'; then
36
+ printf '%s' "/$f" | grep -qE '/(e2e|tests?)/' && return
37
+ has_subject "$f" || echo "a test sits beside the file it tests, and nothing here is named ${b%%.*}"
38
+ return
39
+ fi
40
+ rest=$(printf '%s' "/$f" | sed -nE 's#.*/features/[^/]+/##p')
41
+ if [ -n "$rest" ]; then feature_misplaced "$rest"; return; fi
42
+ kinds="components|hooks|queries|api|schemas|types|utils"
43
+ grep -qs '"express"' "$2/package.json" && kinds="$kinds|routes|controllers|services|middleware|models"
44
+ if printf '%s' "/$f" | grep -qE "/src/($kinds)/"; then
45
+ echo "sorted by kind first; it belongs in <domain>/<kind>/ or shared/<kind>/"
46
+ elif printf '%s' "$b" | grep -qE '^use[A-Z].*\.[jt]sx?$' && ! printf '%s' "/$f" | grep -qE '/(hooks|queries)/'; then
47
+ echo "a hook belongs in hooks/ or queries/"
48
+ elif printf '%s' "$b" | grep -qE "$STRUCTURE_KIND_FILE" \
49
+ && ! printf '%s' "$parent" | grep -qxE 'routes|controllers|services|schemas|models|api|queries|types'; then
50
+ echo "the kind is in the filename; it belongs in a kind folder"
51
+ fi
52
+ }
53
+
54
+ py_misplaced() { # py_misplaced <path>
55
+ f=$1; b=${f##*/}; d=$(dirname "$f"); parent=${d##*/}
56
+ case "$b" in
57
+ test_*.py|*_test.py|conftest.py)
58
+ printf '%s' "/$f" | grep -qE '/tests/' || echo "tests live under tests/, mirroring the app"
59
+ return ;;
60
+ esac
61
+ k=$(printf '%s' "$b" | sed -nE 's/^(.*_)?(router|service|schema|model)s?\.py$/\2/p')
62
+ if [ -n "$k" ] && [ "$parent" != "${k}s" ]; then
63
+ echo "the kind is in the filename; it belongs in ${k}s/"
64
+ elif printf '%s' "$parent" | grep -qxE 'routers|services|schemas|models' && [ -f "$(dirname "$d")/main.py" ]; then
65
+ echo "sorted by kind first; it belongs in <domain>/$parent/"
66
+ fi
67
+ }
68
+
69
+ structure_check() {
70
+ bad=""; count=0
71
+ while IFS= read -r f; do
72
+ [ -f "$f" ] && ! is_ignored "$f" || continue
73
+ printf '%s' "/$f" | grep -qE '/(node_modules|\.stryker-tmp|mutants)/' && continue
74
+ o=$(owner_of "$f")
75
+ case "$f" in
76
+ *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.astro) why=$(js_misplaced "$f" "$o") ;;
77
+ *.py) grep -qis fastapi "$o/pyproject.toml" || continue; why=$(py_misplaced "$f") ;;
78
+ *) continue ;;
79
+ esac
80
+ count=$((count+1))
81
+ [ -n "$why" ] && bad="$bad $f — $why
82
+ "
83
+ done <<< "$(added_files)"
84
+ echo
85
+ if [ -n "$bad" ]; then
86
+ echo "STRUCTURE — new files outside their kind folder:"
87
+ printf '%s' "$bad"
88
+ echo " Move them (Project Structure in the react, fastapi or express skill), or ship with --force."
89
+ echo
90
+ return 1
91
+ fi
92
+ echo "STRUCTURE — ok, $count new file(s) in their kind folder"
93
+ echo
94
+ }
95
+
96
+ # Called when nothing else in the diff is gated, so the gate would otherwise pass it.
97
+ structure_fail() {
98
+ echo "ship-gate: FAIL — deal with the findings above, then run this again."
99
+ echo " To ship anyway: bash .claude/hooks/ship-gate.sh --force"
100
+ rm -f "$RECEIPT"
101
+ exit 1
102
+ }
@@ -46,7 +46,7 @@
46
46
  set -uo pipefail
47
47
 
48
48
  HERE=$(cd "$(dirname "$0")" && pwd)
49
- . "$HERE/ship-gate-projects.sh" || { echo "ship-gate: cannot read $HERE/ship-gate-projects.sh"; exit 1; }
49
+ . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
50
50
 
51
51
  MODE=""
52
52
  case "${1:-}" in
@@ -76,7 +76,7 @@ GAUNTLET_MAX_LINES=200
76
76
  GAUNTLET_MUTATE=""
77
77
  GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
78
78
  GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|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"
79
- GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */*.config.*"
79
+ GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
80
80
  # Length-checked and skill-audited like everything else, but not mutated.
81
81
  # Mutation pays on logic and burns time on presentation: a component's mutants
82
82
  # are class names, copy and JSX shape, none of which is behaviour, and one
@@ -159,6 +159,8 @@ if [ -n "$IGNORED" ]; then
159
159
  printf '%s\n' "$IGNORED" | sed 's/^/ /'
160
160
  fi
161
161
 
162
+ [ -n "$FILES" ] && echo "ship-gate: $(printf '%s\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
163
+ STATUS=0; structure_check || { STATUS=1; [ -z "$FILES" ] && structure_fail; }
162
164
  if [ -z "$FILES" ]; then
163
165
  # "I recognised nothing" is not "there is nothing", and the gate used to report
164
166
  # both as a PASS. A Godot repo changed only .gd files, which no extension in
@@ -183,10 +185,6 @@ if [ -z "$FILES" ]; then
183
185
  exit 0
184
186
  fi
185
187
 
186
- echo "ship-gate: $(printf '%s\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
187
- echo
188
- STATUS=0
189
-
190
188
  # ------------------------------------------------- check 1: file length (hard)
191
189
  OVER=""
192
190
  LARGEST=0
@@ -27,6 +27,7 @@ const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
27
27
  const GAUNTLET_FILENAME = "gauntlet.sh";
28
28
  const SHIP_GATE_FILENAME = "ship-gate.sh";
29
29
  const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
30
+ const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
30
31
  const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
31
32
  const VERSION_CHECK_FILENAME = "version-check.sh";
32
33
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
@@ -106,6 +107,7 @@ export async function setupHook(targetDir = process.cwd()) {
106
107
  GAUNTLET_FILENAME,
107
108
  SHIP_GATE_FILENAME,
108
109
  SHIP_GATE_PROJECTS_FILENAME,
110
+ SHIP_GATE_STRUCTURE_FILENAME,
109
111
  SHIP_GATE_HOOK_FILENAME,
110
112
  VERSION_CHECK_FILENAME,
111
113
  ]) {
package/lib/sync.mjs ADDED
@@ -0,0 +1,81 @@
1
+ import chalk from "chalk";
2
+ import { existsSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { join, basename } from "node:path";
5
+ import { installSkills, installCommands, installRequiredAgents } from "./install.mjs";
6
+ import { setupHook } from "./setup-hook.mjs";
7
+ import { readManifest, writeManifest, computeOrphans, removeArtifacts, MANIFEST_FILE } from "./manifest.mjs";
8
+
9
+ const SEARCH_DEPTH = 5;
10
+
11
+ // Re-install everything the manifest records (refreshed to the latest catalog),
12
+ // refresh hooks the project already has, and prune anything removed upstream.
13
+ export async function runSync(dir, catalog, version) {
14
+ const manifest = await readManifest(dir);
15
+ if (!manifest) {
16
+ console.log(` ${chalk.yellow("Nothing to sync")} — no manifest in this project. Run without --sync first.\n`);
17
+ return false;
18
+ }
19
+ const orphans = computeOrphans(manifest, catalog);
20
+ const orphanCount = orphans.skills.length + orphans.commands.length + orphans.agents.length;
21
+ if (orphanCount > 0) {
22
+ await removeArtifacts(dir, orphans);
23
+ console.log(` ${chalk.green("✔")} Pruned ${orphanCount} item(s) removed from the catalog.`);
24
+ }
25
+
26
+ const skills = catalog.skills.filter((s) => manifest.skills.includes(s.dirName));
27
+ const commands = catalog.commands.filter((c) => manifest.commands.includes(c.fileName));
28
+ if (skills.length > 0) { console.log(); await installSkills(skills, dir); }
29
+ if (commands.length > 0) { console.log(); await installCommands(commands, dir); }
30
+ const { installed } = await installRequiredAgents(commands, catalog.agents, dir);
31
+ if (existsSync(join(dir, ".claude", "hooks", "skill-gate.sh"))) { console.log(); await setupHook(dir); }
32
+
33
+ await writeManifest(dir, {
34
+ catalogVersion: version,
35
+ skills: skills.map((s) => s.dirName),
36
+ commands: commands.map((c) => c.fileName),
37
+ agents: installed.map((a) => a.fileName),
38
+ });
39
+ console.log(`\n ${chalk.green("✔")} ${chalk.bold(`Synced to catalog v${version}.`)}\n`);
40
+ return true;
41
+ }
42
+
43
+ export async function findProjects(root, depth = SEARCH_DEPTH) {
44
+ if (existsSync(join(root, ".claude", MANIFEST_FILE))) return [root];
45
+ if (depth === 0) return [];
46
+ let entries;
47
+ try {
48
+ entries = await readdir(root, { withFileTypes: true });
49
+ } catch {
50
+ return [];
51
+ }
52
+ const found = [];
53
+ for (const e of entries) {
54
+ if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules") continue;
55
+ found.push(...(await findProjects(join(root, e.name), depth - 1)));
56
+ }
57
+ return found;
58
+ }
59
+
60
+ export async function runSyncAll(root, catalog, version) {
61
+ const projects = await findProjects(root);
62
+ if (projects.length === 0) {
63
+ console.log(` ${chalk.yellow("No projects found")} under ${root} with .claude/${MANIFEST_FILE}.\n`);
64
+ return;
65
+ }
66
+ const failed = [];
67
+ for (const dir of projects) {
68
+ console.log(chalk.bold.cyan(`── ${dir}`));
69
+ try {
70
+ await runSync(dir, catalog, version);
71
+ } catch (err) {
72
+ failed.push(basename(dir));
73
+ console.log(` ${chalk.red("Error:")} ${err.message}\n`);
74
+ }
75
+ }
76
+ const ok = projects.length - failed.length;
77
+ console.log(` ${chalk.green("✔")} ${chalk.bold(`${ok} of ${projects.length} project(s) synced to v${version}.`)}`);
78
+ if (failed.length > 0) console.log(` ${chalk.red("✘")} Failed: ${failed.join(", ")}`);
79
+ console.log();
80
+ if (failed.length > 0) process.exitCode = 1;
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.33.8",
3
+ "version": "2.35.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "claude-skills": "bin/cli.mjs"
12
12
  },
13
13
  "scripts": {
14
- "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh hooks/",
14
+ "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh hooks/",
15
15
  "postpack": "git checkout -- README.md && rm -rf hooks"
16
16
  },
17
17
  "engines": {