Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/axi-sdk-js/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<command>"`) 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[],
Expand All @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions packages/axi-sdk-js/test/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
computeSessionStartHookUpdate,
extractNpmShimScriptPath,
installSessionStartHooks,
isVersionManagerPathEntry,
resolvePortableHookCommand,
shouldInstallHooksForNodeAxiExecPath,
} from "../src/hooks.js";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading