@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.
Files changed (2) hide show
  1. package/lib/setup-hook.mjs +94 -4
  2. package/package.json +1 -1
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.25.3",
3
+ "version": "2.27.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {