From f0eeb877d4a2ec230bc956e5118f920ffc42ab26 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 21 Oct 2026 10:14:31 -0300 Subject: [PATCH] fix: quote the plugin root in Claude Code and Crush hook commands (#90) --- CHANGELOG.md | 9 +++++++++ hooks/crush-hooks.json | 4 ++-- hooks/hooks.json | 22 +++++++++++----------- tests/hooks.test.mjs | 19 +++++++++++++------ 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ece1d..ca2b940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Claude Code hooks run when the plugin's path has a space in it.** On macOS + lich keeps the plugin under `~/Library/Application Support`, and the + unquoted `${CLAUDE_PLUGIN_ROOT}` split there, so every hook failed to start + and the card got no state, title or touched files. The Claude Code + registration quotes the path now, as the Codex one already did, and so does + the `` placeholder in the Crush block (#90). + ## [0.20.6] - 2026-10-17 ### Changed diff --git a/hooks/crush-hooks.json b/hooks/crush-hooks.json index 82a409d..66560d9 100644 --- a/hooks/crush-hooks.json +++ b/hooks/crush-hooks.json @@ -3,13 +3,13 @@ "PreToolUse": [ { "name": "lich session id", - "command": "/hooks/report-session-start.sh crush", + "command": "\"/hooks/report-session-start.sh\" crush", "timeout": 5 }, { "name": "lich git-status refresh", "matcher": "^(edit|write|multiedit|bash)$", - "command": "/hooks/report-touched.sh", + "command": "\"/hooks/report-touched.sh\"", "timeout": 5 } ] diff --git a/hooks/hooks.json b/hooks/hooks.json index ceb0258..6df6b89 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-session-start.sh claude" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-session-start.sh\" claude" } ] } @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh busy" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy" } ] } @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh\"" } ] } @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh busy" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy" } ] }, @@ -44,7 +44,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh once" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\" once" } ] }, @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh\"" } ] } @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh waiting" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" waiting" } ] } @@ -73,11 +73,11 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh done" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" done" }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\"" } ] } @@ -87,7 +87,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh done" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" done" } ] } @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh idle" + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" idle" } ] } diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 9d90bf7..dafee3c 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -61,7 +61,7 @@ function runHook(command, { env = {}, stdin = '', pluginRoot = true, tmpdir = nu env: { ...BASE_ENV, TMPDIR: tmpdir ?? mkdtempSync(path.join(TMP, 'run-')), - ...(pluginRoot ? { CLAUDE_PLUGIN_ROOT: ROOT } : {}), + ...(pluginRoot ? { CLAUDE_PLUGIN_ROOT: SPACED_ROOT } : {}), ...env, }, stdio: ['pipe', 'pipe', 'pipe'], @@ -98,6 +98,14 @@ function assertHookSucceeded(result, stdout = '') { // ------------------------------------------------------------ registrations -- +const TMP = mkdtempSync(path.join(tmpdir(), 'lich-plugin-test-')) + +// The plugin root a harness hands the hooks, with a space in it: on macOS it +// sits under ~/Library/Application Support, and an unquoted expansion splits +// there and every hook runs a path that does not exist (issue #90). +const SPACED_ROOT = path.join(TMP, 'Application Support') +symlinkSync(ROOT, SPACED_ROOT) + const HOOK_FILES = [ { provider: 'claude', file: 'hooks/hooks.json' }, { provider: 'codex', file: 'hooks/codex-hooks.json' }, @@ -109,7 +117,8 @@ const HOOK_FILES = [ ] // Crush has no plugin root to expand, so its registration ships the clone's -// path as a placeholder the user replaces. Here it becomes this checkout. +// path as a placeholder the user replaces. Here it becomes this checkout, under +// the same spaced path the other harnesses get. const PLUGIN_ROOT_PLACEHOLDER = '' const SCRIPTS = { @@ -172,8 +181,8 @@ function registrations() { for (const hook of hooksIn(group)) { const script = Object.keys(SCRIPTS).find((s) => hook.command.includes(s)) assert.ok(script, `unknown script in ${file}: ${hook.command}`) - const command = hook.command.split(PLUGIN_ROOT_PLACEHOLDER).join(ROOT) - const { report, stdout } = splitVerdict(command) + const command = hook.command.split(PLUGIN_ROOT_PLACEHOLDER).join(SPACED_ROOT) + const { report, stdout } = splitVerdict(hook.command) out.push({ provider, file, @@ -197,8 +206,6 @@ const REGISTRATIONS = registrations() // ------------------------------------------------------------- transcripts -- -const TMP = mkdtempSync(path.join(tmpdir(), 'lich-plugin-test-')) - /** Claude Code keeps its generated title in the transcript as an ai-title line. */ function claudeTranscript(title, name = 'claude.jsonl') { const file = path.join(TMP, name)