From 0d65c8dafc9f35226eb89e22c2777e92160c55b5 Mon Sep 17 00:00:00 2001 From: Steve Holsinger Date: Wed, 22 Jul 2026 20:02:21 -0400 Subject: [PATCH] fix(axi-sdk-js): use absolute path for hooks when CLI installed via a Node version manager resolvePortableHookCommand preferred the bare binary name whenever it resolved on PATH at install time. But Node version managers (nvm, nvs, fnm, asdf, Volta, nodenv) add their bin directory to PATH only from interactive shell startup files. Agent SessionStart hooks run under a non-interactive shell (`/bin/sh -c`) that never sources those files, so the bare name is "command not found" when the hook fires. Skip version-manager bin directories when choosing the command so the resolver falls back to the absolute exec path, which does not depend on an interactively-augmented PATH. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/axi-sdk-js/src/hooks.ts | 28 ++++++++++ packages/axi-sdk-js/test/hooks.test.ts | 71 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) 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", () => {