@spardutti/claude-skills 2.17.0 → 2.19.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 +15 -1
- package/lib/setup-hook.mjs +176 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -164,6 +164,10 @@ The last two guard the **back** — nothing finishes or ships until the code pas
|
|
|
164
164
|
- `gauntlet.sh` — a `Stop` hook running the fast gates on your changed files (see [Automatic Verification](#automatic-verification-gauntlet))
|
|
165
165
|
- `ship-gate-hook.sh` — a `PreToolUse` gate on `Bash` that refuses `git commit` and `git push` without a receipt from `ship-gate.sh`
|
|
166
166
|
|
|
167
|
+
One more hook stays out of the way entirely:
|
|
168
|
+
|
|
169
|
+
- `version-check.sh` — a `SessionStart` hook that mentions a newer catalog at most once a day. It never touches the network in the foreground: it reads a cache the previous session refreshed in the background, so it costs a file read. Silent when there is no cache, no network, or nothing new. `CLAUDE_SKILLS_NO_VERSION_CHECK=1` turns it off.
|
|
170
|
+
|
|
167
171
|
<details>
|
|
168
172
|
<summary>How the gate works</summary>
|
|
169
173
|
|
|
@@ -295,8 +299,17 @@ GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test file
|
|
|
295
299
|
GAUNTLET_MAX_LINES=200 # per-file limit; 0 turns the check off
|
|
296
300
|
GAUNTLET_SOURCE_EXT='ts|tsx|py' # what counts as SOURCE — not the same question
|
|
297
301
|
GAUNTLET_MUTATE='bash mutate.sh' # replace per-project mutation detection
|
|
302
|
+
GAUNTLET_IGNORE_FILES='*.gen.ts' # globs that are never gated — see below
|
|
298
303
|
```
|
|
299
304
|
|
|
305
|
+
`GAUNTLET_IGNORE_FILES` holds files nobody wrote, so the line limit and mutation both
|
|
306
|
+
skip them. It defaults to `*.gen.ts *.gen.tsx *.generated.* */migrations/*.py
|
|
307
|
+
*/alembic/versions/*.py */components/ui/*.tsx` — a TanStack `routeTree.gen.ts`, a Django
|
|
308
|
+
or Alembic migration, and a vendored shadcn component are all over 200 lines, none of
|
|
309
|
+
them can be split, and gating them only teaches `--force`. Every skipped file is named in
|
|
310
|
+
the output. Patterns match against `/<path>`, so `*/components/ui/*.tsx` covers the repo
|
|
311
|
+
root and `apps/web/src` alike. Set it to `""` to gate everything.
|
|
312
|
+
|
|
300
313
|
`GAUNTLET_SOURCE_EXT` is separate from `GAUNTLET_CODE_EXT` on purpose. The Stop hook asks
|
|
301
314
|
"did anything worth checking change", so a docs repo sets `CODE_EXT` to include `.md`. The
|
|
302
315
|
line limit and mutation ask about source code, where that answer is wrong.
|
|
@@ -345,7 +358,8 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
|
|
|
345
358
|
gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
|
|
346
359
|
ship-gate.sh — /ship's file-length and mutation checks, behind an exit code
|
|
347
360
|
ship-gate-hook.sh — refuses git commit/push without a ship-gate receipt
|
|
348
|
-
|
|
361
|
+
version-check.sh — SessionStart nudge when a newer catalog is published
|
|
362
|
+
gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
|
|
349
363
|
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
350
364
|
preflight.sh — everything that must pass before a release
|
|
351
365
|
check-freshness.mjs — compares each skill's `tracks:` versions to the registries
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -589,6 +589,12 @@ const SHIP_GATE_SCRIPT = `#!/usr/bin/env bash
|
|
|
589
589
|
# GAUNTLET_SOURCE_EXT=... which extensions count as source. Deliberately NOT
|
|
590
590
|
# GAUNTLET_CODE_EXT: the Stop hook's key answers a
|
|
591
591
|
# different question, and a docs repo sets it to .md
|
|
592
|
+
# GAUNTLET_IGNORE_EXT=... extensions that need no gate at all. An extension in
|
|
593
|
+
# neither list is reported UNPROVEN rather than passed
|
|
594
|
+
# GAUNTLET_IGNORE_FILES=.. space-separated globs, matched against /<path>, for
|
|
595
|
+
# files nobody wrote: generated code and vendored
|
|
596
|
+
# components. They are named in the output, never
|
|
597
|
+
# dropped silently. Set it to "" to gate everything
|
|
592
598
|
# GAUNTLET_MUTATE="cmd" one explicit mutation command for the whole repo,
|
|
593
599
|
# replacing per-project detection. $MUTATE_FLAGS holds
|
|
594
600
|
# the --mutate flags, $FILES the changed files.
|
|
@@ -606,9 +612,24 @@ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
|
606
612
|
cd "$PROJECT_DIR" 2>/dev/null || { echo "ship-gate: cannot read $PROJECT_DIR"; exit 1; }
|
|
607
613
|
git rev-parse --git-dir >/dev/null 2>&1 || { echo "ship-gate: not a git repo"; exit 1; }
|
|
608
614
|
|
|
615
|
+
# \`git diff --name-only\` prints paths from the repo ROOT, but CLAUDE_PROJECT_DIR
|
|
616
|
+
# can point at a subdirectory of it. Testing those paths from the subdirectory
|
|
617
|
+
# found no files at all, so the gate said "nothing to check" and wrote a PASS
|
|
618
|
+
# receipt over a diff it never looked at. Work from the root and the paths git
|
|
619
|
+
# prints are the paths this script tests. The receipt key moves with it, and the
|
|
620
|
+
# PreToolUse hook asks this script for the key rather than computing its own, so
|
|
621
|
+
# the two cannot disagree.
|
|
622
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
623
|
+
if [ -n "$ROOT" ]; then
|
|
624
|
+
cd "$ROOT" || { echo "ship-gate: cannot read $ROOT"; exit 1; }
|
|
625
|
+
PROJECT_DIR="$ROOT"
|
|
626
|
+
fi
|
|
627
|
+
|
|
609
628
|
GAUNTLET_MAX_LINES=200
|
|
610
629
|
GAUNTLET_MUTATE=""
|
|
611
|
-
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift"
|
|
630
|
+
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
631
|
+
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"
|
|
632
|
+
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx"
|
|
612
633
|
[ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
613
634
|
[ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
|
|
614
635
|
|
|
@@ -645,12 +666,58 @@ if [ "$MODE" = force ]; then
|
|
|
645
666
|
echo "ship-gate: FORCED — receipt written without running any check."
|
|
646
667
|
exit 0
|
|
647
668
|
fi
|
|
648
|
-
|
|
669
|
+
SOURCED=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_SOURCE_EXT)$" \\
|
|
649
670
|
| grep -vE '(\\.|_)(test|spec)\\.[^.]+$|(^|/)tests?/|(^|/)\\.stryker-tmp/' | while read -r f; do
|
|
650
671
|
[ -f "$f" ] && printf '%s\\n' "$f"
|
|
651
672
|
done)
|
|
652
673
|
|
|
674
|
+
# A generated file cannot be split and a vendored one is not ours to split, so
|
|
675
|
+
# the length check fires on every route regeneration and teaches people to
|
|
676
|
+
# --force. A gate that is always forced is not a gate. These are held out of
|
|
677
|
+
# both checks — and printed, because a silent skip is the same lie as a fake
|
|
678
|
+
# PASS. The leading / is what lets one pattern match components/ui at the repo
|
|
679
|
+
# root and apps/web/src/components/ui alike.
|
|
680
|
+
# set -f matters: an unquoted list is glob-expanded before it is split, so
|
|
681
|
+
# */components/ui/*.tsx turned itself into the very path it was meant to match
|
|
682
|
+
# and then matched nothing.
|
|
683
|
+
is_ignored() {
|
|
684
|
+
set -f
|
|
685
|
+
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
686
|
+
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
687
|
+
done
|
|
688
|
+
set +f
|
|
689
|
+
return 1
|
|
690
|
+
}
|
|
691
|
+
FILES=$(printf '%s\\n' "$SOURCED" | while IFS= read -r f; do
|
|
692
|
+
[ -n "$f" ] && ! is_ignored "$f" && printf '%s\\n' "$f"
|
|
693
|
+
done)
|
|
694
|
+
IGNORED=$(printf '%s\\n' "$SOURCED" | while IFS= read -r f; do
|
|
695
|
+
[ -n "$f" ] && is_ignored "$f" && printf '%s\\n' "$f"
|
|
696
|
+
done)
|
|
697
|
+
if [ -n "$IGNORED" ]; then
|
|
698
|
+
echo "ship-gate: not gated, matched GAUNTLET_IGNORE_FILES:"
|
|
699
|
+
printf '%s\\n' "$IGNORED" | sed 's/^/ /'
|
|
700
|
+
fi
|
|
701
|
+
|
|
653
702
|
if [ -z "$FILES" ]; then
|
|
703
|
+
# "I recognised nothing" is not "there is nothing", and the gate used to report
|
|
704
|
+
# both as a PASS. A Godot repo changed only .gd files, which no extension in
|
|
705
|
+
# the list named, so zero source files were found and a PASS receipt was
|
|
706
|
+
# written over work nothing had checked. An extension this gate cannot place is
|
|
707
|
+
# UNPROVEN: it does not block, but it never claims a pass either.
|
|
708
|
+
UNKNOWN=$(printf '%s\\n' "$CHANGED" | grep -E '\\.[A-Za-z0-9]+$' \\
|
|
709
|
+
| grep -viE "\\.($GAUNTLET_SOURCE_EXT)$" \\
|
|
710
|
+
| grep -viE "\\.($GAUNTLET_IGNORE_EXT)$" \\
|
|
711
|
+
| sed 's/.*\\.//' | sort -u | tr '\\n' ' ')
|
|
712
|
+
if [ -n "$UNKNOWN" ]; then
|
|
713
|
+
echo "ship-gate: UNPROVEN — nothing here was checked. These changed files are"
|
|
714
|
+
echo " in a language this gate does not know: $UNKNOWN"
|
|
715
|
+
echo " If they are source, add the extension in .claude/gauntlet.conf:"
|
|
716
|
+
echo " GAUNTLET_SOURCE_EXT=\\"\\$GAUNTLET_SOURCE_EXT|\${UNKNOWN%% *}\\""
|
|
717
|
+
echo " If they are not, add it to GAUNTLET_IGNORE_EXT the same way."
|
|
718
|
+
printf 'UNPROVEN %s unknown-extensions\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
|
|
719
|
+
exit 2
|
|
720
|
+
fi
|
|
654
721
|
echo "ship-gate: no changed code files — nothing to check"
|
|
655
722
|
printf 'PASS %s no-code-changes\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
|
|
656
723
|
exit 0
|
|
@@ -954,12 +1021,105 @@ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision"
|
|
|
954
1021
|
exit 0
|
|
955
1022
|
`;
|
|
956
1023
|
|
|
1024
|
+
// SessionStart: a once-a-day line when a newer catalog has been published.
|
|
1025
|
+
// Canonical source: scripts/version-check.sh.
|
|
1026
|
+
const VERSION_CHECK_SCRIPT = `#!/usr/bin/env bash
|
|
1027
|
+
# version-check.sh — SessionStart. Says once a day when a newer catalog exists.
|
|
1028
|
+
#
|
|
1029
|
+
# SessionStart stdout is added to the model's context, so this does not draw a
|
|
1030
|
+
# banner: it tells the assistant, and the assistant mentions it. That is the
|
|
1031
|
+
# whole reach of this hook — it never installs anything and never asks a
|
|
1032
|
+
# question the user has to answer before working.
|
|
1033
|
+
#
|
|
1034
|
+
# The rule that keeps it invisible: NEVER touch the network in the foreground.
|
|
1035
|
+
# A session start that waits on npm is a session start that hangs on a bad
|
|
1036
|
+
# connection, and a version nudge is not worth one second of that. So it reads
|
|
1037
|
+
# a cache written by the PREVIOUS session's background refresh, and kicks off
|
|
1038
|
+
# the next refresh detached. The first session after an install therefore says
|
|
1039
|
+
# nothing, and after that the answer is at worst a day old — which is the right
|
|
1040
|
+
# resolution for "there is a new version" anyway.
|
|
1041
|
+
#
|
|
1042
|
+
# Silent when: no manifest, no cache yet, offline, already current, or the user
|
|
1043
|
+
# has already been told today.
|
|
1044
|
+
#
|
|
1045
|
+
# CLAUDE_SKILLS_NO_VERSION_CHECK=1 turns it off entirely
|
|
1046
|
+
|
|
1047
|
+
set -uo pipefail
|
|
1048
|
+
|
|
1049
|
+
[ -n "\${CLAUDE_SKILLS_NO_VERSION_CHECK:-}" ] && exit 0
|
|
1050
|
+
|
|
1051
|
+
PKG="@spardutti/claude-skills"
|
|
1052
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
1053
|
+
MANIFEST="$PROJECT_DIR/.claude/.claude-skills.json"
|
|
1054
|
+
|
|
1055
|
+
# No manifest means the CLI never installed here, so there is nothing to update.
|
|
1056
|
+
[ -f "$MANIFEST" ] || exit 0
|
|
1057
|
+
|
|
1058
|
+
INSTALLED=$(sed -n 's/.*"catalogVersion"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$MANIFEST" | head -1)
|
|
1059
|
+
[ -n "$INSTALLED" ] || exit 0
|
|
1060
|
+
|
|
1061
|
+
# One cache for the whole machine, not one per project: the answer is the same
|
|
1062
|
+
# everywhere, and a developer with ten repos should still cost one request.
|
|
1063
|
+
CACHE_DIR="\${HOME:-/tmp}/.claude"
|
|
1064
|
+
CACHE="$CACHE_DIR/.claude-skills-version"
|
|
1065
|
+
STAMP="$CACHE_DIR/.claude-skills-version-told"
|
|
1066
|
+
mkdir -p "$CACHE_DIR" 2>/dev/null
|
|
1067
|
+
|
|
1068
|
+
NOW=$(date +%s 2>/dev/null || echo 0)
|
|
1069
|
+
DAY=86400
|
|
1070
|
+
|
|
1071
|
+
# Refresh in the background, fully detached. Nothing downstream waits on it: all
|
|
1072
|
+
# three streams are closed, or the session start blocks until curl gives up.
|
|
1073
|
+
refresh() {
|
|
1074
|
+
AGE=$DAY
|
|
1075
|
+
if [ -f "$CACHE" ]; then
|
|
1076
|
+
MTIME=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
|
|
1077
|
+
AGE=$((NOW - MTIME))
|
|
1078
|
+
fi
|
|
1079
|
+
[ "$AGE" -lt "$DAY" ] && return
|
|
1080
|
+
command -v curl >/dev/null 2>&1 || return
|
|
1081
|
+
( curl -fsS --max-time 5 "https://registry.npmjs.org/$PKG/latest" \\
|
|
1082
|
+
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' \\
|
|
1083
|
+
| head -1 > "$CACHE.tmp" 2>/dev/null
|
|
1084
|
+
# Only replace the cache with something that parsed. A truncated reply that
|
|
1085
|
+
# overwrote it would nudge toward a version that does not exist.
|
|
1086
|
+
if [ -s "$CACHE.tmp" ]; then mv "$CACHE.tmp" "$CACHE"; else rm -f "$CACHE.tmp"; fi
|
|
1087
|
+
) </dev/null >/dev/null 2>&1 &
|
|
1088
|
+
}
|
|
1089
|
+
refresh
|
|
1090
|
+
|
|
1091
|
+
[ -f "$CACHE" ] || exit 0
|
|
1092
|
+
LATEST=$(head -1 "$CACHE" | tr -d '[:space:]')
|
|
1093
|
+
[ -n "$LATEST" ] || exit 0
|
|
1094
|
+
[ "$LATEST" = "$INSTALLED" ] && exit 0
|
|
1095
|
+
|
|
1096
|
+
# sort -V puts the newer one last. Equal versions were already handled, so the
|
|
1097
|
+
# only case left to reject is a cache that is BEHIND the install — which happens
|
|
1098
|
+
# normally right after publishing from the machine that publishes.
|
|
1099
|
+
NEWER=$(printf '%s\\n%s\\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
|
|
1100
|
+
[ "$NEWER" = "$LATEST" ] || exit 0
|
|
1101
|
+
|
|
1102
|
+
# Once a day, however many sessions that day holds.
|
|
1103
|
+
if [ -f "$STAMP" ]; then
|
|
1104
|
+
TOLD=$(date -r "$STAMP" +%s 2>/dev/null || echo 0)
|
|
1105
|
+
[ $((NOW - TOLD)) -lt "$DAY" ] && exit 0
|
|
1106
|
+
fi
|
|
1107
|
+
: > "$STAMP"
|
|
1108
|
+
|
|
1109
|
+
echo "A newer version of the $PKG catalog is available: $INSTALLED installed, $LATEST published."
|
|
1110
|
+
echo "Mention this to the user ONCE, in a single line, and only when the current task is done."
|
|
1111
|
+
echo "Do not interrupt what they are doing, and do not run the installer unless they ask."
|
|
1112
|
+
echo "To update, they run: npx $PKG"
|
|
1113
|
+
exit 0
|
|
1114
|
+
`;
|
|
1115
|
+
|
|
957
1116
|
const GATE_FILENAME = "skill-gate.sh";
|
|
958
1117
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
959
1118
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
960
1119
|
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
961
1120
|
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
962
1121
|
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
1122
|
+
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
963
1123
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
964
1124
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
965
1125
|
|
|
@@ -1024,6 +1184,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
1024
1184
|
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
1025
1185
|
const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
|
|
1026
1186
|
const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
|
|
1187
|
+
const versionCheckPath = join(hooksDir, VERSION_CHECK_FILENAME);
|
|
1027
1188
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
1028
1189
|
|
|
1029
1190
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -1039,6 +1200,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
1039
1200
|
await chmod(shipGatePath, 0o755);
|
|
1040
1201
|
await writeFile(shipGateHookPath, SHIP_GATE_HOOK_SCRIPT, { mode: 0o755 });
|
|
1041
1202
|
await chmod(shipGateHookPath, 0o755);
|
|
1203
|
+
await writeFile(versionCheckPath, VERSION_CHECK_SCRIPT, { mode: 0o755 });
|
|
1204
|
+
await chmod(versionCheckPath, 0o755);
|
|
1042
1205
|
|
|
1043
1206
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
1044
1207
|
try {
|
|
@@ -1114,6 +1277,17 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
1114
1277
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
1115
1278
|
}
|
|
1116
1279
|
|
|
1280
|
+
// Register SessionStart version check. No matcher: startup and resume both
|
|
1281
|
+
// want it, and the script's own daily stamp keeps a burst of resumes quiet.
|
|
1282
|
+
const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
|
|
1283
|
+
const versionCheckEntry = { hooks: [{ type: "command", command: versionCheckCommand }] };
|
|
1284
|
+
|
|
1285
|
+
if (Array.isArray(settings.hooks.SessionStart)) {
|
|
1286
|
+
upsert(settings.hooks.SessionStart, VERSION_CHECK_FILENAME, versionCheckEntry);
|
|
1287
|
+
} else {
|
|
1288
|
+
settings.hooks.SessionStart = [versionCheckEntry];
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1117
1291
|
// Register Stop gauntlet hook.
|
|
1118
1292
|
const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
|
|
1119
1293
|
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spardutti/claude-skills",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "CLI to install Claude Code skills from the claude-skills collection",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Spardutti/claude-skills.git"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"claude-skills": "bin/cli.mjs"
|
|
8
12
|
},
|