@spardutti/claude-skills 2.31.0 → 2.32.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.
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ # PostToolUse on Skill: marks gate satisfied + records loaded skill.
3
+
4
+ INPUT=$(cat)
5
+
6
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
7
+ if [ -z "$SESSION_ID" ]; then
8
+ exit 0
9
+ fi
10
+
11
+ # Keyed exactly as the gate keys it. A subagent carries the parent's
12
+ # session_id, so both scripts fall back to agent_id when one is present — and
13
+ # they must agree, or a loaded skill writes a marker the gate never looks for
14
+ # and every edit is denied forever.
15
+ AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
16
+ KEY="$SESSION_ID"
17
+ if [ -n "$AGENT_ID" ]; then
18
+ KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
19
+ fi
20
+
21
+ touch "/tmp/claude-skill-gate-$KEY"
22
+
23
+ SKILL_NAME=$(printf '%s' "$INPUT" | grep -o '"skill":"[^"]*"' | head -1 | sed 's/"skill":"//; s/"$//')
24
+ if [ -n "$SKILL_NAME" ]; then
25
+ # Sanitize: only allow [A-Za-z0-9_-] in the marker filename.
26
+ SAFE_NAME=$(printf '%s' "$SKILL_NAME" | tr -cd 'A-Za-z0-9_-')
27
+ if [ -n "$SAFE_NAME" ]; then
28
+ touch "/tmp/claude-skill-loaded-$KEY-$SAFE_NAME"
29
+ fi
30
+ fi
31
+
32
+ exit 0
@@ -0,0 +1,192 @@
1
+ #!/bin/bash
2
+ # PreToolUse gate: forces skill evaluation before file-writing tools run.
3
+
4
+ INPUT=$(cat)
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
8
+
9
+ if ! find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | grep -q .; then
10
+ exit 0
11
+ fi
12
+
13
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
14
+ if [ -z "$SESSION_ID" ]; then
15
+ exit 0
16
+ fi
17
+
18
+ # A subagent reports the PARENT's session_id — measured, not assumed: a probe
19
+ # hook dumped its own stdin from the main thread and from inside a subagent and
20
+ # the two ids were identical. So a worker spawned to implement part of a plan
21
+ # inherited the main thread's cleared markers and edited with none of the
22
+ # skills loaded in its own context.
23
+ #
24
+ # agent_id is present only inside a subagent, and holds steady across that
25
+ # subagent's turns, so keying on it gives each worker its own namespace and
26
+ # costs it one Skill call per skill, not one per turn.
27
+ AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
28
+ KEY="$SESSION_ID"
29
+ if [ -n "$AGENT_ID" ]; then
30
+ KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
31
+ fi
32
+
33
+ # The marker is checked at the very bottom now, not here. A skill the edited
34
+ # file's own stack demands is not something an already-cleared gate excuses,
35
+ # so the required-skill scan below runs first.
36
+ MARKER="/tmp/claude-skill-gate-$KEY"
37
+
38
+ # Write|Edit|MultiEdit is not the only way to change a file. A session that edits
39
+ # through `python3 - <<'PY'` in Bash walked past this gate entirely — twelve
40
+ # source files, not one prompt — and some harnesses actively tell the model to
41
+ # prefer Bash for edits. So Bash is gated too, but only for commands that can
42
+ # write, and only until the gate is cleared once for the session.
43
+
44
+ # Skills are about code. Blocking a plan document to ask how the React rules
45
+ # apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
46
+ # Config files stay gated — skills do have rules about tsconfig and compose.
47
+ PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
48
+ CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql|sh|json|ya?ml|toml'
49
+
50
+ TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
51
+
52
+ # Every file this call would write, collected for the required-skill scan below.
53
+ TARGETS=""
54
+
55
+ if [ "$TOOL" != "Bash" ]; then
56
+ # The structured tools name their target outright.
57
+ TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
58
+ case "$TARGET" in
59
+ # The settings file that configures the escape hatch cannot sit behind the
60
+ # gate, or a denied ack has no way to be un-denied. The bare relative form
61
+ # matters: a skill claiming **/* gated it, and nothing could re-open it.
62
+ .claude/settings.json|.claude/settings.local.json) exit 0 ;;
63
+ */.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
64
+ *.*) printf '%s' "$TARGET" | grep -qiE ".($PROSE_EXT)$" && exit 0 ;;
65
+ esac
66
+ TARGETS="$TARGET"
67
+ fi
68
+ if [ "$TOOL" = "Bash" ]; then
69
+ CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
70
+
71
+ # Never gate the command that clears the gate, or this deadlocks.
72
+ case "$CMD" in
73
+ *claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
74
+ *.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
75
+ esac
76
+
77
+ # /dev/null redirects are not file writes; drop them before looking for one.
78
+
79
+ # A command that names a prose file and no code file is writing prose.
80
+ if printf '%s' "$CMD" | grep -qiE ".($PROSE_EXT)([^A-Za-z0-9]|$)" && ! printf '%s' "$CMD" | grep -qiE ".($CODE_EXT)([^A-Za-z0-9]|$)"; then
81
+ exit 0
82
+ fi
83
+ STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
84
+ WRITES=""
85
+ case "$STRIPPED" in
86
+ *">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
87
+ esac
88
+ # An interpreter given inline code or a heredoc can write anything, and the
89
+ # shell shows no redirect at all — this is the shape that got past the gate.
90
+ case "$CMD" in
91
+ *"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
92
+ esac
93
+ [ -z "$WRITES" ] && exit 0
94
+ # A Bash write names its files in the command itself. Dockerfile and
95
+ # .dockerignore carry no extension, so they are matched by name.
96
+ TARGETS=$(printf '%s' "$CMD" | grep -oiE "[A-Za-z0-9_./-]+.($CODE_EXT)|[A-Za-z0-9_./-]*(Dockerfile[A-Za-z0-9_.-]*|.dockerignore)" 2>/dev/null)
97
+ fi
98
+
99
+ # --- mandatory skills -------------------------------------------------------
100
+ # The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
101
+ # and hands it an all-SKIP escape. A session took that escape, touched the
102
+ # marker, and edited React files with none of the React rules loaded. It had
103
+ # followed the hook exactly: the hook let it decide.
104
+ #
105
+ # So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
106
+ # edited file matches its paths AND this project depends on a package it
107
+ # tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
108
+ # without both fields keep the old behaviour, so this tightens nothing that was
109
+ # not deliberately declared.
110
+ MISSING=""
111
+ MANIFESTS=""
112
+ if [ -n "$TARGETS" ]; then
113
+ MANIFESTS=$(find "$PROJECT_DIR" -maxdepth 4 \( -name node_modules -o -name .git -o -name .venv -o -name dist \) -prune -o \( -name package.json -o -name pyproject.toml -o -name requirements.txt \) -print 2>/dev/null)
114
+ fi
115
+ if [ -n "$TARGETS" ]; then
116
+ for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
117
+ # Read gate-paths from under metadata:, never a bare paths: key. A bare
118
+ # paths: made Claude Code drop the skill from its registry outright —
119
+ # every Skill(name) call returned "Unknown skill" and, with no SKIP for a
120
+ # mandatory skill, the session could not edit code at all. metadata is
121
+ # documented as free-form and ignored by Claude Code, and the docs say in
122
+ # so many words not to reuse paths as a key inside it.
123
+ #
124
+ # The value must be quoted in YAML either way: a scalar opening with * is
125
+ # an alias indicator.
126
+ UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
127
+ SPATHS=$(sed -n 's/^[[:space:]]*gate-paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
128
+ STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
129
+ SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
130
+ [ -z "$SPATHS" ] && continue
131
+ [ -z "$SNAME" ] && continue
132
+
133
+ HIT=""
134
+ for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
135
+ # Claude Code writes these gitignore-style. A case glob has no **, but *
136
+ # already crosses / here, so dropping the **/ prefix is equivalent.
137
+ G=$(printf '%s' "$G" | sed 's!\*\*/!!g')
138
+ case "$G" in \**|/*) ;; *) G="*$G" ;; esac
139
+ for T in $TARGETS; do
140
+ case "$T" in $G) HIT=1 ;; esac
141
+ done
142
+ done
143
+ [ -z "$HIT" ] && continue
144
+
145
+ # A .tsx file is not proof of React — it could be Astro, Solid or Preact.
146
+ # The packages the skill tracks are the proof, so the manifests decide.
147
+ #
148
+ # Some skills need no such proof: a .sql file is SQL and a Dockerfile is a
149
+ # Dockerfile, and no package declares either. A skill that names paths and
150
+ # no tracks is taken at its word.
151
+ PY=""
152
+ case "$STRACKS" in *pypi*) PY=1 ;; esac
153
+ DEP=""
154
+ [ -z "$STRACKS" ] && DEP=1
155
+ for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
156
+ case "$P" in *@*) ;; *) continue ;; esac
157
+ PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
158
+ [ -z "$PKG" ] && continue
159
+ for M in $MANIFESTS; do
160
+ if [ -n "$PY" ]; then
161
+ grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
162
+ else
163
+ grep -qs "\"$PKG\"" "$M" && DEP=1
164
+ fi
165
+ done
166
+ done
167
+ [ -z "$DEP" ] && continue
168
+
169
+ SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
170
+ [ -f "/tmp/claude-skill-loaded-$KEY-$SAFE" ] && continue
171
+ case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
172
+ done
173
+ fi
174
+
175
+ if [ -n "$MISSING" ]; then
176
+ LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
177
+ FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
178
+ cat <<EOF
179
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: this file's stack has mandatory skills that are not loaded: $LIST.\n\nThere is no SKIP for these. The file matches each skill's declared paths AND this project depends on a package that skill tracks, so they apply as a matter of fact, not judgement. Touching the gate marker will not clear them.\n\nCall Skill($FIRST) now — then every other name in the list — and retry the edit."}}
180
+ EOF
181
+ exit 0
182
+ fi
183
+
184
+ if [ -f "$MARKER" ]; then
185
+ exit 0
186
+ fi
187
+
188
+
189
+ cat <<EOF
190
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: skill evaluation required before file edits in this session.\n\nStep 1 — evaluate every available skill as ACTIVATE or SKIP with a one-line reason.\n\nStep 2 — you MUST take EXACTLY ONE of these tool actions to clear the gate. Listing skills in text is NOT enough; retrying the edit without doing one of these will be denied again:\n (a) If any skill is ACTIVATE → call Skill(name) for it. This auto-clears the gate.\n (b) If ALL skills are SKIP → run this Bash tool call: touch /tmp/claude-skill-gate-$KEY\n\nStep 3 — only after Step 2 completes, retry the file edit."}}
191
+ EOF
192
+ exit 0
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ # version-check.sh — SessionStart. Says once a day when a newer catalog exists.
3
+ #
4
+ # SessionStart stdout is added to the model's context, so this does not draw a
5
+ # banner: it tells the assistant, and the assistant mentions it. That is the
6
+ # whole reach of this hook — it never installs anything and never asks a
7
+ # question the user has to answer before working.
8
+ #
9
+ # The rule that keeps it invisible: NEVER touch the network in the foreground.
10
+ # A session start that waits on npm is a session start that hangs on a bad
11
+ # connection, and a version nudge is not worth one second of that. So it reads
12
+ # a cache written by the PREVIOUS session's background refresh, and kicks off
13
+ # the next refresh detached. The first session after an install therefore says
14
+ # nothing, and after that the answer is at worst a day old — which is the right
15
+ # resolution for "there is a new version" anyway.
16
+ #
17
+ # Silent when: no manifest, no cache yet, offline, already current, or the user
18
+ # has already been told today.
19
+ #
20
+ # CLAUDE_SKILLS_NO_VERSION_CHECK=1 turns it off entirely
21
+
22
+ set -uo pipefail
23
+
24
+ [ -n "${CLAUDE_SKILLS_NO_VERSION_CHECK:-}" ] && exit 0
25
+
26
+ PKG="@spardutti/claude-skills"
27
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
28
+ MANIFEST="$PROJECT_DIR/.claude/.claude-skills.json"
29
+
30
+ # No manifest means the CLI never installed here, so there is nothing to update.
31
+ [ -f "$MANIFEST" ] || exit 0
32
+
33
+ INSTALLED=$(sed -n 's/.*"catalogVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
34
+ [ -n "$INSTALLED" ] || exit 0
35
+
36
+ # One cache for the whole machine, not one per project: the answer is the same
37
+ # everywhere, and a developer with ten repos should still cost one request.
38
+ CACHE_DIR="${HOME:-/tmp}/.claude"
39
+ CACHE="$CACHE_DIR/.claude-skills-version"
40
+ STAMP="$CACHE_DIR/.claude-skills-version-told"
41
+ mkdir -p "$CACHE_DIR" 2>/dev/null
42
+
43
+ NOW=$(date +%s 2>/dev/null || echo 0)
44
+ DAY=86400
45
+
46
+ # Refresh in the background, fully detached. Nothing downstream waits on it: all
47
+ # three streams are closed, or the session start blocks until curl gives up.
48
+ refresh() {
49
+ AGE=$DAY
50
+ if [ -f "$CACHE" ]; then
51
+ MTIME=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
52
+ AGE=$((NOW - MTIME))
53
+ fi
54
+ [ "$AGE" -lt "$DAY" ] && return
55
+ command -v curl >/dev/null 2>&1 || return
56
+ ( curl -fsS --max-time 5 "https://registry.npmjs.org/$PKG/latest" \
57
+ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
58
+ | head -1 > "$CACHE.tmp" 2>/dev/null
59
+ # Only replace the cache with something that parsed. A truncated reply that
60
+ # overwrote it would nudge toward a version that does not exist.
61
+ if [ -s "$CACHE.tmp" ]; then mv "$CACHE.tmp" "$CACHE"; else rm -f "$CACHE.tmp"; fi
62
+ ) </dev/null >/dev/null 2>&1 &
63
+ }
64
+ refresh
65
+
66
+ [ -f "$CACHE" ] || exit 0
67
+ LATEST=$(head -1 "$CACHE" | tr -d '[:space:]')
68
+ [ -n "$LATEST" ] || exit 0
69
+ [ "$LATEST" = "$INSTALLED" ] && exit 0
70
+
71
+ # sort -V puts the newer one last. Equal versions were already handled, so the
72
+ # only case left to reject is a cache that is BEHIND the install — which happens
73
+ # normally right after publishing from the machine that publishes.
74
+ NEWER=$(printf '%s\n%s\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
75
+ [ "$NEWER" = "$LATEST" ] || exit 0
76
+
77
+ # Once a day, however many sessions that day holds.
78
+ if [ -f "$STAMP" ]; then
79
+ TOLD=$(date -r "$STAMP" +%s 2>/dev/null || echo 0)
80
+ [ $((NOW - TOLD)) -lt "$DAY" ] && exit 0
81
+ fi
82
+ : > "$STAMP"
83
+
84
+ echo "A newer version of the $PKG catalog is available: $INSTALLED installed, $LATEST published."
85
+ echo "Mention this to the user ONCE, in a single line, and only when the current task is done."
86
+ echo "Do not interrupt what they are doing, and do not run the installer unless they ask."
87
+ echo "To update, they run: npx $PKG"
88
+ exit 0