Files
oh-my-claudecode/scripts/lib/hook-command-normalizer.mjs
fangwei219andgaebal-gajae 579465926b fix: use ${CLAUDE_PLUGIN_ROOT} brace form in hook commands (Windows startup error) (#4042)
* fix: use ${CLAUDE_PLUGIN_ROOT} brace form in hook commands

Hook commands referenced $CLAUDE_PLUGIN_ROOT (bare form), which Claude
Code does not substitute on Windows — the variable is left for the shell
to expand and comes back empty, so node tried to load C:\scripts\run.cjs
and SessionStart hooks failed with a cjs/loader:1424 MODULE_NOT_FOUND
error. The braced ${CLAUDE_PLUGIN_ROOT} form is substituted by Claude Code
before the command reaches the shell, matching the pattern used by other
plugins (agentmemory, superpowers). Applies to all 25 hook command lines.

* fix(hooks): complete the braced placeholder migration across the normalizer

The manifest change alone did not hold. scripts/lib/hook-command-normalizer.mjs
emitted the bare $CLAUDE_PLUGIN_ROOT form and treated the braced spelling as
legacy to rewrite away, and both scripts/plugin-setup.mjs and
scripts/repair-plugin-cache.mjs run that normalizer and write hooks.json back to
disk. Any install that ran plugin setup converted the corrected manifest
straight back to the bare form, so the Windows startup failure returned.
plugin-shipping-surface.mjs accepts either spelling, so no drift check caught it.

Why braced is correct rather than merely different: the bare form relies on a
POSIX shell expanding an environment variable. The Windows prefix invokes node
directly with no sh in front of it, so the plugin root never expands there and
node fails loading <drive>:\scripts\run.cjs. Under sh both spellings expand
identically, which makes the braced form a no-op on Unix/macOS and the only
spelling that can work on native Windows.

- normalizer: both prefixes emit braced; input patterns accept bare or braced so
  existing caches are repaired rather than skipped; all branches emit braced
- hooks.json: convert the remaining dev-only PostToolUse hook the original
  change predated (52 occurrences total, previously 50)
- plugin-setup-deps test: exercise the real normalizer instead of a private copy
  of its rules, since that duplicate is what let manifest and patcher drift
- repair-plugin-cache test: expect braced output while keeping bare fixtures as
  input, proving legacy installs are upgraded

Refs #4042

* chore(inventory): regenerate inventory graph for #4042

* fix(hooks): migrate the TypeScript Windows patcher to the braced placeholder

src/hooks/setup/index.ts carries a third copy of the hook-command rewriting
rules, independent of scripts/lib/hook-command-normalizer.mjs, and it also
emitted the bare form. Left alone it would rewrite hooks.json back to
$CLAUDE_PLUGIN_ROOT on Windows init and reintroduce the failure.

Its input patterns now accept either spelling so existing caches are repaired,
and output is always braced. Contract tests that asserted the bare spelling are
updated: windows-patch, hooks-command-escaping, preemptive-compaction-hook and
setup-contracts-regression.

Full local suite: the CLAUDE_PLUGIN_ROOT-related failures are gone and the
remaining failures match the dev baseline exactly (10 pre-existing files,
unrelated to this change).

Refs #4042

* chore(inventory): regenerate inventory graph for #4042 windows patcher

* fix(hooks): update workflow-drift-guard Stop registration to the braced placeholder

The Stop-hook drift guard pins the exact hooks.json registration, so it still
asserted the bare spelling and failed on CI after the migration.

Refs #4042

* chore(inventory): regenerate inventory graph for #4042 drift-guard fix

---------

Co-authored-by: gaebal-gajae <clawdbot@users.noreply.github.com>
2026-09-16 17:01:44 +09:00

71 lines
3.2 KiB
JavaScript

// Hook commands use the braced `${CLAUDE_PLUGIN_ROOT}` form.
//
// Claude Code substitutes the braced placeholder itself before handing the
// command to a shell. The bare `$CLAUDE_PLUGIN_ROOT` form instead relies on the
// shell expanding an environment variable, which only holds where a POSIX shell
// actually runs the command. The Windows prefix invokes `node` directly with no
// `sh` in front of it, so there the bare form is never expanded from the
// environment and the plugin root resolves to nothing — `node` then tries to
// load `<drive>:\scripts\run.cjs` and the CJS loader fails at session start.
//
// Under `sh` both forms expand identically, so the braced form is a no-op on
// Unix/macOS and the only form that can work on native Windows.
export const WINDOWS_HOOK_PREFIX = 'node "${CLAUDE_PLUGIN_ROOT}"/scripts/run.cjs ';
export const UNIX_HOOK_PREFIX = 'sh "${CLAUDE_PLUGIN_ROOT}"/scripts/find-node.sh "${CLAUDE_PLUGIN_ROOT}"/scripts/run.cjs ';
export function hookPrefixForPlatform(platform = process.platform) {
return platform === 'win32' ? WINDOWS_HOOK_PREFIX : UNIX_HOOK_PREFIX;
}
export function normalizeHookCommand(command, prefix = hookPrefixForPlatform()) {
// Both the bare and braced spellings are accepted on input so that manifests
// written by any earlier version are repaired rather than left alone; every
// branch emits the braced form.
const root = String.raw`(?:\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT)`;
const legacyFindNodePattern = new RegExp(
String.raw`^sh "${root}\/scripts\/find-node\.sh" "${root}\/scripts\/([^"\s]+)"?(.*)$`,
);
const currentFindNodePattern = new RegExp(
String.raw`^(?:"\/bin\/sh"|sh) "${root}"\/scripts\/find-node\.sh "${root}"\/scripts\/run\.cjs "${root}"\/scripts\/([^"\s]+)"?(.*)$`,
);
const directRunCjsPattern = new RegExp(
String.raw`^node\s+"${root}"\/scripts\/run\.cjs\s+"${root}"\/scripts\/([^"\s]+)"?(.*)$`,
);
const absoluteNodeRunCjsPattern = new RegExp(
String.raw`^"([^"]*\/node|[A-Za-z]:\\[^"]*\\node(?:\.exe)?)"\s+"${root}"\/scripts\/run\.cjs\s+"${root}"\/scripts\/([^"\s]+)"?(.*)$`,
);
const match = command.match(currentFindNodePattern)
?? command.match(legacyFindNodePattern)
?? command.match(directRunCjsPattern);
if (match) return `${prefix}"\${CLAUDE_PLUGIN_ROOT}"/scripts/${match[1]}${match[2]}`;
const absNodeMatch = command.match(absoluteNodeRunCjsPattern);
if (absNodeMatch) return `${prefix}"\${CLAUDE_PLUGIN_ROOT}"/scripts/${absNodeMatch[2]}${absNodeMatch[3]}`;
return command;
}
export function normalizeHooksDataForPlatform(data, platform = process.platform) {
const prefix = hookPrefixForPlatform(platform);
let patched = false;
for (const groups of Object.values(data?.hooks ?? {})) {
if (!Array.isArray(groups)) continue;
for (const group of groups) {
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
for (const hook of group.hooks) {
if (!hook || typeof hook !== 'object' || typeof hook.command !== 'string') continue;
const nextCommand = normalizeHookCommand(hook.command, prefix);
if (hook.command !== nextCommand) {
hook.command = nextCommand;
patched = true;
}
}
}
}
return patched;
}