fix: add CLAUDE_PLUGIN_ROOT fallback for Stop hooks (#1215)

Upstream Claude Code bug (anthropics/claude-code#24529) leaves
CLAUDE_PLUGIN_ROOT unset for Stop hooks on macOS and ALL hooks
on Linux. Two-layer defense:

1. Shell-level: hooks.json commands now use inline fallback
   _R="${CLAUDE_PLUGIN_ROOT}"; [ -z "$_R" ] && _R="$HOME/...";
   falling back to the known marketplace install path.

2. Script-level: bun-runner.js self-resolves plugin root from
   its own filesystem location via import.meta.url, and fixes
   broken /scripts/... paths that result from empty expansion.

Added test to verify all hook commands include the fallback path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-24 17:36:51 -05:00
parent 9c187d6261
commit 04e320b190
3 changed files with 53 additions and 9 deletions
+29 -1
View File
@@ -13,11 +13,36 @@
*/
import { spawnSync, spawn } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const IS_WINDOWS = process.platform === 'win32';
// Self-resolve plugin root when CLAUDE_PLUGIN_ROOT is not set by Claude Code.
// Upstream bug: anthropics/claude-code#24529 — Stop hooks (and on Linux, all hooks)
// don't receive CLAUDE_PLUGIN_ROOT, causing script paths to resolve to /scripts/...
// which doesn't exist. This fallback derives the plugin root from bun-runner.js's
// own filesystem location (this file lives in <plugin-root>/scripts/).
const __bun_runner_dirname = dirname(fileURLToPath(import.meta.url));
const RESOLVED_PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || resolve(__bun_runner_dirname, '..');
/**
* Fix script path arguments that were broken by empty CLAUDE_PLUGIN_ROOT.
* When CLAUDE_PLUGIN_ROOT is empty, "${CLAUDE_PLUGIN_ROOT}/scripts/foo.cjs"
* expands to "/scripts/foo.cjs" which doesn't exist. Detect this and rewrite
* the path using our self-resolved plugin root.
*/
function fixBrokenScriptPath(argPath) {
if (argPath.startsWith('/scripts/') && !existsSync(argPath)) {
const fixedPath = join(RESOLVED_PLUGIN_ROOT, argPath);
if (existsSync(fixedPath)) {
return fixedPath;
}
}
return argPath;
}
/**
* Find Bun executable - checks PATH first, then common install locations
*/
@@ -80,6 +105,9 @@ if (args.length === 0) {
process.exit(1);
}
// Fix broken script paths caused by empty CLAUDE_PLUGIN_ROOT (#1215)
args[0] = fixBrokenScriptPath(args[0]);
const bunPath = findBun();
if (!bunPath) {