fix(design-gate): resolve claude on a sanitized PATH (real cause of unknown)

The "unknown -> exit 11" path triggers when `command -v claude` fails. Root
cause is NOT the interactive alias (claude->dtach_claude) not surviving the
subshell — that's true but harmless: the real binary is on the inherited PATH,
so `command -v` finds it in a normal `bash script.sh` (proven: toggle-external
and the gate both resolve claude). The actual lever is PATH carrying the nvm
node bin. A skill/hook that shells the gate out with a sanitized PATH, or a node
upgrade moving the version-pinned nvm path, loses it.

ensure_claude_on_path(): if `command -v claude` already resolves, do nothing;
else probe known install dirs (~/.claude/local, ~/.local/bin, /usr/local/bin)
and the nvm glob, prepending the bin dir — which carries BOTH claude and its
node runtime (claude's shebang needs node, same dir). nvm keeps old versions
after an upgrade, so pick the newest that ships claude via sort -V, not the
first glob match. If nothing resolves, command -v still fails -> unknown ->
exit 11 (fail-visible net stays).

Verified: shellcheck clean; normal PATH -> READY exit 0 (function returns early,
no regression); PATH=/usr/bin:/bin (sanitized hook) -> now resolves claude via
the nvm glob and reports REAL magic state (READY exit 0), where before the fix
it was exit 11 unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bastien Chanot 2026-06-21 12:12:49 +02:00
parent 60031c1bef
commit f96331844c

View File

@ -51,6 +51,33 @@ PROFILE_FILE="$PROFILES_DIR/$PROFILE.profile"
[ -x "$PROFILE_SH" ] || { echo "design-gate: profile.sh not executable at $PROFILE_SH" >&2; exit 2; } [ -x "$PROFILE_SH" ] || { echo "design-gate: profile.sh not executable at $PROFILE_SH" >&2; exit 2; }
[ -f "$PROFILE_FILE" ] || { echo "design-gate: profile '$PROFILE' not found" >&2; exit 2; } [ -f "$PROFILE_FILE" ] || { echo "design-gate: profile '$PROFILE' not found" >&2; exit 2; }
# Ensure the claude CLI + its node runtime are reachable even when a skill/hook
# shells this script out with a sanitized PATH. The interactive alias
# claude->dtach_claude never reaches a non-interactive subshell; the real binary
# AND its node bin dir are what matter (claude's shebang needs node, same dir).
# If `command -v claude` already resolves, do nothing; else probe known install
# dirs and prepend. nvm keeps old node versions after an upgrade, so pick the
# newest that actually ships claude (sort -V), not the first glob match.
ensure_claude_on_path() {
command -v claude >/dev/null 2>&1 && return
local cand
for cand in \
"$HOME/.claude/local/claude" \
"$HOME/.local/bin/claude" \
/usr/local/bin/claude; do
[ -x "$cand" ] && { PATH="$(dirname "$cand"):$PATH"; return; }
done
local m newest matches=()
for m in "$HOME"/.nvm/versions/node/*/bin/claude; do
[ -x "$m" ] && matches+=("$m")
done
if [ "${#matches[@]}" -gt 0 ]; then
newest="$(printf '%s\n' "${matches[@]}" | sort -V | tail -1)"
PATH="$(dirname "$newest"):$PATH"
fi
}
ensure_claude_on_path
# Gate scope: the "# GATE-BLOCK:" allowlist (one or more lines, concatenated). # Gate scope: the "# GATE-BLOCK:" allowlist (one or more lines, concatenated).
# Empty => fall back to "every gate-relevant entry is in scope" (coarse). # Empty => fall back to "every gate-relevant entry is in scope" (coarse).
core_set="$(grep '^# GATE-BLOCK:' "$PROFILE_FILE" 2>/dev/null \ core_set="$(grep '^# GATE-BLOCK:' "$PROFILE_FILE" 2>/dev/null \