@spardutti/claude-skills 2.25.2 → 2.26.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/lib/setup-hook.mjs +95 -4
- package/package.json +1 -1
package/lib/setup-hook.mjs
CHANGED
|
@@ -30,10 +30,10 @@ if [ -z "$SESSION_ID" ]; then
|
|
|
30
30
|
exit 0
|
|
31
31
|
fi
|
|
32
32
|
|
|
33
|
+
# The session marker is checked at the very bottom now, not here. A skill the
|
|
34
|
+
# edited file's own stack demands is not something an already-cleared gate
|
|
35
|
+
# excuses, so the required-skill scan below runs first.
|
|
33
36
|
MARKER="/tmp/claude-skill-gate-$SESSION_ID"
|
|
34
|
-
if [ -f "$MARKER" ]; then
|
|
35
|
-
exit 0
|
|
36
|
-
fi
|
|
37
37
|
|
|
38
38
|
# Write|Edit|MultiEdit is not the only way to change a file. A session that edits
|
|
39
39
|
# through \`python3 - <<'PY'\` in Bash walked past this gate entirely — twelve
|
|
@@ -49,6 +49,9 @@ CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql
|
|
|
49
49
|
|
|
50
50
|
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
51
51
|
|
|
52
|
+
# Every file this call would write, collected for the required-skill scan below.
|
|
53
|
+
TARGETS=""
|
|
54
|
+
|
|
52
55
|
if [ "$TOOL" != "Bash" ]; then
|
|
53
56
|
# The structured tools name their target outright.
|
|
54
57
|
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
@@ -58,6 +61,7 @@ if [ "$TOOL" != "Bash" ]; then
|
|
|
58
61
|
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
59
62
|
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
60
63
|
esac
|
|
64
|
+
TARGETS="$TARGET"
|
|
61
65
|
fi
|
|
62
66
|
if [ "$TOOL" = "Bash" ]; then
|
|
63
67
|
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
@@ -86,6 +90,84 @@ if [ "$TOOL" = "Bash" ]; then
|
|
|
86
90
|
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
87
91
|
esac
|
|
88
92
|
[ -z "$WRITES" ] && exit 0
|
|
93
|
+
# A Bash write names its files in the command itself.
|
|
94
|
+
TARGETS=$(printf '%s' "$CMD" | grep -oiE "[A-Za-z0-9_./-]+\.($CODE_EXT)" 2>/dev/null)
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# --- mandatory skills -------------------------------------------------------
|
|
98
|
+
# The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
|
|
99
|
+
# and hands it an all-SKIP escape. A session took that escape, touched the
|
|
100
|
+
# marker, and edited React files with none of the React rules loaded. It had
|
|
101
|
+
# followed the hook exactly: the hook let it decide.
|
|
102
|
+
#
|
|
103
|
+
# So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
|
|
104
|
+
# edited file matches its paths AND this project depends on a package it
|
|
105
|
+
# tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
|
|
106
|
+
# without both fields keep the old behaviour, so this tightens nothing that was
|
|
107
|
+
# not deliberately declared.
|
|
108
|
+
MISSING=""
|
|
109
|
+
if [ -n "$TARGETS" ]; then
|
|
110
|
+
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)
|
|
111
|
+
fi
|
|
112
|
+
if [ -n "$TARGETS" ] && [ -n "$MANIFESTS" ]; then
|
|
113
|
+
for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
|
|
114
|
+
# paths: must be quoted in YAML — a scalar opening with * is an alias.
|
|
115
|
+
UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
|
|
116
|
+
SPATHS=$(sed -n 's/^paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
117
|
+
STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
118
|
+
SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
119
|
+
[ -z "$SPATHS" ] && continue
|
|
120
|
+
[ -z "$STRACKS" ] && continue
|
|
121
|
+
[ -z "$SNAME" ] && continue
|
|
122
|
+
|
|
123
|
+
HIT=""
|
|
124
|
+
for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
|
|
125
|
+
# Claude Code writes these gitignore-style. A case glob has no **, but *
|
|
126
|
+
# already crosses / here, so dropping the **/ prefix is equivalent.
|
|
127
|
+
G=$(printf '%s' "$G" | sed 's!\\*\\*/!!g')
|
|
128
|
+
case "$G" in \\**|/*) ;; *) G="*$G" ;; esac
|
|
129
|
+
for T in $TARGETS; do
|
|
130
|
+
case "$T" in $G) HIT=1 ;; esac
|
|
131
|
+
done
|
|
132
|
+
done
|
|
133
|
+
[ -z "$HIT" ] && continue
|
|
134
|
+
|
|
135
|
+
# A .tsx file is not proof of React — it could be Astro, Solid or Preact.
|
|
136
|
+
# The packages the skill tracks are the proof, so the manifests decide.
|
|
137
|
+
PY=""
|
|
138
|
+
case "$STRACKS" in *pypi*) PY=1 ;; esac
|
|
139
|
+
DEP=""
|
|
140
|
+
for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
|
|
141
|
+
case "$P" in *@*) ;; *) continue ;; esac
|
|
142
|
+
PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
|
|
143
|
+
[ -z "$PKG" ] && continue
|
|
144
|
+
for M in $MANIFESTS; do
|
|
145
|
+
if [ -n "$PY" ]; then
|
|
146
|
+
grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
|
|
147
|
+
else
|
|
148
|
+
grep -qs "\\"$PKG\\"" "$M" && DEP=1
|
|
149
|
+
fi
|
|
150
|
+
done
|
|
151
|
+
done
|
|
152
|
+
[ -z "$DEP" ] && continue
|
|
153
|
+
|
|
154
|
+
SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
|
|
155
|
+
[ -f "/tmp/claude-skill-loaded-$SESSION_ID-$SAFE" ] && continue
|
|
156
|
+
case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
|
|
157
|
+
done
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
if [ -n "$MISSING" ]; then
|
|
161
|
+
LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
|
|
162
|
+
FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
|
|
163
|
+
cat <<EOF
|
|
164
|
+
{"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."}}
|
|
165
|
+
EOF
|
|
166
|
+
exit 0
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
if [ -f "$MARKER" ]; then
|
|
170
|
+
exit 0
|
|
89
171
|
fi
|
|
90
172
|
|
|
91
173
|
|
|
@@ -685,6 +767,15 @@ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
|
|
|
685
767
|
git diff HEAD --name-only 2>/dev/null
|
|
686
768
|
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
687
769
|
|
|
770
|
+
# Only the files this gate can actually read go into the key. It used to be every
|
|
771
|
+
# changed file, so a version bump in package.json or a line in a changelog threw
|
|
772
|
+
# away a receipt the gate would have re-earned unchanged — fifteen minutes of
|
|
773
|
+
# mutation testing to prove nothing about the file that moved, at the one moment
|
|
774
|
+
# a release is trying to ship. An ignored extension is one no check here opens,
|
|
775
|
+
# so it cannot change a verdict. Unknown extensions stay in, because UNPROVEN is
|
|
776
|
+
# a verdict about exactly those.
|
|
777
|
+
KEYED=$(printf '%s\\n' "$CHANGED" | grep -viE "\\.($GAUNTLET_IGNORE_EXT)$")
|
|
778
|
+
|
|
688
779
|
# The repo path plus the names and contents of the changed files, in a fixed
|
|
689
780
|
# order. The path is in there because the key is otherwise pure content: two
|
|
690
781
|
# repos holding the same file would share a receipt, and one would be waved
|
|
@@ -693,7 +784,7 @@ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
|
|
|
693
784
|
# the moment it was committed. This is identical either side of a commit, and
|
|
694
785
|
# changes the instant any of those files is edited.
|
|
695
786
|
RECEIPT_KEY=$( { printf '%s\\n' "$PROJECT_DIR"
|
|
696
|
-
printf '%s\\n' "$
|
|
787
|
+
printf '%s\\n' "$KEYED" | while IFS= read -r f; do
|
|
697
788
|
[ -n "$f" ] || continue
|
|
698
789
|
printf '%s\\n' "$f"
|
|
699
790
|
[ -f "$f" ] && cat "$f"
|