The three opt-in bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
gsd-validate-commit.sh) shipped with #!/bin/bash, which fails on distros
that don't ship bash at /bin/bash (NixOS, minimal Alpine images, some
container runtimes). POSIX guarantees /bin/sh but not /bin/bash.
This is latent in the default install path because Claude Code wires the
hooks as `bash <path>` from settings.json (PATH-resolved — the script's
own shebang is read as a comment by bash). The fix matters when scripts
are run directly: tests, future installer changes, or manual debugging.
Changes:
- hooks/gsd-{phase-boundary,session-state,validate-commit}.sh: shebang
switched to #!/usr/bin/env bash, matching the convention already used
in scripts/*.sh.
- tests/bug-2136-sh-hook-version.test.cjs: assertion updated to expect
the new shebang; comment updated to spell out the rationale.
- tests/bug-2979-hook-absolute-node.test.cjs: doc-comment updated — the
prior wording cited "POSIX std PATH always has /bin" as the reason
bare `bash` is OK. The actual reason is that bare `bash` is
PATH-resolved, which is portable across distros that don't ship
/bin/bash. POSIX std PATH guarantees /bin/sh, not /bin/bash.
- bin/install.js::buildHookCommand: comment block clarifying the same.
No behavior change in this file — bare `bash` was already correct.
- .changeset/portable-bash-shebang-hooks.md: changeset entry.
Verified locally on NixOS:
- npm run build:hooks: hooks/dist/*.sh shebangs propagate correctly.
- node --test tests/bug-2136-*.cjs tests/bug-2979-*.cjs
tests/bug-1817-*.cjs tests/bug-1834-*.cjs tests/bug-1906-*.cjs
tests/bug-2557-*.cjs tests/bug-3017-*.cjs tests/security-scan.test.cjs
tests/hooks-doc-parity.test.cjs: 126/126 pass.
- node scripts/run-tests.cjs (full suite): 6944 pass / 0 fail / 5 skip.
60 lines
2.4 KiB
Bash
Executable File
60 lines
2.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gsd-hook-version: {{GSD_VERSION}}
|
|
# gsd-session-state.sh — SessionStart hook: inject project state reminder
|
|
# Outputs STATE.md head on every session start for orientation.
|
|
#
|
|
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
|
|
# Enable with: "hooks": { "community": true } in .planning/config.json
|
|
|
|
# Check opt-in config — exit silently if not enabled
|
|
if [ -f .planning/config.json ]; then
|
|
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
|
|
if [ "$ENABLED" != "1" ]; then exit 0; fi
|
|
else
|
|
exit 0
|
|
fi
|
|
|
|
# Build the additionalContext text and emit it as a structured JSON
|
|
# envelope per the Claude Code SessionStart hook protocol (#2974). Tests
|
|
# parse the JSON and assert on typed fields (state_present: bool,
|
|
# config_mode: string, etc) rather than substring-matching free-form text.
|
|
STATE_PRESENT="false"
|
|
STATE_HEAD=""
|
|
if [ -f .planning/STATE.md ]; then
|
|
STATE_PRESENT="true"
|
|
STATE_HEAD=$(head -20 .planning/STATE.md)
|
|
fi
|
|
|
|
CONFIG_MODE="unknown"
|
|
if [ -f .planning/config.json ]; then
|
|
CONFIG_MODE=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(String(c.mode||'unknown'))}catch{process.stdout.write('unknown')}" 2>/dev/null)
|
|
fi
|
|
|
|
# Use Node for JSON encoding so embedded newlines/quotes are escaped correctly.
|
|
# additionalContext is the text Claude Code injects at session start; the
|
|
# typed fields (state_present, config_mode) let tests assert on the
|
|
# structured contract without grepping the prose.
|
|
node -e '
|
|
const [statePresent, stateHead, configMode] = process.argv.slice(1);
|
|
const headerLines = ["## Project State Reminder", ""];
|
|
if (statePresent === "true") {
|
|
headerLines.push("STATE.md exists - check for blockers and current phase.");
|
|
if (stateHead) headerLines.push(stateHead);
|
|
} else {
|
|
headerLines.push("No .planning/ found - suggest /gsd-new-project if starting new work.");
|
|
}
|
|
headerLines.push("");
|
|
headerLines.push("Config: \"mode\": \"" + configMode + "\"");
|
|
const additionalContext = headerLines.join("\n");
|
|
process.stdout.write(JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "SessionStart",
|
|
additionalContext,
|
|
state_present: statePresent === "true",
|
|
config_mode: configMode,
|
|
},
|
|
}));
|
|
' "$STATE_PRESENT" "$STATE_HEAD" "$CONFIG_MODE"
|
|
|
|
exit 0
|