From fa66640bf600551602d5002b2fb385d2731a4cba Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:14 -0400 Subject: [PATCH 1/7] chore(hooks): expose dependency preparation for agent commands --- .claude/hooks/install-deps.sh | 28 +++- .claude/hooks/prepare-command.sh | 13 ++ .gitignore | 1 + AGENTS.md | 22 ++- src/__tests__/install-deps.test.ts | 258 +++++++++++++++++++++++++++-- 5 files changed, 301 insertions(+), 21 deletions(-) create mode 100755 .claude/hooks/prepare-command.sh diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 73d1a2a37..6d199737d 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -13,6 +13,14 @@ set -euo pipefail # Logs go to stderr because SessionStart hook stdout enters the model's context. log() { echo "[install-deps] $*" >&2; } +# Explicit command callers need the selected Node bin, not a readiness signal. +# Claude hook invocations omit the argument and keep stdout empty. +output_mode="${1:-}" +case "${output_mode}" in + ""|--node-bin|--prepare-node-bin) ;; + *) log "unknown operation '${output_mode}'"; exit 2 ;; +esac + # A hook's non-interactive shell never sources .bashrc, so nvm is loaded here. # - Without it, npm can resolve to a system Node (cloud images ship Node 22 at # /opt/node22). @@ -28,7 +36,7 @@ for dir in "${HOME}/.nvm" /root/.nvm; do # version is not installed. set +eu # shellcheck disable=SC1091 - . "${dir}/nvm.sh" + . "${dir}/nvm.sh" >&2 set -eu break fi @@ -138,7 +146,23 @@ persist_node_on_path() { fi } -persist_node_on_path +if [[ -n "${output_mode}" ]]; then + # Resolve inside the checkout: a version-manager shim can select another + # Node when invoked from the original shell's working directory. + physical_node_path="$(cd "${checkout}" && node -p process.execPath 2>/dev/null)" || { + log "could not resolve the selected Node in ${checkout}" + exit 1 + } + if [[ "${physical_node_path}" != /* || ! -x "${physical_node_path}" ]]; then + log "selected Node in ${checkout} returned an invalid executable path" + exit 1 + fi + node_bin="$(dirname "${physical_node_path}")" + printf '%s\n' "${node_bin}" + [[ "${output_mode}" != --node-bin ]] || exit 0 +else + persist_node_on_path +fi # The stamp, marker, and lock (each described below) live in the checkout's # git directory, which is per-worktree and never tracked. A .claude/ location diff --git a/.claude/hooks/prepare-command.sh b/.claude/hooks/prepare-command.sh new file mode 100755 index 000000000..38b1506df --- /dev/null +++ b/.claude/hooks/prepare-command.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Repository command callers send the checkout and a non-SessionStart event +# as JSON on stdin. Only the selected physical Node bin goes to stdout. +set -euo pipefail + +case "${1:-}" in + environment) installer_operation=--node-bin ;; + prepare) installer_operation=--prepare-node-bin ;; + *) echo "[prepare-command] expected environment or prepare" >&2; exit 2 ;; +esac + +hook_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec bash "${hook_directory}/install-deps.sh" "${installer_operation}" diff --git a/.gitignore b/.gitignore index 3e9e36875..53d1afaf7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ CLAUDE.local.md !.claude/hooks/ .claude/hooks/* !.claude/hooks/install-deps.sh +!.claude/hooks/prepare-command.sh TASKS.md .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 54feb0ffa..6b714cbfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,9 @@ on a folder for the full list. server.json # MCP server registry manifest render.yaml # Render Blueprint; stays at the repo root, the only place Render reads it Dockerfile # Two-target build: local (default) + remote -.claude/ # Committed Claude Code hook setup only (the rest is gitignored): settings.json registers the hook below - hooks/install-deps.sh # Runs at session start and worktree entry: loads nvm, installs dependencies when needed (npm ci, sst install), and at session start puts the checkout's Node (the .nvmrc version when nvm has it, else nvm's default alias) first on PATH for the session's later Bash commands +.claude/ # Committed dependency hooks (the rest is gitignored); settings.json registers Claude Code setup + hooks/install-deps.sh # Claude session/worktree setup; loads the checkout's Node and installs dependencies/SST types when needed + hooks/prepare-command.sh # Optional command interface: environment selects Node; prepare also runs dependency setup obsidian-headless/ # Lockfile-pinned obsidian-headless Sync CLI for the :remote image rootfs/ # Container filesystem overlay for the :remote image: s6 init chain and services in etc/s6-overlay/, and usr/local/bin/get-sync-token, an in-container terminal sign-in to Obsidian that prints the Sync token for .env templates/memory/ # About Me/ memory file templates for new vaults @@ -416,6 +417,21 @@ change path separator behavior. Native Windows execution (without Docker) is not a supported deployment and would break path handling throughout the codebase. +## Dependency setup for agent commands + +Claude Code uses the committed session/worktree hook. Other clients can opt in +through `.claude/hooks/prepare-command.sh`: send JSON with the checkout's `cwd` +and `hook_event_name: "PreToolUse"` on stdin, and pass `environment` or `prepare`. +The first operation selects Node without writing install state; the second also +waits for the existing dependency and SST setup. Both print only the physical +Node bin directory to stdout. Prepend that directory to the command's PATH. +Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. + +Setup keeps the existing per-worktree lock and nonblocking failure behavior; +an exit of zero does not prove dependencies are ready. Check the actual test or +build result. Callers must allow time for setup and its lock wait. A checkout +without this entry point has not opted in. + ## Code style @@ -968,7 +984,7 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): `init-setup-vault.test.ts`, `check-sync-vault.test.ts` (the vault check `init-setup-vault` runs), and `print-derived-env.test.ts` (the derivation that `init-derive-env` publishes) - - the Claude Code hook in `.claude/hooks/` → + - the dependency hooks in `.claude/hooks/` → `src/__tests__/install-deps.test.ts` - the release scripts in `.github/scripts/` → `src/__tests__/`: `generate-notes.test.ts`, `generate-cli-notes.test.ts`, diff --git a/src/__tests__/install-deps.test.ts b/src/__tests__/install-deps.test.ts index 0ce848ad4..f97a54731 100644 --- a/src/__tests__/install-deps.test.ts +++ b/src/__tests__/install-deps.test.ts @@ -13,7 +13,7 @@ import { writeFileSync, } from "node:fs" import { tmpdir } from "node:os" -import { basename, join, resolve } from "node:path" +import { basename, dirname, join, resolve } from "node:path" import { describe, expect, it, onTestFinished } from "vitest" @@ -28,6 +28,7 @@ import { describe, expect, it, onTestFinished } from "vitest" */ const HOOK_PATH = resolve(import.meta.dirname, "../../.claude/hooks/install-deps.sh") +const COMMAND_PATH = resolve(import.meta.dirname, "../../.claude/hooks/prepare-command.sh") /** Stub `npm`: appends its arguments to STUB_NPM_LOG, one call per line. An * `ls` call exits with STUB_NPM_LS_STATUS and a `ci` call with @@ -105,6 +106,7 @@ type HookRun = { type HookRunOptions = { fixture: HookFixture + operation?: "environment" | "prepare" /** What the stub `npm ls` exits with. Defaults to 0, a complete tree. */ npmLsStatus?: number /** What the stub `npm ci` exits with. Defaults to 0, a finished install. */ @@ -277,7 +279,8 @@ const hookEnv = (options: HookRunOptions): Record => { } const runHook = (options: HookRunOptions): HookRun => { - const result = spawnSync("bash", [HOOK_PATH], { + const command = options.operation ? [COMMAND_PATH, options.operation] : [HOOK_PATH] + const result = spawnSync("bash", command, { cwd: options.fixture.outsideDir, input: hookPayload(options), encoding: "utf8", @@ -423,6 +426,10 @@ const createNodeBinDir = (fixture: HookFixture, version: keyof typeof STUB_NODE_ ` echo ${STUB_NODE_ABIS[version]}`, " exit 0", "fi", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + ` echo "${join(binDir, "node")}"`, + " exit 0", + "fi", `exec "${process.execPath}" "$@"`, "", ].join("\n"), @@ -434,7 +441,239 @@ const createNodeBinDir = (fixture: HookFixture, version: keyof typeof STUB_NODE_ return binDir } +/** A version-manager shim chooses a different real executable in the target + * checkout than in the shell's original working directory. */ +const createDirectoryKeyedNodeShim = ( + fixture: HookFixture, +): { shimBin: string; checkoutBin: string } => { + const checkoutBin = createNodeBinDir(fixture, "v24") + const outsideBin = createNodeBinDir(fixture, "v20") + const shimBin = join(fixture.outsideDir, "shims") + mkdirSync(shimBin) + writeExecutable( + join(shimBin, "node"), + [ + "#!/bin/sh", + `if [ "$PWD" = "${fixture.checkout}" ]; then`, + ` exec "${join(checkoutBin, "node")}" "$@"`, + "fi", + `exec "${join(outsideBin, "node")}" "$@"`, + "", + ].join("\n"), + ) + return { shimBin, checkoutBin } +} + describe("install-deps hook", () => { + describe("repository command interface", () => { + it("selects a valid Node bin without touching missing dependencies, SST types or interrupted-install state", () => { + const fixture = createHookFixture() + rmSync(join(fixture.checkout, "node_modules"), { recursive: true }) + rmSync(join(fixture.checkout, ".sst"), { recursive: true }) + leaveMarker(fixture) + leaveOlderStamp(fixture) + const claudeEnvFile = join(fixture.outsideDir, "foreign-env") + writeFileSync(claudeEnvFile, "keep this file\n") + + const run = runHook({ fixture, operation: "environment", claudeEnvFile }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + dependenciesCreated: existsSync(join(fixture.checkout, "node_modules")), + sstCreated: existsSync(join(fixture.checkout, ".sst")), + envFile: readFileSync(claudeEnvFile, "utf8"), + }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: "", + npmCalls: [], + marker: `${fixture.installIdentity}\n`, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + dependenciesCreated: false, + sstCreated: false, + envFile: "keep this file\n", + }) + }) + + it.each([{ dependencyState: "missing" }, { dependencyState: "stale" }])( + "prepares $dependencyState dependencies and missing SST types while printing only the selected bin", + ({ dependencyState }) => { + const fixture = createHookFixture() + + if (dependencyState === "missing") { + rmSync(join(fixture.checkout, "node_modules"), { recursive: true }) + } else { + leaveOlderStamp(fixture) + } + rmSync(join(fixture.checkout, ".sst"), { recursive: true }) + writeExecutable( + join(fixture.stubBinDir, "npm"), + NPM_STUB.replace('case "$*" in', 'echo "npm output"\ncase "$*" in'), + ) + writeExecutable( + join(fixture.stubBinDir, "npx"), + NPX_STUB.replace("exit 0\n", "echo 'sst output'\nexit 0\n"), + ) + const claudeEnvFile = join(fixture.outsideDir, "foreign-env") + writeFileSync(claudeEnvFile, "keep this file\n") + + const run = runHook({ fixture, operation: "prepare", claudeEnvFile }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + envFile: readFileSync(claudeEnvFile, "utf8"), + }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: [ + `[install-deps] installing dependencies in ${fixture.checkout} (node ${process.version})`, + "npm output", + "[install-deps] install complete", + `[install-deps] installing SST platform types in ${fixture.checkout}`, + "sst output", + "", + ].join("\n"), + npmCalls: ["ci", "npx sst install"], + marker: null, + stamp: `${fixture.installIdentity}\n`, + envFile: "keep this file\n", + }) + }, + ) + + it("reports the selected bin after a failed install while preserving the retry marker", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + + const run = runHook({ fixture, operation: "prepare", npmCiStatus: 1 }) + + expect({ ...run, npmCalls: recordedNpmCalls(fixture), ...installState(fixture) }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: [ + `[install-deps] installing dependencies in ${fixture.checkout} (node ${process.version})`, + "[install-deps] npm ci failed — the session continues without dependencies; the marker forces a retry next session", + "", + ].join("\n"), + npmCalls: ["ci"], + marker: `${fixture.installIdentity}\n`, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + }) + }) + + it("diagnoses an invalid runtime result without printing a bin or touching install state", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + writeExecutable( + join(fixture.stubBinDir, "node"), + [ + "#!/bin/sh", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + " echo relative/node", + " exit 0", + "fi", + `exec "${process.execPath}" "$@"`, + "", + ].join("\n"), + ) + + const run = runHook({ fixture, operation: "prepare" }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] selected Node in ${fixture.checkout} returned an invalid executable path\n`, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }) + + it.each([{ operation: "environment" }, { operation: "prepare" }] as const)( + "uses the .nvmrc Node despite the wrong initial PATH for $operation", + ({ operation }) => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + writeFileSync(join(fixture.checkout, ".nvmrc"), "24\n") + writeNvmStandIn(fixture) + const launchingBin = createNodeBinDir(fixture, "v22") + const nvmrcBin = createNodeBinDir(fixture, "v24") + const defaultBin = createNodeBinDir(fixture, "v20") + + const run = runHook({ + fixture, + operation, + path: `${launchingBin}:${fixture.stubBinDir}:${runnerPath()}`, + nvmAnswers: { nvmrcNode: join(nvmrcBin, "node"), defaultNode: join(defaultBin, "node") }, + }) + + expect({ + status: run.status, + stdout: run.stdout, + npmCalls: recordedNpmCalls(fixture), + }).toEqual({ + status: 0, + stdout: `${nvmrcBin}\n`, + npmCalls: operation === "prepare" ? ["v24 ci"] : [], + }) + }, + ) + + it("returns the checkout's physical runtime so a prefix command outside the checkout uses the installer's Node", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + const { shimBin, checkoutBin } = createDirectoryKeyedNodeShim(fixture) + const path = `${shimBin}:${fixture.stubBinDir}:${runnerPath()}` + writeExecutable( + join(fixture.stubBinDir, "npm"), + '#!/bin/sh\nprintf "abi-%s %s\\n" "$(node -p process.versions.modules)" "$*" >> "$STUB_NPM_LOG"\n', + ) + + const run = runHook({ fixture, operation: "prepare", path }) + const command = spawnSync( + "bash", + [ + "-c", + 'node -p process.versions.modules && npm --prefix "$1" test', + "bash", + fixture.checkout, + ], + { + cwd: fixture.outsideDir, + encoding: "utf8", + env: { ...hookEnv({ fixture, path }), PATH: `${run.stdout.trimEnd()}:${path}` }, + }, + ) + + expect({ + status: run.status, + stdout: run.stdout, + command: { status: command.status, stdout: command.stdout, stderr: command.stderr }, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + }).toEqual({ + status: 0, + stdout: `${checkoutBin}\n`, + command: { status: 0, stdout: "137\n", stderr: "" }, + npmCalls: ["abi-137 ci", `v24 --prefix ${fixture.checkout} test`], + marker: null, + stamp: `${fixture.lockfileHash} node-abi-137\n`, + }) + }) + }) + it("resolves npm and npx to the stubs, with no nvm command, after loading the HOME nvm.sh the way the hook does", () => { const fixture = createHookFixture() // The hook's own condition: it loads $HOME/.nvm/nvm.sh only when the file @@ -1165,20 +1404,7 @@ describe("install-deps hook", () => { it("records the ABI of the Node a directory-keyed shim picks in the checkout, where npm ci runs", () => { const fixture = createHookFixture() leaveOlderStamp(fixture) - const shimBin = join(fixture.outsideDir, "shims") - mkdirSync(shimBin) - writeExecutable( - join(shimBin, "node"), - [ - "#!/bin/sh", - 'if [ "$1" = "-p" ] && [ "$2" = "process.versions.modules" ]; then', - ` if [ "$PWD" = "${fixture.checkout}" ]; then echo ${STUB_NODE_ABIS.v24}; else echo ${STUB_NODE_ABIS.v20}; fi`, - " exit 0", - "fi", - `exec "${process.execPath}" "$@"`, - "", - ].join("\n"), - ) + const { shimBin } = createDirectoryKeyedNodeShim(fixture) const run = runHook({ fixture, path: `${shimBin}:${fixture.stubBinDir}:${runnerPath()}` }) From 2a8dde16d21595c173a175ee7fab6a2bfcf0d4c0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:55:38 -0400 Subject: [PATCH 2/7] style: clarify dependency command operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: code-quality · gpt-6.1-sol --- .claude/hooks/install-deps.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 6d199737d..26499d79e 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -13,7 +13,9 @@ set -euo pipefail # Logs go to stderr because SessionStart hook stdout enters the model's context. log() { echo "[install-deps] $*" >&2; } -# Explicit command callers need the selected Node bin, not a readiness signal. +# Explicit command callers print the selected Node bin: +# - --node-bin selects Node without installing. +# - --prepare-node-bin also runs dependency setup. # Claude hook invocations omit the argument and keep stdout empty. output_mode="${1:-}" case "${output_mode}" in From c7133415485b521c4a0737398663218b096500fa Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:03:29 -0400 Subject: [PATCH 3/7] test: cover command preparation edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: test-audit · gpt-6.1-sol --- scripts/__tests__/lint-shell.test.ts | 1 + src/__tests__/install-deps.test.ts | 126 ++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index e168b428c..df2d1d931 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it, onTestFinished } from "vitest" /** Every shell script in the repo, sorted: what a run with no paths checks. */ const ALL_SHELL_SCRIPTS = [ ".claude/hooks/install-deps.sh", + ".claude/hooks/prepare-command.sh", ".github/scripts/generate-cli-notes.sh", ".github/scripts/generate-notes.sh", ".github/scripts/update-changelog.sh", diff --git a/src/__tests__/install-deps.test.ts b/src/__tests__/install-deps.test.ts index f97a54731..18178462d 100644 --- a/src/__tests__/install-deps.test.ts +++ b/src/__tests__/install-deps.test.ts @@ -466,6 +466,116 @@ const createDirectoryKeyedNodeShim = ( describe("install-deps hook", () => { describe("repository command interface", () => { + it.each([ + { + label: "a missing wrapper operation", + path: COMMAND_PATH, + arguments: [], + message: "[prepare-command] expected environment or prepare\n", + }, + { + label: "an unknown wrapper operation", + path: COMMAND_PATH, + arguments: ["unknown"], + message: "[prepare-command] expected environment or prepare\n", + }, + { + label: "an unknown installer operation", + path: HOOK_PATH, + arguments: ["--unknown"], + message: "[install-deps] unknown operation '--unknown'\n", + }, + ])( + "rejects $label before touching install state", + ({ path, arguments: commandArguments, message }) => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + + const run = spawnSync("bash", [path, ...commandArguments], { + cwd: fixture.outsideDir, + input: hookPayload({ fixture }), + encoding: "utf8", + env: hookEnv({ fixture }), + }) + + expect({ + status: run.status, + stdout: run.stdout, + stderr: run.stderr, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 2, + stdout: "", + stderr: message, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }, + ) + + it.each([ + { label: "environment selection", operation: "environment" }, + { label: "dependency preparation", operation: "prepare" }, + { label: "a Claude hook", operation: undefined }, + ] as const)("keeps nvm startup output off stdout for $label", ({ operation }) => { + const fixture = createHookFixture() + writeFileSync( + join(fixture.outsideDir, ".nvm", "nvm.sh"), + "echo 'nvm startup output'\nfalse\n", + ) + + const run = runHook({ fixture, ...(operation ? { operation } : {}) }) + + expect({ ...run, npmCalls: recordedNpmCalls(fixture) }).toEqual({ + status: 0, + stdout: operation ? `${dirname(realpathSync(process.execPath))}\n` : "", + stderr: + "nvm startup output\n" + + (operation === "environment" + ? "" + : `[install-deps] node_modules current in ${fixture.checkout} — nothing to do\n`), + npmCalls: [], + }) + }) + + it("diagnoses a failed runtime probe before printing a bin or touching install state", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + writeExecutable( + join(fixture.stubBinDir, "node"), + [ + "#!/bin/sh", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + " echo 'runtime probe failed' >&2", + " exit 9", + "fi", + `exec "${process.execPath}" "$@"`, + "", + ].join("\n"), + ) + + const run = runHook({ fixture, operation: "prepare" }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] could not resolve the selected Node in ${fixture.checkout}\n`, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }) + it("selects a valid Node bin without touching missing dependencies, SST types or interrupted-install state", () => { const fixture = createHookFixture() rmSync(join(fixture.checkout, "node_modules"), { recursive: true }) @@ -567,15 +677,27 @@ describe("install-deps hook", () => { }) }) - it("diagnoses an invalid runtime result without printing a bin or touching install state", () => { + it.each([ + { label: "a relative runtime path", pathKind: "relative" }, + { label: "a missing runtime executable", pathKind: "missing" }, + { label: "a non-executable runtime file", pathKind: "non-executable" }, + ])("rejects $label without printing a bin or touching install state", ({ pathKind }) => { const fixture = createHookFixture() leaveOlderStamp(fixture) + const runtimePath = + pathKind === "relative" ? "relative/node" : join(fixture.outsideDir, "runtime") + + if (pathKind === "relative") { + mkdirSync(join(fixture.outsideDir, "relative")) + writeExecutable(join(fixture.outsideDir, runtimePath), "#!/bin/sh\nexit 0\n") + } + if (pathKind === "non-executable") writeFileSync(runtimePath, "not executable\n") writeExecutable( join(fixture.stubBinDir, "node"), [ "#!/bin/sh", 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', - " echo relative/node", + ` echo "${runtimePath}"`, " exit 0", "fi", `exec "${process.execPath}" "$@"`, From 6c47b0fdf999b4a91c074e06da3f7967e305ccd7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:29:12 -0400 Subject: [PATCH 4/7] chore: reject command setup without a checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · gpt-6.1-sol --- .claude/hooks/install-deps.sh | 1 + AGENTS.md | 10 +++++-- src/__tests__/install-deps.test.ts | 42 +++++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 26499d79e..36a93c89b 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -63,6 +63,7 @@ payload_cwd="$(read_payload_field cwd)" checkout_lookup_dir="${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" checkout="$(git -C "${checkout_lookup_dir}" rev-parse --show-toplevel 2>/dev/null)" || { log "no git checkout resolved from '${checkout_lookup_dir}' — skipping" + [[ -z "${output_mode}" ]] || exit 1 exit 0 } diff --git a/AGENTS.md b/AGENTS.md index 6b714cbfc..cce955e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,10 +423,16 @@ Claude Code uses the committed session/worktree hook. Other clients can opt in through `.claude/hooks/prepare-command.sh`: send JSON with the checkout's `cwd` and `hook_event_name: "PreToolUse"` on stdin, and pass `environment` or `prepare`. The first operation selects Node without writing install state; the second also -waits for the existing dependency and SST setup. Both print only the physical -Node bin directory to stdout. Prepend that directory to the command's PATH. +waits for the existing dependency and SST setup. Both operations print only the +selected physical Node bin directory to stdout. Require a successful exit and +a nonempty absolute directory containing an executable `node` before prepending +the directory to the command's PATH. Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. +With nvm, selection uses the checkout's `.nvmrc` version when installed, then +nvm's default alias. If neither lookup finds an executable, or nvm is +unavailable, selection uses the current PATH. Setup never downloads Node. + Setup keeps the existing per-worktree lock and nonblocking failure behavior; an exit of zero does not prove dependencies are ready. Check the actual test or build result. Callers must allow time for setup and its lock wait. A checkout diff --git a/src/__tests__/install-deps.test.ts b/src/__tests__/install-deps.test.ts index 18178462d..9acd36308 100644 --- a/src/__tests__/install-deps.test.ts +++ b/src/__tests__/install-deps.test.ts @@ -768,14 +768,15 @@ describe("install-deps hook", () => { "bash", [ "-c", - 'node -p process.versions.modules && npm --prefix "$1" test', + 'export PATH="$1:$PATH"; node -p process.versions.modules && npm --prefix "$2" test', "bash", + run.stdout.trimEnd(), fixture.checkout, ], { cwd: fixture.outsideDir, encoding: "utf8", - env: { ...hookEnv({ fixture, path }), PATH: `${run.stdout.trimEnd()}:${path}` }, + env: hookEnv({ fixture, path }), }, ) @@ -855,15 +856,48 @@ describe("install-deps hook", () => { const run = runHook({ fixture, payloadCwd: null, claudeProjectDir: fixture.outsideDir }) expect({ - status: run.status, - stderr: run.stderr, + ...run, npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), }).toEqual({ status: 0, + stdout: "", stderr: `[install-deps] no git checkout resolved from '${fixture.outsideDir}' — skipping\n`, npmCalls: [], + marker: null, + stamp: null, + lockCreated: false, }) }) + + it.each([{ operation: "environment" }, { operation: "prepare" }] as const)( + "reports no checkout for $operation without touching install state", + ({ operation }) => { + const fixture = createHookFixture() + + const run = runHook({ + fixture, + payloadCwd: fixture.outsideDir, + operation, + }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] no git checkout resolved from '${fixture.outsideDir}' — skipping\n`, + npmCalls: [], + marker: null, + stamp: null, + lockCreated: false, + }) + }, + ) }) describe("with a marker left by an interrupted install", () => { From e5aa7c35beb501c63a8fa8b14f072c66d2d24596 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:36:03 -0400 Subject: [PATCH 5/7] docs(agents): route command setup into the contributor guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · gpt-6.1-sol --- AGENTS.md | 19 +------------------ CONTRIBUTING.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cce955e47..4ac588bca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,24 +419,7 @@ throughout the codebase. ## Dependency setup for agent commands -Claude Code uses the committed session/worktree hook. Other clients can opt in -through `.claude/hooks/prepare-command.sh`: send JSON with the checkout's `cwd` -and `hook_event_name: "PreToolUse"` on stdin, and pass `environment` or `prepare`. -The first operation selects Node without writing install state; the second also -waits for the existing dependency and SST setup. Both operations print only the -selected physical Node bin directory to stdout. Require a successful exit and -a nonempty absolute directory containing an executable `node` before prepending -the directory to the command's PATH. -Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. - -With nvm, selection uses the checkout's `.nvmrc` version when installed, then -nvm's default alias. If neither lookup finds an executable, or nvm is -unavailable, selection uses the current PATH. Setup never downloads Node. - -Setup keeps the existing per-worktree lock and nonblocking failure behavior; -an exit of zero does not prove dependencies are ready. Check the actual test or -build result. Callers must allow time for setup and its lock wait. A checkout -without this entry point has not opted in. +Before integrating agent command setup, read [Agent command setup](./CONTRIBUTING.md#agent-command-setup). ## Code style diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71b50e88f..4ab479efe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,27 @@ npx @modelcontextprotocol/inspector See the [README](./README.md#development) for full details on each mode. +## Agent command setup + +Claude Code uses the committed session/worktree hook. Other clients can opt in +through `.claude/hooks/prepare-command.sh`: send JSON with the checkout's `cwd` +and `hook_event_name: "PreToolUse"` on stdin, and pass `environment` or `prepare`. +The first operation selects Node without writing install state; the second also +waits for the existing dependency and SST setup. Both operations print only the +selected physical Node bin directory to stdout. Require a successful exit and +a nonempty absolute directory containing an executable `node` before prepending +the directory to the command's PATH. +Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. + +With nvm, selection uses the checkout's `.nvmrc` version when installed, then +nvm's default alias. If neither lookup finds an executable, or nvm is +unavailable, selection uses the current PATH. Setup never downloads Node. + +Setup keeps the existing per-worktree lock and nonblocking failure behavior; +an exit of zero does not prove dependencies are ready. Check the actual test or +build result. Callers must allow time for setup and its lock wait. A checkout +without this entry point has not opted in. + ## The `cli/` Package `cli/` is a separate npm package (`npx vault-cortex@latest init`) that scaffolds the From da47983790969fc4f4066bdc13961409c7acd10d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:42:02 -0400 Subject: [PATCH 6/7] docs: structure the agent command setup procedure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · gpt-6.1-sol --- CONTRIBUTING.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ab479efe..36a8f3652 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,15 +97,21 @@ See the [README](./README.md#development) for full details on each mode. ## Agent command setup Claude Code uses the committed session/worktree hook. Other clients can opt in -through `.claude/hooks/prepare-command.sh`: send JSON with the checkout's `cwd` -and `hook_event_name: "PreToolUse"` on stdin, and pass `environment` or `prepare`. -The first operation selects Node without writing install state; the second also -waits for the existing dependency and SST setup. Both operations print only the -selected physical Node bin directory to stdout. Require a successful exit and -a nonempty absolute directory containing an executable `node` before prepending -the directory to the command's PATH. -Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. +through `.claude/hooks/prepare-command.sh`: + +| Operation | Behavior | +| ------------- | ------------------------------------------------- | +| `environment` | Select Node without writing install state | +| `prepare` | Select Node and wait for dependency and SST setup | +1. Invoke the entry point with the chosen operation. Send JSON with the + checkout's `cwd` and `hook_event_name: "PreToolUse"` on stdin. +2. Require a successful exit and stdout containing one nonempty absolute + directory with an executable `node`. Both operations print only the + selected physical Node bin directory. +3. Prepend the validated directory to the original command's PATH. + +Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. With nvm, selection uses the checkout's `.nvmrc` version when installed, then nvm's default alias. If neither lookup finds an executable, or nvm is unavailable, selection uses the current PATH. Setup never downloads Node. From da2051c017132675f4565ba26c8f66a43f476be4 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:55:58 -0400 Subject: [PATCH 7/7] chore(hooks): reject extra operation arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use direct argv execution in the physical-runtime fixture while retaining outside-checkout Node ABI and npm PATH checks. Ship-Check: pr-monitor · gpt-6.1-sol --- .claude/hooks/install-deps.sh | 4 +++ .claude/hooks/prepare-command.sh | 4 +++ src/__tests__/install-deps.test.ts | 50 ++++++++++++++++++++---------- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 36a93c89b..4cff7d336 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -22,6 +22,10 @@ case "${output_mode}" in ""|--node-bin|--prepare-node-bin) ;; *) log "unknown operation '${output_mode}'"; exit 2 ;; esac +if (($# > 1)); then + log "expected at most one operation argument" + exit 2 +fi # A hook's non-interactive shell never sources .bashrc, so nvm is loaded here. # - Without it, npm can resolve to a system Node (cloud images ship Node 22 at diff --git a/.claude/hooks/prepare-command.sh b/.claude/hooks/prepare-command.sh index 38b1506df..1b653abf5 100755 --- a/.claude/hooks/prepare-command.sh +++ b/.claude/hooks/prepare-command.sh @@ -8,6 +8,10 @@ case "${1:-}" in prepare) installer_operation=--prepare-node-bin ;; *) echo "[prepare-command] expected environment or prepare" >&2; exit 2 ;; esac +if (($# != 1)); then + echo "[prepare-command] expected exactly one operation argument" >&2 + exit 2 +fi hook_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" exec bash "${hook_directory}/install-deps.sh" "${installer_operation}" diff --git a/src/__tests__/install-deps.test.ts b/src/__tests__/install-deps.test.ts index 9acd36308..8c78c28a2 100644 --- a/src/__tests__/install-deps.test.ts +++ b/src/__tests__/install-deps.test.ts @@ -485,6 +485,30 @@ describe("install-deps hook", () => { arguments: ["--unknown"], message: "[install-deps] unknown operation '--unknown'\n", }, + { + label: "trailing arguments after wrapper environment", + path: COMMAND_PATH, + arguments: ["environment", "--verbose"], + message: "[prepare-command] expected exactly one operation argument\n", + }, + { + label: "trailing arguments after wrapper prepare", + path: COMMAND_PATH, + arguments: ["prepare", "--verbose"], + message: "[prepare-command] expected exactly one operation argument\n", + }, + { + label: "trailing arguments after installer environment selection", + path: HOOK_PATH, + arguments: ["--node-bin", "--verbose"], + message: "[install-deps] expected at most one operation argument\n", + }, + { + label: "trailing arguments after installer preparation", + path: HOOK_PATH, + arguments: ["--prepare-node-bin", "--verbose"], + message: "[install-deps] expected at most one operation argument\n", + }, ])( "rejects $label before touching install state", ({ path, arguments: commandArguments, message }) => { @@ -764,32 +788,26 @@ describe("install-deps hook", () => { ) const run = runHook({ fixture, operation: "prepare", path }) - const command = spawnSync( - "bash", - [ - "-c", - 'export PATH="$1:$PATH"; node -p process.versions.modules && npm --prefix "$2" test', - "bash", - run.stdout.trimEnd(), - fixture.checkout, - ], - { - cwd: fixture.outsideDir, - encoding: "utf8", - env: hookEnv({ fixture, path }), - }, - ) + const executionOptions = { + cwd: fixture.outsideDir, + encoding: "utf8" as const, + env: hookEnv({ fixture, path: `${run.stdout.trimEnd()}:${path}` }), + } + const runtime = spawnSync("node", ["-p", "process.versions.modules"], executionOptions) + const command = spawnSync("npm", ["--prefix", fixture.checkout, "test"], executionOptions) expect({ status: run.status, stdout: run.stdout, + runtime: { status: runtime.status, stdout: runtime.stdout, stderr: runtime.stderr }, command: { status: command.status, stdout: command.stdout, stderr: command.stderr }, npmCalls: recordedNpmCalls(fixture), ...installState(fixture), }).toEqual({ status: 0, stdout: `${checkoutBin}\n`, - command: { status: 0, stdout: "137\n", stderr: "" }, + runtime: { status: 0, stdout: "137\n", stderr: "" }, + command: { status: 0, stdout: "", stderr: "" }, npmCalls: ["abi-137 ci", `v24 --prefix ${fixture.checkout} test`], marker: null, stamp: `${fixture.lockfileHash} node-abi-137\n`,