Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<lich-plugin>` placeholder in the Crush block (#90).

## [0.20.6] - 2026-10-17

### Changed
Expand Down
4 changes: 2 additions & 2 deletions hooks/crush-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"PreToolUse": [
{
"name": "lich session id",
"command": "<lich-plugin>/hooks/report-session-start.sh crush",
"command": "\"<lich-plugin>/hooks/report-session-start.sh\" crush",
"timeout": 5
},
{
"name": "lich git-status refresh",
"matcher": "^(edit|write|multiedit|bash)$",
"command": "<lich-plugin>/hooks/report-touched.sh",
"command": "\"<lich-plugin>/hooks/report-touched.sh\"",
"timeout": 5
}
]
Expand Down
22 changes: 11 additions & 11 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand All @@ -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"
}
]
}
Expand All @@ -26,7 +26,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh"
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh\""
}
]
}
Expand All @@ -36,15 +36,15 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh busy"
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh once"
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\" once"
}
]
},
Expand All @@ -53,7 +53,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh"
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh\""
}
]
}
Expand All @@ -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"
}
]
}
Expand All @@ -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\""
}
]
}
Expand All @@ -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"
}
]
}
Expand All @@ -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"
}
]
}
Expand Down
19 changes: 13 additions & 6 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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' },
Expand All @@ -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 = '<lich-plugin>'

const SCRIPTS = {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading