diff --git a/README.md b/README.md index 497b7c6e..917ec0e3 100644 --- a/README.md +++ b/README.md @@ -1535,10 +1535,10 @@ entries into the file's `hooks`: ```json { "hooks": { - "WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }], - "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }], - "SubagentStop": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }] + "WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], + "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], + "SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }] } } ``` diff --git a/quest/m2/README.md b/quest/m2/README.md index df3f891a..7e3130b7 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -17,4 +17,3 @@ additional login providers. This milestone does not gate the public release. - [Donor isolation](/quest/m2/donor-isolation.md) - stronger boundaries for untrusted shared jobs - [ChatGPT hosted runs](/quest/m2/chatgpt-hosted.md) - conditional on provider approval and current terms - [ChatGPT approval](/quest/m2/chatgpt-waitlist.md) - resolve hosted-use approval before implementation -- [Worktree hook PATH error](/quest/m2/hook-path-error.md) - a worktree hook without `quest` on `PATH` says so and names the dev-shell launch route diff --git a/quest/m2/hook-path-error.md b/quest/m2/hook-path-error.md deleted file mode 100644 index b48d42b6..00000000 --- a/quest/m2/hook-path-error.md +++ /dev/null @@ -1,25 +0,0 @@ -# [XS] Worktree hooks name the launch route when quest is missing - -## Goal - -When a Claude Code session runs Quest's worktree hooks and `quest` is not on -the `PATH` Claude Code runs hooks with, the session shows an error saying so -and how to fix it, such as launching the agent inside the project's dev shell -(`nix develop --command claude`) or installing `quest` on that `PATH`, instead -of the shell's bare `quest: command not found`. A test covers it. - -## Plan - -Decided 2026-11-04 (maintainer, /quest-plan). - -- The failure happens in the shell, before any Quest code runs, so the fix - belongs in the hook entry's command: a guard that checks for `quest` and - otherwise prints the message on stderr and exits 2, which Claude Code shows - and treats as blocking. A `WorktreeCreate` failure then aborts the worktree - with that message. -- The entries are a user-level opt-in whose JSON the README's "Claude Code - checkouts" section gives and SETUP.md points to, so the guarded entry is the - documented one. -- The test runs the documented entry with a `PATH` that lacks `quest` and - checks the exit status and message, and with `quest` present checks that - the hook runs. diff --git a/test/worktree.test.ts b/test/worktree.test.ts index f31ae43b..8e29b577 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -21,7 +21,7 @@ import { type Owner, Retained, type Scopes, currentOwner, kill, killable } from import { GitHub, mergedHead } from "../src/cli/github"; import { main } from "../src/cli/main"; import { Worktrees, canonical, derivedDefaults, diskUsage } from "../src/cli/worktree"; -import { claudeHook } from "../src/cli/worktree-hook"; +import { HOOK_EVENTS, claudeHook } from "../src/cli/worktree-hook"; import { initRepository, run, template } from "./fixture"; const source = dirname(dirname(fileURLToPath(import.meta.url))); @@ -1090,4 +1090,34 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { 1, ); }); + it.skipIf(process.platform === "win32")("the documented entries say so when quest is not on PATH", () => { + const readme = readFileSync(join(source, "README.md"), "utf8"); + const json = [...readme.matchAll(/```json\n(.*?)```/gs)] + .map((match) => match[1]) + .find((text) => text.includes('"WorktreeCreate"')); + if (json === undefined) throw new Error("README has no hook settings block"); + const { hooks } = JSON.parse(json) as { hooks: Record }; + const commands = HOOK_EVENTS.map((event) => hooks[event][0].hooks[0].command); + expect(new Set(commands).size).toBe(1); + const [command] = commands; + const run = (path: string, input = "") => + spawnSync("/bin/sh", ["-c", command], { input, encoding: "utf8", env: { PATH: path } }); + const dir = (): string => { + const root = mkdtempSync(join(tmpdir(), "quest-hook-")); + roots.push(root); + return root; + }; + const missing = run(dir()); + expect(missing.status).toBe(1); + expect(missing.stdout).toBe(""); + expect(missing.stderr).toBe( + "quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH\n", + ); + const bin = dir(); + writeFileSync(join(bin, "quest"), '#!/bin/sh\necho "quest $*"\ncat\n', { mode: 0o755 }); + const input = JSON.stringify({ hook_event_name: "WorktreeCreate", cwd: "/repo", name: "agent-1" }); + const present = run(`${bin}:${process.env.PATH}`, input); + expect(present.status).toBe(0); + expect(present.stdout).toBe(`quest worktree hook\n${input}`); + }); });