fix: use absolute node path in hook commands instead of CLI

The `context-mode hook <platform> <event>` CLI command requires
the binary to be in PATH, which fails on homebrew/nvm/volta setups
where the global bin dir is not in the hook subprocess PATH.

All three adapters now use `node "<pluginRoot>/hooks/<script>.mjs"`
format via buildHookCommand(hookType, pluginRoot), ensuring hooks
work regardless of PATH configuration.

Affected adapters: Claude Code, VS Code Copilot, Gemini CLI.
727 tests passing.
This commit is contained in:
Mert Koseoglu
2026-03-06 20:12:56 +03:00
parent 5be9ac192b
commit f5c9d02a79
6 changed files with 39 additions and 24 deletions
+7 -3
View File
@@ -104,9 +104,13 @@ export function isContextModeHook(
/**
* Build the hook command string for a given hook type.
* Uses the CLI dispatcher: `context-mode hook claude-code <event>`
* Requires global install: `npm install -g context-mode`
* Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
* Falls back to CLI dispatcher if pluginRoot is not provided.
*/
export function buildHookCommand(hookType: HookType): string {
export function buildHookCommand(hookType: HookType, pluginRoot?: string): string {
if (pluginRoot) {
const scriptName = HOOK_SCRIPTS[hookType];
return `node "${pluginRoot}/hooks/${scriptName}"`;
}
return `context-mode hook claude-code ${hookType.toLowerCase()}`;
}
+1 -1
View File
@@ -482,7 +482,7 @@ export class ClaudeCodeAdapter implements HookAdapter {
];
for (const hookType of hookTypes) {
const command = buildHookCommand(hookType);
const command = buildHookCommand(hookType, pluginRoot);
if (hookType === HOOK_TYPES.PRE_TOOL_USE) {
const entry = {
+7 -3
View File
@@ -77,9 +77,13 @@ export function isContextModeHook(
/**
* Build the hook command string for a given hook type.
* Uses the CLI dispatcher: `context-mode hook gemini-cli <event>`
* Requires global install: `npm install -g context-mode`
* Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
* Falls back to CLI dispatcher if pluginRoot is not provided.
*/
export function buildHookCommand(hookType: HookType): string {
export function buildHookCommand(hookType: HookType, pluginRoot?: string): string {
const scriptName = HOOK_SCRIPTS[hookType];
if (pluginRoot && scriptName) {
return `node "${pluginRoot}/hooks/${scriptName}"`;
}
return `context-mode hook gemini-cli ${hookType.toLowerCase()}`;
}
+9 -7
View File
@@ -69,6 +69,8 @@ interface GeminiCLIHookInput {
import {
HOOK_TYPES as GEMINI_HOOK_NAMES,
HOOK_SCRIPTS as GEMINI_HOOK_SCRIPTS,
buildHookCommand as buildGeminiHookCommand,
type HookType as GeminiHookType,
} from "./hooks.js";
// ─────────────────────────────────────────────────────────
@@ -241,7 +243,7 @@ export class GeminiCLIAdapter implements HookAdapter {
return join(this.getSessionDir(), `${hash}-events.md`);
}
generateHookConfig(_pluginRoot: string): HookRegistration {
generateHookConfig(pluginRoot: string): HookRegistration {
return {
[GEMINI_HOOK_NAMES.BEFORE_TOOL]: [
{
@@ -249,7 +251,7 @@ export class GeminiCLIAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook gemini-cli ${GEMINI_HOOK_NAMES.BEFORE_TOOL.toLowerCase()}`,
command: buildGeminiHookCommand(GEMINI_HOOK_NAMES.BEFORE_TOOL, pluginRoot),
},
],
},
@@ -260,7 +262,7 @@ export class GeminiCLIAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook gemini-cli ${GEMINI_HOOK_NAMES.AFTER_TOOL.toLowerCase()}`,
command: buildGeminiHookCommand(GEMINI_HOOK_NAMES.AFTER_TOOL, pluginRoot),
},
],
},
@@ -271,7 +273,7 @@ export class GeminiCLIAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook gemini-cli ${GEMINI_HOOK_NAMES.PRE_COMPRESS.toLowerCase()}`,
command: buildGeminiHookCommand(GEMINI_HOOK_NAMES.PRE_COMPRESS, pluginRoot),
},
],
},
@@ -282,7 +284,7 @@ export class GeminiCLIAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook gemini-cli ${GEMINI_HOOK_NAMES.SESSION_START.toLowerCase()}`,
command: buildGeminiHookCommand(GEMINI_HOOK_NAMES.SESSION_START, pluginRoot),
},
],
},
@@ -440,7 +442,7 @@ export class GeminiCLIAdapter implements HookAdapter {
// ── Upgrade ────────────────────────────────────────────
configureAllHooks(_pluginRoot: string): string[] {
configureAllHooks(pluginRoot: string): string[] {
const settings = this.readSettings() ?? {};
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
const changes: string[] = [];
@@ -453,7 +455,7 @@ export class GeminiCLIAdapter implements HookAdapter {
];
for (const config of hookConfigs) {
const command = `context-mode hook gemini-cli ${config.name.toLowerCase()}`;
const command = buildGeminiHookCommand(config.name as GeminiHookType, pluginRoot);
const entry = {
matcher: "",
hooks: [{ type: "command", command }],
+6 -3
View File
@@ -84,13 +84,16 @@ export function isContextModeHook(
/**
* Build the hook command string for a given hook type.
* Uses the CLI dispatcher: `context-mode hook vscode-copilot <event>`
* Requires global install: `npm install -g context-mode`
* Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
* Falls back to CLI dispatcher if pluginRoot is not provided.
*/
export function buildHookCommand(hookType: HookType): string {
export function buildHookCommand(hookType: HookType, pluginRoot?: string): string {
const scriptName = HOOK_SCRIPTS[hookType];
if (!scriptName) {
throw new Error(`No script defined for hook type: ${hookType}`);
}
if (pluginRoot) {
return `node "${pluginRoot}/hooks/${scriptName}"`;
}
return `context-mode hook vscode-copilot ${hookType.toLowerCase()}`;
}
+9 -7
View File
@@ -74,6 +74,8 @@ interface VSCodeCopilotHookInput {
import {
HOOK_TYPES as VSCODE_HOOK_NAMES,
HOOK_SCRIPTS as VSCODE_HOOK_SCRIPTS,
buildHookCommand as buildVSCodeHookCommand,
type HookType as VSCodeHookType,
} from "./hooks.js";
// ─────────────────────────────────────────────────────────
@@ -266,7 +268,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
return join(this.getSessionDir(), `${hash}-events.md`);
}
generateHookConfig(_pluginRoot: string): HookRegistration {
generateHookConfig(pluginRoot: string): HookRegistration {
return {
[VSCODE_HOOK_NAMES.PRE_TOOL_USE]: [
{
@@ -274,7 +276,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook vscode-copilot ${VSCODE_HOOK_NAMES.PRE_TOOL_USE.toLowerCase()}`,
command: buildVSCodeHookCommand(VSCODE_HOOK_NAMES.PRE_TOOL_USE, pluginRoot),
},
],
},
@@ -285,7 +287,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook vscode-copilot ${VSCODE_HOOK_NAMES.POST_TOOL_USE.toLowerCase()}`,
command: buildVSCodeHookCommand(VSCODE_HOOK_NAMES.POST_TOOL_USE, pluginRoot),
},
],
},
@@ -296,7 +298,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook vscode-copilot ${VSCODE_HOOK_NAMES.PRE_COMPACT.toLowerCase()}`,
command: buildVSCodeHookCommand(VSCODE_HOOK_NAMES.PRE_COMPACT, pluginRoot),
},
],
},
@@ -307,7 +309,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook vscode-copilot ${VSCODE_HOOK_NAMES.SESSION_START.toLowerCase()}`,
command: buildVSCodeHookCommand(VSCODE_HOOK_NAMES.SESSION_START, pluginRoot),
},
],
},
@@ -498,7 +500,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
// ── Upgrade ────────────────────────────────────────────
configureAllHooks(_pluginRoot: string): string[] {
configureAllHooks(pluginRoot: string): string[] {
const changes: string[] = [];
const hookConfig: Record<string, unknown> = { hooks: {} };
const hooks = hookConfig.hooks as Record<string, unknown>;
@@ -520,7 +522,7 @@ export class VSCodeCopilotAdapter implements HookAdapter {
hooks: [
{
type: "command",
command: `context-mode hook vscode-copilot ${hookType.toLowerCase()}`,
command: buildVSCodeHookCommand(hookType, pluginRoot),
},
],
},