@spardutti/claude-skills 2.17.0 → 2.18.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
@@ -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
 
@@ -345,7 +349,8 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
345
349
  gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
346
350
  ship-gate.sh — /ship's file-length and mutation checks, behind an exit code
347
351
  ship-gate-hook.sh — refuses git commit/push without a ship-gate receipt
348
- gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
352
+ version-check.sh — SessionStart nudge when a newer catalog is published
353
+ gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
349
354
  gauntlet-survey.sh — what the hook would do in every repo under a directory
350
355
  preflight.sh — everything that must pass before a release
351
356
  check-freshness.mjs — compares each skill's `tracks:` versions to the registries
@@ -589,6 +589,8 @@ 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
592
594
  # GAUNTLET_MUTATE="cmd" one explicit mutation command for the whole repo,
593
595
  # replacing per-project detection. $MUTATE_FLAGS holds
594
596
  # the --mutate flags, $FILES the changed files.
@@ -606,9 +608,23 @@ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
606
608
  cd "$PROJECT_DIR" 2>/dev/null || { echo "ship-gate: cannot read $PROJECT_DIR"; exit 1; }
607
609
  git rev-parse --git-dir >/dev/null 2>&1 || { echo "ship-gate: not a git repo"; exit 1; }
608
610
 
611
+ # \`git diff --name-only\` prints paths from the repo ROOT, but CLAUDE_PROJECT_DIR
612
+ # can point at a subdirectory of it. Testing those paths from the subdirectory
613
+ # found no files at all, so the gate said "nothing to check" and wrote a PASS
614
+ # receipt over a diff it never looked at. Work from the root and the paths git
615
+ # prints are the paths this script tests. The receipt key moves with it, and the
616
+ # PreToolUse hook asks this script for the key rather than computing its own, so
617
+ # the two cannot disagree.
618
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
619
+ if [ -n "$ROOT" ]; then
620
+ cd "$ROOT" || { echo "ship-gate: cannot read $ROOT"; exit 1; }
621
+ PROJECT_DIR="$ROOT"
622
+ fi
623
+
609
624
  GAUNTLET_MAX_LINES=200
610
625
  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"
626
+ GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
627
+ 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"
612
628
  [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
613
629
  [ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
614
630
 
@@ -651,6 +667,24 @@ FILES=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_SOURCE_EXT)$" \\
651
667
  done)
652
668
 
653
669
  if [ -z "$FILES" ]; then
670
+ # "I recognised nothing" is not "there is nothing", and the gate used to report
671
+ # both as a PASS. A Godot repo changed only .gd files, which no extension in
672
+ # the list named, so zero source files were found and a PASS receipt was
673
+ # written over work nothing had checked. An extension this gate cannot place is
674
+ # UNPROVEN: it does not block, but it never claims a pass either.
675
+ UNKNOWN=$(printf '%s\\n' "$CHANGED" | grep -E '\\.[A-Za-z0-9]+$' \\
676
+ | grep -viE "\\.($GAUNTLET_SOURCE_EXT)$" \\
677
+ | grep -viE "\\.($GAUNTLET_IGNORE_EXT)$" \\
678
+ | sed 's/.*\\.//' | sort -u | tr '\\n' ' ')
679
+ if [ -n "$UNKNOWN" ]; then
680
+ echo "ship-gate: UNPROVEN — nothing here was checked. These changed files are"
681
+ echo " in a language this gate does not know: $UNKNOWN"
682
+ echo " If they are source, add the extension in .claude/gauntlet.conf:"
683
+ echo " GAUNTLET_SOURCE_EXT=\\"\\$GAUNTLET_SOURCE_EXT|\${UNKNOWN%% *}\\""
684
+ echo " If they are not, add it to GAUNTLET_IGNORE_EXT the same way."
685
+ printf 'UNPROVEN %s unknown-extensions\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
686
+ exit 2
687
+ fi
654
688
  echo "ship-gate: no changed code files — nothing to check"
655
689
  printf 'PASS %s no-code-changes\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
656
690
  exit 0
@@ -954,12 +988,105 @@ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision"
954
988
  exit 0
955
989
  `;
956
990
 
991
+ // SessionStart: a once-a-day line when a newer catalog has been published.
992
+ // Canonical source: scripts/version-check.sh.
993
+ const VERSION_CHECK_SCRIPT = `#!/usr/bin/env bash
994
+ # version-check.sh — SessionStart. Says once a day when a newer catalog exists.
995
+ #
996
+ # SessionStart stdout is added to the model's context, so this does not draw a
997
+ # banner: it tells the assistant, and the assistant mentions it. That is the
998
+ # whole reach of this hook — it never installs anything and never asks a
999
+ # question the user has to answer before working.
1000
+ #
1001
+ # The rule that keeps it invisible: NEVER touch the network in the foreground.
1002
+ # A session start that waits on npm is a session start that hangs on a bad
1003
+ # connection, and a version nudge is not worth one second of that. So it reads
1004
+ # a cache written by the PREVIOUS session's background refresh, and kicks off
1005
+ # the next refresh detached. The first session after an install therefore says
1006
+ # nothing, and after that the answer is at worst a day old — which is the right
1007
+ # resolution for "there is a new version" anyway.
1008
+ #
1009
+ # Silent when: no manifest, no cache yet, offline, already current, or the user
1010
+ # has already been told today.
1011
+ #
1012
+ # CLAUDE_SKILLS_NO_VERSION_CHECK=1 turns it off entirely
1013
+
1014
+ set -uo pipefail
1015
+
1016
+ [ -n "\${CLAUDE_SKILLS_NO_VERSION_CHECK:-}" ] && exit 0
1017
+
1018
+ PKG="@spardutti/claude-skills"
1019
+ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
1020
+ MANIFEST="$PROJECT_DIR/.claude/.claude-skills.json"
1021
+
1022
+ # No manifest means the CLI never installed here, so there is nothing to update.
1023
+ [ -f "$MANIFEST" ] || exit 0
1024
+
1025
+ INSTALLED=$(sed -n 's/.*"catalogVersion"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$MANIFEST" | head -1)
1026
+ [ -n "$INSTALLED" ] || exit 0
1027
+
1028
+ # One cache for the whole machine, not one per project: the answer is the same
1029
+ # everywhere, and a developer with ten repos should still cost one request.
1030
+ CACHE_DIR="\${HOME:-/tmp}/.claude"
1031
+ CACHE="$CACHE_DIR/.claude-skills-version"
1032
+ STAMP="$CACHE_DIR/.claude-skills-version-told"
1033
+ mkdir -p "$CACHE_DIR" 2>/dev/null
1034
+
1035
+ NOW=$(date +%s 2>/dev/null || echo 0)
1036
+ DAY=86400
1037
+
1038
+ # Refresh in the background, fully detached. Nothing downstream waits on it: all
1039
+ # three streams are closed, or the session start blocks until curl gives up.
1040
+ refresh() {
1041
+ AGE=$DAY
1042
+ if [ -f "$CACHE" ]; then
1043
+ MTIME=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
1044
+ AGE=$((NOW - MTIME))
1045
+ fi
1046
+ [ "$AGE" -lt "$DAY" ] && return
1047
+ command -v curl >/dev/null 2>&1 || return
1048
+ ( curl -fsS --max-time 5 "https://registry.npmjs.org/$PKG/latest" \\
1049
+ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' \\
1050
+ | head -1 > "$CACHE.tmp" 2>/dev/null
1051
+ # Only replace the cache with something that parsed. A truncated reply that
1052
+ # overwrote it would nudge toward a version that does not exist.
1053
+ if [ -s "$CACHE.tmp" ]; then mv "$CACHE.tmp" "$CACHE"; else rm -f "$CACHE.tmp"; fi
1054
+ ) </dev/null >/dev/null 2>&1 &
1055
+ }
1056
+ refresh
1057
+
1058
+ [ -f "$CACHE" ] || exit 0
1059
+ LATEST=$(head -1 "$CACHE" | tr -d '[:space:]')
1060
+ [ -n "$LATEST" ] || exit 0
1061
+ [ "$LATEST" = "$INSTALLED" ] && exit 0
1062
+
1063
+ # sort -V puts the newer one last. Equal versions were already handled, so the
1064
+ # only case left to reject is a cache that is BEHIND the install — which happens
1065
+ # normally right after publishing from the machine that publishes.
1066
+ NEWER=$(printf '%s\\n%s\\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
1067
+ [ "$NEWER" = "$LATEST" ] || exit 0
1068
+
1069
+ # Once a day, however many sessions that day holds.
1070
+ if [ -f "$STAMP" ]; then
1071
+ TOLD=$(date -r "$STAMP" +%s 2>/dev/null || echo 0)
1072
+ [ $((NOW - TOLD)) -lt "$DAY" ] && exit 0
1073
+ fi
1074
+ : > "$STAMP"
1075
+
1076
+ echo "A newer version of the $PKG catalog is available: $INSTALLED installed, $LATEST published."
1077
+ echo "Mention this to the user ONCE, in a single line, and only when the current task is done."
1078
+ echo "Do not interrupt what they are doing, and do not run the installer unless they ask."
1079
+ echo "To update, they run: npx $PKG"
1080
+ exit 0
1081
+ `;
1082
+
957
1083
  const GATE_FILENAME = "skill-gate.sh";
958
1084
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
959
1085
  const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
960
1086
  const GAUNTLET_FILENAME = "gauntlet.sh";
961
1087
  const SHIP_GATE_FILENAME = "ship-gate.sh";
962
1088
  const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
1089
+ const VERSION_CHECK_FILENAME = "version-check.sh";
963
1090
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
964
1091
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
965
1092
 
@@ -1024,6 +1151,7 @@ export async function setupHook(targetDir = process.cwd()) {
1024
1151
  const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
1025
1152
  const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
1026
1153
  const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
1154
+ const versionCheckPath = join(hooksDir, VERSION_CHECK_FILENAME);
1027
1155
  const settingsPath = join(resolved, ".claude", "settings.json");
1028
1156
 
1029
1157
  await mkdir(hooksDir, { recursive: true });
@@ -1039,6 +1167,8 @@ export async function setupHook(targetDir = process.cwd()) {
1039
1167
  await chmod(shipGatePath, 0o755);
1040
1168
  await writeFile(shipGateHookPath, SHIP_GATE_HOOK_SCRIPT, { mode: 0o755 });
1041
1169
  await chmod(shipGateHookPath, 0o755);
1170
+ await writeFile(versionCheckPath, VERSION_CHECK_SCRIPT, { mode: 0o755 });
1171
+ await chmod(versionCheckPath, 0o755);
1042
1172
 
1043
1173
  // Remove the legacy audit-runner hook file from prior installs (2.3.x).
1044
1174
  try {
@@ -1114,6 +1244,17 @@ export async function setupHook(targetDir = process.cwd()) {
1114
1244
  settings.hooks.PostToolUse = [autoMarkEntry];
1115
1245
  }
1116
1246
 
1247
+ // Register SessionStart version check. No matcher: startup and resume both
1248
+ // want it, and the script's own daily stamp keeps a burst of resumes quiet.
1249
+ const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
1250
+ const versionCheckEntry = { hooks: [{ type: "command", command: versionCheckCommand }] };
1251
+
1252
+ if (Array.isArray(settings.hooks.SessionStart)) {
1253
+ upsert(settings.hooks.SessionStart, VERSION_CHECK_FILENAME, versionCheckEntry);
1254
+ } else {
1255
+ settings.hooks.SessionStart = [versionCheckEntry];
1256
+ }
1257
+
1117
1258
  // Register Stop gauntlet hook.
1118
1259
  const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
1119
1260
  const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {