Skip to content

Hook commands reference ${CLAUDE_SKILL_DIR} but Claude Code only exposes ${CLAUDE_PLUGIN_ROOT} — all skill-registered hooks silently broken #961

@ivansamartino

Description

@ivansamartino

Summary

Several gstack skills register PreToolUse hooks in their SKILL.md frontmatter whose commands reference ${CLAUDE_SKILL_DIR}. Claude Code does not define this environment variable for skill-registered hooks — the only variable it substitutes and exports is ${CLAUDE_PLUGIN_ROOT}. As a result, bash expands ${CLAUDE_SKILL_DIR} to the empty string at hook-execution time, producing paths rooted at / and failing every Write/Edit/Bash operation with a noisy but non-blocking error the whole time the skill is loaded.

Because the hooks are non-blocking, tool calls still succeed, so this presents as purely cosmetic noise — but any user running /investigate (or /guard) sees the error repeat on every single Write/Edit in the session. More importantly, this means the debug-scope and freeze-boundary checks these hooks are supposed to enforce have never actually run for any affected user — they fail before the real check script is reached.

Reproduction

  1. Install latest gstack (I reproduced on 0.16.2.0, commit dbd7aee).
  2. Invoke a skill that registers a hook referencing \${CLAUDE_SKILL_DIR} — e.g. /investigate.
  3. Have Claude Code make any Write or Edit tool call.
  4. Observe the error in the transcript:
PreToolUse:Write hook error
Failed with non-blocking status code: bash: /../freeze/bin/check-freeze.sh: No such file or directory

Every subsequent Write/Edit in the session repeats the error.

Root cause

Claude Code's skill-hook runtime (verified against the 2.1.101 native binary) only substitutes and exports ${CLAUDE_PLUGIN_ROOT} for hooks registered via a skill's frontmatter. The relevant logic validates the hook command and throws if unknown plugin-scoped variables are referenced, but does not validate or substitute ${CLAUDE_SKILL_DIR} — it passes the unexpanded string straight to bash, and since the variable is not in the inherited environment either, bash expands it to empty.

Verbatim error string from the binary confirming the whitelist:

Hook command references ${X} but only ${CLAUDE_PLUGIN_ROOT} is available for skill hooks (${CLAUDE_PLUGIN_DATA} is plugin-only).

So when investigate/SKILL.md declares:

hooks:
  PreToolUse:
    - matcher: "Edit"
      hooks:
        - type: command
          command: "bash \${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"

bash receives the literal string bash /../freeze/bin/check-freeze.sh and fails.

Affected files on main

All of these contain \${CLAUDE_SKILL_DIR} references in hook commands or in preamble scripts that run as skill instructions:

  • freeze/SKILL.md — 2× hook commands (\${CLAUDE_SKILL_DIR}/bin/check-freeze.sh)
  • careful/SKILL.md — 1× hook command (\${CLAUDE_SKILL_DIR}/bin/check-careful.sh)
  • guard/SKILL.md — 3× hook commands (sibling references into ../freeze/bin/ and ../careful/bin/)
  • investigate/SKILL.md — 2× hook commands + 1× availability probe (all sibling references into ../freeze/bin/)
  • ship/SKILL.md — 2× cat \${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md / ../document-release/SKILL.md in skill body

All five break the same way.

Suggested fix

Replace \${CLAUDE_SKILL_DIR} with \${CLAUDE_PLUGIN_ROOT} throughout SKILL.md (and .tmpl) files. CLAUDE_PLUGIN_ROOT is the officially-supported, Claude-Code-substituted variable pointing at the skill's own directory.

Caveat for sibling references: the ../freeze/bin/... style relies on each skill being installed as a sibling directory under the same parent. That's the case for gstack's current layout (~/.agents/skills/gstack/{freeze,careful,investigate,guard}/), but it's fragile if Claude Code ever reports the skill root as a symlink location rather than the resolved target. A more robust alternative would be to ship the hook scripts inside each skill that needs them (so investigate/bin/check-freeze.sh is a copy or a repo-relative symlink of freeze/bin/check-freeze.sh), eliminating the sibling assumption. Either approach fixes the immediate bug.

Environment

  • gstack: 0.16.2.0 (installed via npx skills add -g garrytan/gstack, latest on main as of today)
  • Claude Code: 2.1.101 (native binary, ~/.local/share/claude/versions/2.1.101)
  • OS: macOS (arm64)

Happy to submit a PR if that'd help — the fix is mechanical.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions