@spardutti/claude-skills 2.25.3 → 2.27.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 +94 -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,15 +49,21 @@ 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/"$//')
|
|
55
58
|
case "$TARGET" in
|
|
56
59
|
# The settings file that configures the escape hatch cannot sit behind the
|
|
57
|
-
# gate, or a denied ack has no way to be un-denied.
|
|
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 ;;
|
|
58
63
|
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
59
64
|
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
60
65
|
esac
|
|
66
|
+
TARGETS="$TARGET"
|
|
61
67
|
fi
|
|
62
68
|
if [ "$TOOL" = "Bash" ]; then
|
|
63
69
|
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
@@ -86,6 +92,90 @@ if [ "$TOOL" = "Bash" ]; then
|
|
|
86
92
|
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
87
93
|
esac
|
|
88
94
|
[ -z "$WRITES" ] && exit 0
|
|
95
|
+
# A Bash write names its files in the command itself. Dockerfile and
|
|
96
|
+
# .dockerignore carry no extension, so they are matched by name.
|
|
97
|
+
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)
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# --- mandatory skills -------------------------------------------------------
|
|
101
|
+
# The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
|
|
102
|
+
# and hands it an all-SKIP escape. A session took that escape, touched the
|
|
103
|
+
# marker, and edited React files with none of the React rules loaded. It had
|
|
104
|
+
# followed the hook exactly: the hook let it decide.
|
|
105
|
+
#
|
|
106
|
+
# So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
|
|
107
|
+
# edited file matches its paths AND this project depends on a package it
|
|
108
|
+
# tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
|
|
109
|
+
# without both fields keep the old behaviour, so this tightens nothing that was
|
|
110
|
+
# not deliberately declared.
|
|
111
|
+
MISSING=""
|
|
112
|
+
MANIFESTS=""
|
|
113
|
+
if [ -n "$TARGETS" ]; then
|
|
114
|
+
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)
|
|
115
|
+
fi
|
|
116
|
+
if [ -n "$TARGETS" ]; then
|
|
117
|
+
for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
|
|
118
|
+
# paths: must be quoted in YAML — a scalar opening with * is an alias.
|
|
119
|
+
UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
|
|
120
|
+
SPATHS=$(sed -n 's/^paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
121
|
+
STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
122
|
+
SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
123
|
+
[ -z "$SPATHS" ] && continue
|
|
124
|
+
[ -z "$SNAME" ] && continue
|
|
125
|
+
|
|
126
|
+
HIT=""
|
|
127
|
+
for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
|
|
128
|
+
# Claude Code writes these gitignore-style. A case glob has no **, but *
|
|
129
|
+
# already crosses / here, so dropping the **/ prefix is equivalent.
|
|
130
|
+
G=$(printf '%s' "$G" | sed 's!\\*\\*/!!g')
|
|
131
|
+
case "$G" in \\**|/*) ;; *) G="*$G" ;; esac
|
|
132
|
+
for T in $TARGETS; do
|
|
133
|
+
case "$T" in $G) HIT=1 ;; esac
|
|
134
|
+
done
|
|
135
|
+
done
|
|
136
|
+
[ -z "$HIT" ] && continue
|
|
137
|
+
|
|
138
|
+
# A .tsx file is not proof of React — it could be Astro, Solid or Preact.
|
|
139
|
+
# The packages the skill tracks are the proof, so the manifests decide.
|
|
140
|
+
#
|
|
141
|
+
# Some skills need no such proof: a .sql file is SQL and a Dockerfile is a
|
|
142
|
+
# Dockerfile, and no package declares either. A skill that names paths and
|
|
143
|
+
# no tracks is taken at its word.
|
|
144
|
+
PY=""
|
|
145
|
+
case "$STRACKS" in *pypi*) PY=1 ;; esac
|
|
146
|
+
DEP=""
|
|
147
|
+
[ -z "$STRACKS" ] && DEP=1
|
|
148
|
+
for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
|
|
149
|
+
case "$P" in *@*) ;; *) continue ;; esac
|
|
150
|
+
PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
|
|
151
|
+
[ -z "$PKG" ] && continue
|
|
152
|
+
for M in $MANIFESTS; do
|
|
153
|
+
if [ -n "$PY" ]; then
|
|
154
|
+
grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
|
|
155
|
+
else
|
|
156
|
+
grep -qs "\\"$PKG\\"" "$M" && DEP=1
|
|
157
|
+
fi
|
|
158
|
+
done
|
|
159
|
+
done
|
|
160
|
+
[ -z "$DEP" ] && continue
|
|
161
|
+
|
|
162
|
+
SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
|
|
163
|
+
[ -f "/tmp/claude-skill-loaded-$SESSION_ID-$SAFE" ] && continue
|
|
164
|
+
case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
|
|
165
|
+
done
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
if [ -n "$MISSING" ]; then
|
|
169
|
+
LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
|
|
170
|
+
FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
|
|
171
|
+
cat <<EOF
|
|
172
|
+
{"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."}}
|
|
173
|
+
EOF
|
|
174
|
+
exit 0
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
if [ -f "$MARKER" ]; then
|
|
178
|
+
exit 0
|
|
89
179
|
fi
|
|
90
180
|
|
|
91
181
|
|