diff --git a/packages/axi-sdk-js/src/hooks.ts b/packages/axi-sdk-js/src/hooks.ts index 6b554dc..9a2a706 100644 --- a/packages/axi-sdk-js/src/hooks.ts +++ b/packages/axi-sdk-js/src/hooks.ts @@ -353,6 +353,31 @@ function installOpenCodeAmbientPlugin( } } +// Node version managers add their active bin directory to PATH from +// interactive shell startup files (~/.zshrc, ~/.bashrc, fnm/asdf hooks). +// Agent SessionStart hooks, however, run under a NON-interactive shell +// (Claude Code spawns `/bin/sh -c ""`) that never sources those +// files. A bare binary name that resolves via one of these directories at +// install time therefore becomes "command not found" when the hook later +// fires. Detect such directories so the caller falls back to the absolute +// exec path, which does not depend on PATH. +const VERSION_MANAGER_PATH_SEGMENTS = new Set([ + ".nvm", // nvm + ".nvs", // nvs + ".fnm", // fnm (install / some layouts) + "fnm_multishells", // fnm (per-shell PATH entry — the common case) + ".asdf", // asdf + ".volta", // Volta + ".nodenv", // nodenv +]); + +export function isVersionManagerPathEntry(dir: string): boolean { + return dir + .replaceAll("\\", "/") + .split("/") + .some((segment) => VERSION_MANAGER_PATH_SEGMENTS.has(segment)); +} + export function resolvePortableHookCommand( execPath: string, binaryNames: string[], @@ -374,6 +399,9 @@ export function resolvePortableHookCommand( } for (const dir of context.pathEntries) { if (!dir) continue; + // Skip version-manager bin dirs: the bare name resolves here now but + // won't be on PATH when the hook runs in a non-interactive shell. + if (isVersionManagerPathEntry(dir)) continue; for (const ext of context.pathExtensions) { const candidate = join(dir, `${name}${ext}`); const resolvedCandidate = context.resolveRealPath(candidate); diff --git a/packages/axi-sdk-js/test/hooks.test.ts b/packages/axi-sdk-js/test/hooks.test.ts index fd21238..0092776 100644 --- a/packages/axi-sdk-js/test/hooks.test.ts +++ b/packages/axi-sdk-js/test/hooks.test.ts @@ -18,6 +18,7 @@ import { computeSessionStartHookUpdate, extractNpmShimScriptPath, installSessionStartHooks, + isVersionManagerPathEntry, resolvePortableHookCommand, shouldInstallHooksForNodeAxiExecPath, } from "../src/hooks.js"; @@ -337,6 +338,76 @@ describe("resolvePortableHookCommand", () => { resolvePortableHookCommand(exec, ["gh-axi"], "gh-axi", context), ).toBe(exec); }); + + it("returns the absolute exec path when the binary only resolves via nvm", () => { + // Reproduces the reported bug: `gh-axi setup hooks` run from an + // interactive shell where nvm has put its bin dir on PATH. The bare name + // resolves now, but the hook later runs under `/bin/sh` without nvm. + const exec = "/home/me/.nvm/versions/node/v20.11.1/bin/gh-axi"; + const context = { + pathEntries: ["/home/me/.nvm/versions/node/v20.11.1/bin"], + pathExtensions: [""], + resolveRealPath: (p: string) => + ({ + [exec]: exec, + "/home/me/.nvm/versions/node/v20.11.1/bin/gh-axi": exec, + })[p], + }; + + expect( + resolvePortableHookCommand(exec, ["gh-axi"], "gh-axi", context), + ).toBe(exec); + }); + + it("prefers a stable PATH dir over a version-manager dir for the same binary", () => { + const exec = "/usr/local/lib/node_modules/gh-axi/dist/bin/gh-axi.js"; + const context = { + // nvm dir comes first but must be skipped in favor of /usr/local/bin. + pathEntries: [ + "/home/me/.nvm/versions/node/v20.11.1/bin", + "/usr/local/bin", + ], + pathExtensions: [""], + resolveRealPath: (p: string) => + ({ + [exec]: exec, + "/home/me/.nvm/versions/node/v20.11.1/bin/gh-axi": exec, + "/usr/local/bin/gh-axi": exec, + })[p], + }; + + expect( + resolvePortableHookCommand(exec, ["gh-axi"], "gh-axi", context), + ).toBe("gh-axi"); + }); +}); + +describe("isVersionManagerPathEntry", () => { + it("flags version-manager bin directories", () => { + for (const dir of [ + "/home/me/.nvm/versions/node/v20.11.1/bin", + "/home/me/.nvs/node/20.11.1/x64/bin", + "/home/me/.fnm/node-versions/v20.11.1/installation/bin", + "/home/me/Library/Application Support/fnm_multishells/1234_567/bin", + "/home/me/.asdf/shims", + "/home/me/.volta/bin", + "/home/me/.nodenv/shims", + ]) { + expect(isVersionManagerPathEntry(dir)).toBe(true); + } + }); + + it("does not flag stable system directories", () => { + for (const dir of [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + "/home/me/.local/bin", + "C:\\Program Files\\nodejs", + ]) { + expect(isVersionManagerPathEntry(dir)).toBe(false); + } + }); }); describe("extractNpmShimScriptPath", () => {