Skip to content

Commit 461a8b2

Browse files
committed
fix(install,uninstall): correct Claude Code hooks schema for SessionStart entry
Regression caught when opening a fresh Claude Code session post- install.sh: settings.json failed validation with hooks > SessionStart > 1 > hooks: Expected array, but received undefined Root cause: my jq merge appended a flat `{"command": "..."}` entry, but the Claude Code hook schema is `{matcher?, hooks: [{type, command, async?}, ...]}` — each SessionStart entry must carry a `hooks` array. Fixes: - install.sh — merges a correctly-shaped entry: {"hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/prewarm-direct-wrappers.py"}]} Idempotency re-checked via flat-scanning all existing entries' `hooks[].command` for our exact string; skips append if found. - uninstall.sh — filters our command out of each entry's `hooks` array, then drops entries that become empty. Unrelated SessionStart entries (other tools' hooks bundled into separate entries) preserved. - install.sh also gained `CLAUDE="${CLAUDE:-$HOME/.claude}"` env override to match uninstall.sh (enables dry-run testing against a scratch settings file) and switched `mktemp` to `mktemp "${TMPDIR:-/tmp}/install.XXXXXX"` so the atomic-swap works under sandboxes that override $TMPDIR. Verified dry-run against scratch settings.json with an existing SessionStart entry: - install.sh run 1: appends our entry → 2 entries total. - install.sh run 2 (idempotency): still 2 entries. - uninstall.sh: strips our entry → back to 1 entry (the pre- existing one untouched). Users who already ran the broken install.sh can either run uninstall.sh then install.sh again, or manually replace the flat entry in ~/.claude/settings.json's hooks.SessionStart[] with the correctly-shaped object.
1 parent 9878ca5 commit 461a8b2

2 files changed

Lines changed: 25 additions & 6 deletions

File tree

scripts/install.sh

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
set -euo pipefail
77

88
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9-
CLAUDE="$HOME/.claude"
9+
# CLAUDE defaults to ~/.claude; override for dry-runs (CLAUDE=/tmp/foo install.sh).
10+
CLAUDE="${CLAUDE:-$HOME/.claude}"
1011

1112
# bin files (wrappers + coordinators + shared harness modules)
1213
BIN_FILES=(
@@ -121,7 +122,7 @@ SETTINGS="$CLAUDE/settings.json"
121122
if [ -f "$SETTINGS" ]; then
122123
log "merging permissions + sandbox into $SETTINGS"
123124
cp "$SETTINGS" "$SETTINGS.bak-$(date +%s)"
124-
TMP="$(mktemp)"
125+
TMP="$(mktemp "${TMPDIR:-/tmp}/install.XXXXXX")"
125126
jq '
126127
.permissions.allow = ((.permissions.allow // []) + [
127128
"Bash(~/.claude/bin/metals-direct *)",
@@ -155,9 +156,17 @@ if [ -f "$SETTINGS" ]; then
155156
"~/.coursier/**",
156157
"/private/var/folders/**/.scala-build/**"
157158
] | unique)
158-
| .hooks.SessionStart = ((.hooks.SessionStart // []) + [{
159-
"command": "python3 ~/.claude/hooks/prewarm-direct-wrappers.py"
160-
}] | unique_by(.command))
159+
| .hooks.SessionStart = (
160+
# append only if our command isnt already registered under any existing entrys hooks array.
161+
# Claude Code hook schema: each SessionStart entry is {matcher?, hooks: [{type, command, async?}, ...]}
162+
if ((.hooks.SessionStart // []) | map(.hooks // []) | flatten | map(.command) | index("python3 ~/.claude/hooks/prewarm-direct-wrappers.py")) then
163+
(.hooks.SessionStart // [])
164+
else
165+
(.hooks.SessionStart // []) + [{
166+
"hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/prewarm-direct-wrappers.py"}]
167+
}]
168+
end
169+
)
161170
' "$SETTINGS" > "$TMP" && mv "$TMP" "$SETTINGS"
162171
log " merged (backup at $SETTINGS.bak-<ts>)"
163172
else

scripts/uninstall.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,17 @@ if [ -f "$SETTINGS" ] && command -v jq >/dev/null; then
8686
"~/.coursier/**",
8787
"/private/var/folders/**/.scala-build/**"
8888
])
89-
| .hooks.SessionStart = ((.hooks.SessionStart // []) | map(select(.command != "python3 ~/.claude/hooks/prewarm-direct-wrappers.py")))
89+
| .hooks.SessionStart = (
90+
# Remove any SessionStart entry whose hooks[].command references our prewarm script.
91+
# Also drop entries that become empty after the filter.
92+
(.hooks.SessionStart // [])
93+
| map(
94+
if type == "object" and has("hooks") then
95+
.hooks |= map(select(.command != "python3 ~/.claude/hooks/prewarm-direct-wrappers.py"))
96+
else . end
97+
)
98+
| map(select(type != "object" or (.hooks // []) != []))
99+
)
90100
' "$SETTINGS" > "$TMP" && mv "$TMP" "$SETTINGS"
91101
fi
92102

0 commit comments

Comments
 (0)