From e73575436120449df33a159b52ce1e3b866b8db5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 22:57:36 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m0/user-dev-shell/init-hooks.md Quest-Claim: 579425610148c647 From 5d89ce2a40ebf66a77dfde181f3a5b2f92288c85 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 23:06:46 -0800 Subject: [PATCH 2/8] feat(init)!: write no hook entries and remove Quest's from project settings `quest init` no longer adds Claude Code hook entries to `.claude/settings.json`; it removes the `quest worktree hook` entries earlier versions wrote, including wrappers that run Quest such as `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, and names each on stderr. `quest uninstall` removes both forms through the same match. The README and SETUP.md describe the hooks as a user-level opt-in with the entries to add. Co-Authored-By: Claude Opus 5.5 --- README.md | 30 ++++- SETUP.md | 30 +++-- cloudflare/GITHUB.md | 7 +- cloudflare/src/github/service.ts | 2 +- cloudflare/src/github/setup.ts | 3 +- cloudflare/src/projects.tsx | 3 +- cloudflare/test/setup.test.ts | 14 ++- quest/m0/session-checkouts.md | 9 +- quest/m0/user-dev-shell/README.md | 1 - quest/m0/user-dev-shell/init-hooks.md | 42 ------- quest/m0/worktree-routes.md | 4 +- quest/m2/hook-path-error.md | 13 +-- src/cli/main.ts | 19 +++- src/cli/setup.ts | 59 +++------- src/cli/worktree-hook.ts | 9 +- src/core/index.ts | 8 +- src/core/init.ts | 128 +++++++++++----------- test/cli.test.ts | 19 +++- test/init.test.ts | 151 +++++++++++++++++--------- 19 files changed, 291 insertions(+), 260 deletions(-) delete mode 100644 quest/m0/user-dev-shell/init-hooks.md diff --git a/README.md b/README.md index b5a59f7c..1c5299cc 100644 --- a/README.md +++ b/README.md @@ -1296,7 +1296,7 @@ The experimental `quest worktree` commands share one disk budget across this user's repositories and release a checkout's disk by deleting its declared scratch in place. They only operate on Git worktrees they create; use the owning application for Codex or other managed checkouts. Claude Code's -checkouts are Quest's own once `quest init` installs its hooks (see +checkouts are Quest's own once you opt into its hooks (see [Claude Code checkouts](#claude-code-checkouts)). Set a shared capacity and an explicit reservation in bytes, covering the @@ -1395,11 +1395,31 @@ stolen. This initial interface refuses unknown or changed checkout ownership. ### Claude Code checkouts -`quest init` adds hook entries to the repository's `.claude/settings.json` that -run `quest worktree hook` for Claude Code's `WorktreeCreate`, `WorktreeRemove`, -`SubagentStart`, and `SubagentStop` events, so `quest` must be on the `PATH` +Quest's hooks for Claude Code are an opt-in each user adds to their own +settings, `~/.claude/settings.json`, so they apply in every repository that +user opens and no repository forces them on its contributors. Merge these +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" }] }] + } +} +``` + +Only one hook can manage Claude's worktrees, so remove any other +`WorktreeCreate` or `WorktreeRemove` hook first. `quest` must be on the `PATH` Claude Code runs hooks with. For `claude --bg` and agent view, that is the -environment of the shell that started the session. The hook then creates every worktree Claude makes +environment of the shell that started the session. Earlier versions of +`quest init` wrote these entries to a repository's `.claude/settings.json`; +`quest init` and `quest uninstall` now remove them there, along with wrappers +that run Quest, such as `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. + +The hook then creates every worktree Claude makes in the repository: for `--worktree`, `EnterWorktree`, isolated subagents and workflow agents, background sessions, and `claude remote-control --spawn worktree`. Claude no longer copies `.worktreeinclude` files into them. diff --git a/SETUP.md b/SETUP.md index cdab05f7..854aa11c 100644 --- a/SETUP.md +++ b/SETUP.md @@ -48,10 +48,10 @@ without creating symbolic links (existing linked layouts continue to work), crea starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets `merge.quest.driver` in the clone's git config, so local merges resolve -concurrent edits to quest link lists. It adds hook entries running -`quest worktree hook` to `.claude/settings.json`, keeping the file's other -settings, so Quest creates and reclaims Claude Code's worktrees; Claude Code -then needs `quest` on its `PATH` to create any worktree in the repository. It +concurrent edits to quest link lists. It writes no hooks: it removes the +entries earlier versions added to `.claude/settings.json` that run +`quest worktree hook`, directly or through a wrapper, keeping the file's other +settings, and names each on stderr. It prints each path it changed and is safe to rerun. Rerun after an upgrade, it also rewrites the unmodified stubs whose description or setup link changed and removes the unmodified stubs of skills the new version no longer ships. @@ -59,6 +59,19 @@ removes the unmodified stubs of skills the new version no longer ships. If init refuses, it names the conflicting path. Report it and ask the user how to proceed; never delete or rename their files to get past it. +## Opt into worktree hooks + +Quest can create and reclaim Claude Code's worktrees, sharing one disk budget +across the user's repositories, through hooks in the user's own Claude Code +settings rather than the repository's, since repository settings apply to +every contributor. Offer them, explaining that they then apply in every +repository the user opens in Claude Code and need `quest` on the `PATH` +Claude Code runs hooks with. If init just removed entries, say they were +those hooks. With consent, merge the entries in +[Claude Code checkouts](README.md#claude-code-checkouts) into +`~/.claude/settings.json`, keeping its other settings. If it already has a +`WorktreeCreate` or `WorktreeRemove` hook, ask the user which to keep. + ## Adapt the instructions Read the root instructions with the new line in place: @@ -102,11 +115,14 @@ work or `/quest-import` to bring in open GitHub issues. 1. Run `quest skill export` and follow it, so active quests become GitHub issues. Skip this only if the user says so. 2. Run `quest uninstall`. It removes only unmodified stubs of any version, the - `Quests: ` line, Quest's hook entries, and the merge driver, and never - deletes the quest tree. + `Quests: ` line, Quest's hook entries in `.claude/settings.json`, and the + merge driver, and never deletes the quest tree. 3. Remove the pin added above: the mise entry, or the flake input and dev shell package. For an unpinned install, delete the `quest` binary the installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the - chosen Windows install directory). Remove any PATH entry you added. + chosen Windows install directory). Remove any PATH entry you added. If the + user opted into Quest's worktree hooks and Claude Code no longer finds + `quest`, remove those entries from `~/.claude/settings.json`, since without + it they stop Claude Code making worktrees. 4. Ask the user whether to delete `quest/`. Git history keeps the plans either way. diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index f0a8ef27..79be74b5 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -607,12 +607,13 @@ Submitting it opens the App's setup pull request, with no runner involved `quest init` applies to a checkout, plans it from the default branch's tree: the skill stubs this deployment's `quest` ships (rendered into `build/stubs.json` at build time), `quest/README.md` with the answer as its -Goal, the `Quests: ` line in the root agent instructions, the `.gitattributes` -line, and Claude Code's hook entries in `.claude/settings.json`. Heading +Goal, the `Quests: ` line in the root agent instructions, and the +`.gitattributes` line. Like `quest init`, it removes Quest's worktree hook +entries from `.claude/settings.json`, naming each in the body. Heading syntax in the answer is escaped, and the planned `quest/README.md` must pass `quest check`, so a goal that would fail it, such as one linking a missing quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill -directory that is not a Quest stub or invalid settings JSON, is named on the +directory that is not a Quest stub, is named on the page and nothing is written; kix never deletes or renames a repository's files. The plan does not follow symlinks, since a Git tree stores their targets as data: one on a planned path is refused the same way, pointing at running diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 31db0831..d9d6c1a8 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -1462,7 +1462,7 @@ export class GitHubService { mode: "100644" as const, })); const message = - "chore: set up Quest\n\nWrites what `quest init` installs: skill stubs, quest/README.md, the agent\ninstructions' Quests line, the merge driver's .gitattributes line, and\nClaude Code's worktree hooks.\n"; + "chore: set up Quest\n\nWrites what `quest init` installs: skill stubs, quest/README.md, the agent\ninstructions' Quests line, and the merge driver's .gitattributes line.\n"; // A retry builds on the default branch's files, but keeps kix's earlier commit as a parent, // so the branch only fast-forwards: a push since the check above makes it fail rather than vanish. const commit = diff --git a/cloudflare/src/github/setup.ts b/cloudflare/src/github/setup.ts index 0938c722..0f75d5e6 100644 --- a/cloudflare/src/github/setup.ts +++ b/cloudflare/src/github/setup.ts @@ -120,11 +120,12 @@ export function planCheck(changes: InitChange[], paths: Iterable): strin /** What each planned change is for, in the pull request body. */ function purpose(change: InitChange): string { const path = change.path; + if (change.hooks?.length) + return `removes Quest's worktree hooks (${change.hooks.map((hook) => `\`${hook.command}\` for ${hook.event}`).join(", ")}), which each user now opts into in their own Claude settings`; if (change.content === null) return "removes the stub of a skill Quest no longer ships"; if (path.endsWith("/SKILL.md")) return "a skill stub"; if (path === "quest/README.md") return "the quest tree's root, with the project's goal"; if (path === ".gitattributes") return "routes `quest/**` through the `quest` merge driver"; - if (path === ".claude/settings.json") return "Claude Code's worktree hooks, which run `quest worktree hook`"; return "points agents at `quest context`"; } diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 3a8d9b69..8b2ff71d 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -259,8 +259,7 @@ export function QuestSetupPage(props: {

This repository has no Quest files on its default branch yet. The App opens a pull request with what{" "} quest init writes: skill stubs, quest/README.md, one line in the agent - instructions, a .gitattributes line, and Claude Code's worktree hooks. Contributions - are enabled when you merge it. + instructions, and a .gitattributes line. Contributions are enabled when you merge it.

diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 9b933e44..22e1f1bf 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -2,9 +2,9 @@ // the setting-up state, and enabling once it merges, against the fake GitHub. import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { HOOK_COMMAND, REFERENCE_LINE } from "quest/core"; +import { REFERENCE_LINE } from "quest/core"; import { app } from "../src/index"; -import { STUBS, planCheck, setupGoal, treeCheckout } from "../src/github/setup"; +import { STUBS, planCheck, setupGoal, setupPull, treeCheckout } from "../src/github/setup"; import { type Pull, type Repo, type World, fakeGitHub, intercept } from "../dev/github"; import { privateKey } from "./github-key"; import { owner } from "./owners"; @@ -115,7 +115,7 @@ describe("the setup pull request", () => { expect(files.get("quest/README.md")).toBe("# Quests\n\n## Goal\n\nShip a fast parser.\n\\# Not a heading\n"); expect(files.get("AGENTS.md")).toBe(`# Agents\n\nRun the tests.\n\n${REFERENCE_LINE}\n`); expect(files.get(".gitattributes")).toBe("quest/** merge=quest\n"); - expect(files.get(".claude/settings.json")).toContain(HOOK_COMMAND); + expect(files.has(".claude/settings.json")).toBe(false); expect(await setup()).toMatchObject({ state: "open", pull: pull!.number, enrolledBy: user, pending: null }); expect(await enrolled()).toBeNull(); @@ -297,6 +297,14 @@ describe("the setup plan on GitHub", () => { expect(planCheck([readme(retired), { path: retired, content: null }], [retired])).not.toEqual([]); }); + it("names each Quest hook it removes", () => { + const hooks = [{ event: "WorktreeCreate", command: "quest worktree hook" }]; + const { body } = setupPull([{ path: ".claude/settings.json", content: null, hooks }], { flake: true }); + expect(body).toContain( + "- `.claude/settings.json`: removes Quest's worktree hooks (`quest worktree hook` for WorktreeCreate)", + ); + }); + it("refuses to follow a symlink", () => { const checkout = treeCheckout( [{ path: ".claude", type: "blob", mode: "120000", sha: "a".repeat(40) }], diff --git a/quest/m0/session-checkouts.md b/quest/m0/session-checkouts.md index 3c9cbce8..b3756c3e 100644 --- a/quest/m0/session-checkouts.md +++ b/quest/m0/session-checkouts.md @@ -24,10 +24,11 @@ Evidence, gathered 2026-11-02: Work: - Add `SessionEnd` and `SessionStart` entries to the opt-in user-level hook - setup the docs describe. Amended 2026-11-03 (maintainer, /quest-unblock): - `quest init` writes no hook entries anymore - ([quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md)), - so this quest no longer installs them through `init`. + setup: the JSON block in the README's "Claude Code checkouts" section, which + SETUP.md's "Opt into worktree hooks" step points to, and `HOOK_EVENTS` in + `src/cli/worktree-hook.ts`. Amended 2026-11-03 (maintainer, /quest-unblock): + `quest init` writes no hook entries anymore, so this quest no longer + installs them through `init`. - `SessionEnd` releases (never removes) the session's checkout; `SessionStart` claims it again on resume. `/clear` also fires `SessionEnd`, so release is idempotent and the next `SessionStart` diff --git a/quest/m0/user-dev-shell/README.md b/quest/m0/user-dev-shell/README.md index 441ea38c..e311ff92 100644 --- a/quest/m0/user-dev-shell/README.md +++ b/quest/m0/user-dev-shell/README.md @@ -73,5 +73,4 @@ moq-dev/moq's `.codex/hooks/direnv.sh`. ## Required -- [quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) - `quest init` stops writing hook entries and removes the ones it or a wrapper wrote; the docs describe the hooks as user-level opt-in - [Repositories drop dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - quest, moq, moq.pro, rind, and rust2 drop their dev-shell hooks, Quest worktree hooks, and `nix develop` instructions diff --git a/quest/m0/user-dev-shell/init-hooks.md b/quest/m0/user-dev-shell/init-hooks.md deleted file mode 100644 index e93d5158..00000000 --- a/quest/m0/user-dev-shell/init-hooks.md +++ /dev/null @@ -1,42 +0,0 @@ -# [S] quest init writes no hook entries - -## Goal - -`quest init` no longer writes Claude Code hook entries into a repository's -`.claude/settings.json`, and removes the `quest worktree hook` entries an -earlier version wrote, including ones a repository wrapped, such as -moq-dev/moq's `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. -`quest uninstall` removes both forms too. The README and SETUP.md describe the -worktree hooks as an opt-in a user adds to their own Claude settings, with -the entries to add. - -## Plan - -Decided 2026-11-03 (maintainer, /quest-unblock): repository config applies -to every contributor, so Quest's hooks become user-level opt-in; see the -[epic](/quest/m0/user-dev-shell/README.md). - -- Replaces the wrapped-hooks quest (moq-dev/moq, 2026-11-03), which widened - `isQuestHook` (`src/core/init.ts`) so `init` would accept wrapped hooks. - For removal, a command hook is Quest's only when it runs Quest: the plain - `quest worktree hook`, or a wrapper whose command ends in `worktree hook` - and names the `quest` executable or package as a word or flake attribute, - such as moq's `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. - Another tool's command that merely ends in `worktree hook` stays. -- `HOOK_EVENTS` and the hook plan in `src/core/init.ts` stop adding - entries; `init` reports each entry it removes. The enrollment setup PR - ([moq-dev/quest#431](https://github.com/moq-dev/quest/pull/431)) uses the - same plan, so it stops proposing hooks too. -- The `quest worktree hook` command stays, for users who opt in. -- Removing moq's four wrapped entries, whether `init` here or - [repo-config](/quest/m0/user-dev-shell/repo-config.md) does it first, ends - Quest's reclaim of Claude Code checkouts in moq. Nothing replaces it: the - maintainer's user-level setup (kixelated/dotfiles) loads only the dev - shell, and the epic's decision rejected moving Quest's hooks into the - maintainer's settings. So this quest doesn't wait on that setup, and the - same end state results in either order. A user who wants reclaim adds the - opt-in entries the docs describe. Any removal through the enrollment setup - PR shows in that PR's diff. -- Test `init` and `uninstall` on a repository with plain and wrapped - entries, and with other hooks beside them, which stay, including another - tool's command that ends in `worktree hook`. diff --git a/quest/m0/worktree-routes.md b/quest/m0/worktree-routes.md index 44ef6442..4787196f 100644 --- a/quest/m0/worktree-routes.md +++ b/quest/m0/worktree-routes.md @@ -24,8 +24,8 @@ agent can't do, so its README description comes from Claude Code's own code: since it records no Git root for one, so quest's lock stays in place. Check it from a scratch repository with the worktree hooks configured (the -opt-in user-level setup once -[quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) lands), with +opt-in user-level setup the README's "Claude Code checkouts" section +describes), with `QUEST_WORKTREE_HOME` isolated: start `claude remote-control --spawn worktree`, spawn a session from claude.ai, end it once without changes and once with an untracked file, and watch `quest worktree status`. Fix the README if it diff --git a/quest/m2/hook-path-error.md b/quest/m2/hook-path-error.md index e9b8ccd8..f64ec2da 100644 --- a/quest/m2/hook-path-error.md +++ b/quest/m2/hook-path-error.md @@ -17,14 +17,11 @@ Decided 2026-11-04 (maintainer, /quest-plan). 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. -- After [init-hooks](/quest/m0/user-dev-shell/init-hooks.md), the entries are - a user-level opt-in that the README and SETUP.md describe, so the guarded - entry is the documented one. `quest uninstall`'s match for Quest's own - hooks must still recognize it. +- 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 match `quest init` and `quest uninstall` use for + Quest's own hooks (`removeHooks` in `src/core/init.ts`) must still + recognize it. - 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. - -## Required - -- [quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) - the documented user-level hook entries this guard changes diff --git a/src/cli/main.ts b/src/cli/main.ts index 499d20a9..ae43a8b7 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -824,10 +824,9 @@ export function main(argv: string[], out: Output): number | Promise { .command("hook") .summary("Handle a Claude Code worktree or subagent hook event read on stdin") .description( - "Handle the Claude Code hook event whose JSON input arrives on stdin; `quest init` installs it in " + - ".claude/settings.json for " + + "Handle the Claude Code hook event whose JSON input arrives on stdin, for " + HOOK_EVENTS.join(", ") + - ".\n\n" + + ". Add it to your own Claude Code settings for those events to opt in, as the README describes.\n\n" + "WorktreeCreate checks out the remote's default branch as last fetched (origin/HEAD), as Claude does by " + "default, or the session's commit without one, as a quest worktree under the main checkout's .worktrees/, " + "initializes its submodules recursively, and prints its path. A name pr-, which Claude gives the worktree for --worktree '#' or a " + @@ -1211,16 +1210,24 @@ export function main(argv: string[], out: Output): number | Promise { program .command("init") .description( - "Install skill stubs, a quest root, agent pointers, Claude Code worktree hooks, and the merge driver for this repository", + "Install skill stubs, a quest root, agent pointers, and the merge driver for this repository, and remove " + + "the Claude Code worktree hooks earlier versions added to .claude/settings.json", ) .action(() => { const changes = init(root()); - for (const line of theme ? theme.init(changes) : changes) print(line); + for (const { hooks = [] } of changes) + for (const hook of hooks) + warn( + `removed the ${hook.event} hook \`${hook.command}\` from .claude/settings.json; ` + + "Quest's worktree hooks are now opt-in in your own Claude settings (see the README)", + ); + const paths = changes.map((change) => change.path); + for (const line of theme ? theme.init(paths) : paths) print(line); }); program .command("uninstall") - .description("Remove Quest stubs, markers, hooks, and the merge driver installed by `quest init`") + .description("Remove Quest stubs, markers, worktree hooks, and the merge driver from this repository") .action(() => { const changes = uninstall(root()); for (const line of theme ? theme.uninstall(changes) : changes) print(line); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 843e2217..07920728 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -22,29 +22,28 @@ import { ATTRIBUTES_LINE, CLAUDE_SETTINGS, type Checkout, - HOOK_EVENTS, + type InitChange, type Kind, REFERENCE_MARKER, SKILL_DIRS, installedStubs, - isQuestHook, lines, newline, - parseSettings, planInit, - renderSettings, + removeHooks, } from "../core"; import { gitOutput } from "./claims"; import { DRIVER, OLD_DRIVERS } from "./resolve"; import { SKILLS } from "./skills"; -export { ATTRIBUTES_LINE, HOOK_COMMAND, REFERENCE_LINE } from "../core"; +export { ATTRIBUTES_LINE, REFERENCE_LINE } from "../core"; /** - * Install Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. - * A conflict throws before anything is written, so a refused init leaves the repository as it was. + * Install Quest stubs and markers under `root`, and remove the hook entries earlier versions wrote. + * Returns the changes it made. A conflict throws before anything is written, so a refused init leaves + * the repository as it was. */ -export function init(root: string): string[] { +export function init(root: string): InitChange[] { const changes = planInit(checkout(root), SKILLS); for (const { path, content } of changes) { if (content === null) { @@ -56,7 +55,7 @@ export function init(root: string): string[] { } } register(root); - return changes.map((change) => change.path); + return changes; } /** The checkout under `root`, as the core's file plan reads it. */ @@ -123,7 +122,12 @@ function unregister(root: string): void { /** Remove Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. */ export function uninstall(root: string): string[] { const changes: string[] = []; - if (uninstallHooks(root)) changes.push(CLAUDE_SETTINGS); + const settings = isFile(join(root, CLAUDE_SETTINGS)) ? removeHooks(read(root, CLAUDE_SETTINGS)) : undefined; + if (settings !== undefined) { + if (settings.content === null) rmSync(join(root, CLAUDE_SETTINGS)); + else writeFileSync(join(root, CLAUDE_SETTINGS), settings.content); + changes.push(CLAUDE_SETTINGS); + } for (const skills of SKILL_DIRS) { if (!isRealDir(join(root, skills))) continue; for (const dir of installedStubs(checkout(root), skills)) changes.push(removeStub(root, dir)); @@ -218,38 +222,3 @@ function removeLine(root: string, path: string, marker: string): boolean { } return true; } - -/** - * Remove exactly Quest's hook handlers, then the groups, events, and hooks object they leave empty, - * deleting the file when nothing else remains. A file that is missing or malformed is left alone. - */ -function uninstallHooks(root: string): boolean { - let parsed; - try { - parsed = isFile(join(root, CLAUDE_SETTINGS)) ? parseSettings(read(root, CLAUDE_SETTINGS)) : undefined; - } catch { - return false; - } - if (!parsed?.settings.hooks) return false; - const { settings, indent, eol } = parsed; - const hooks = settings.hooks!; - let changed = false; - for (const event of HOOK_EVENTS) { - const groups = hooks[event]; - if (!groups) continue; - const kept = groups.flatMap((group) => { - const handlers = group.hooks ?? []; - const others = handlers.filter((handler) => !isQuestHook(handler)); - if (others.length === handlers.length) return [group]; - changed = true; - return others.length ? [{ ...group, hooks: others }] : []; - }); - if (kept.length) hooks[event] = kept; - else delete hooks[event]; - } - if (!changed) return false; - if (Object.keys(hooks).length === 0) delete settings.hooks; - if (Object.keys(settings).length === 0) rmSync(join(root, CLAUDE_SETTINGS)); - else writeFileSync(join(root, CLAUDE_SETTINGS), renderSettings(settings, indent, eol)); - return true; -} diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 81f4f95f..209c3b78 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -1,5 +1,6 @@ -// Claude Code's worktree lifecycle hooks, backed by `quest worktree`. `quest init` installs one -// static entry per event, each calling `quest worktree hook`, so the logic upgrades with the binary. +// Claude Code's worktree lifecycle hooks, backed by `quest worktree`. A user opts in with one static +// entry per event in their own Claude settings, each calling `quest worktree hook`, so the logic +// upgrades with the binary. import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; @@ -15,8 +16,8 @@ import { mainCheckout, } from "./worktree"; -// The events this handles are the core's HOOK_EVENTS, which `quest init` installs. -export { HOOK_EVENTS } from "../core"; +/** The Claude Code events `quest worktree hook` handles. */ +export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; /** * The guide's workspace, which holds an agent's reports and logs. Its parent reads them after it stops, diff --git a/src/core/index.ts b/src/core/index.ts index ca161bd0..e7715360 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -36,25 +36,21 @@ export { ATTRIBUTES_LINE, CLAUDE_SETTINGS, type Checkout, - HOOK_COMMAND, - HOOK_EVENTS, type InitChange, InitRefused, type Kind, PREFIX, + type QuestHook, REFERENCE_LINE, REFERENCE_MARKER, SKILL_DIRS, - type Settings, type Stub, installedStubs, - isQuestHook, lines, newline, - parseSettings, planInit, questReadme, - renderSettings, + removeHooks, } from "./init"; export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge"; diff --git a/src/core/init.ts b/src/core/init.ts index 98996f24..238253ab 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -6,7 +6,9 @@ // The plan touches only skill stubs (writing missing or outdated ones and // removing those of retired skills), one reference line in the root agent // instructions, one `.gitattributes` line, `quest/README.md` when missing, and -// Quest's hook entries in `.claude/settings.json`. +// the hook entries earlier versions wrote to `.claude/settings.json`, which it +// removes: Quest's worktree hooks are a user-level opt-in, since a +// repository's settings would force them on every contributor. /** Installed skills carry this prefix so they don't collide with a repository's own. */ export const PREFIX = "quest-"; @@ -32,18 +34,9 @@ export const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; /** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ export const ATTRIBUTES_LINE = "quest/** merge=quest"; -/** Claude Code's shared project settings, where init installs the worktree lifecycle hooks. */ +/** Claude Code's shared project settings, where earlier versions installed the worktree hooks. */ export const CLAUDE_SETTINGS = ".claude/settings.json"; -/** The one command each installed hook entry runs; the logic lives in the binary, so upgrades never rewrite it. */ -export const HOOK_COMMAND = "quest worktree hook"; - -/** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ -export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; - -/** Events whose single hook decides the outcome, so another hook for them conflicts with Quest's. */ -const EXCLUSIVE_EVENTS = new Set(["WorktreeCreate", "WorktreeRemove"]); - /** The quest tree's root, with the project's goal or a placeholder for it. */ export function questReadme(goal?: string): string { return `# Quests\n\n## Goal\n\n${goal?.trim() || "What this project is working toward."}\n`; @@ -76,10 +69,15 @@ export interface Stub { stub: string; } -/** One file the plan sets to `content`, or, with null content, a retired stub it deletes along with its directory once empty. */ +/** + * One file the plan sets to `content`, or, with null content, deletes along with its directory once + * empty: a retired stub, or settings left empty once Quest's hooks are removed. + */ export interface InitChange { path: string; content: string | null; + /** The Quest hook entries removed from Claude Code's settings by this change. */ + hooks?: QuestHook[]; } /** Init cannot install because of what sits at `path`; the repository needs a person's judgement. */ @@ -136,12 +134,11 @@ export function planInit(repo: Checkout, stubs: readonly Stub[], options: { goal const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => repo.stat(name) === "file") ?? "AGENTS.md"; checkPath(repo, instructions, "file"); checkPath(repo, ".gitattributes", "file"); - checkPath(repo, CLAUDE_SETTINGS, "file"); - const settings = installHooks(repo); + const settings = repo.stat(CLAUDE_SETTINGS) === "file" ? removeHooks(repo.read(CLAUDE_SETTINGS)) : undefined; const changes: InitChange[] = [...written, ...retired.map((dir) => ({ path: `${dir}/SKILL.md`, content: null }))]; if (repo.lstat(readme) === null) changes.push({ path: readme, content: questReadme(options.goal) }); - if (settings !== undefined) changes.push({ path: CLAUDE_SETTINGS, content: settings }); + if (settings !== undefined) changes.push({ path: CLAUDE_SETTINGS, ...settings }); for (const [path, marker, line, old] of [ [instructions, REFERENCE_MARKER, REFERENCE_LINE, OLD_REFERENCE_LINES], [".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE, []], @@ -241,70 +238,67 @@ function appendLine(content: string, marker: string, line: string, old: readonly return `${content}${line}${eol}`; } -/** A settings object as Claude Code reads it: hook groups per event, each with handlers. */ -export type Settings = { hooks?: Record } & Record; +/** A hook entry that runs Quest: the event it handles and its command. */ +export interface QuestHook { + event: string; + command: string; +} const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -export function isQuestHook(handler: unknown): boolean { - return record(handler) && handler.type === "command" && handler.command === HOOK_COMMAND; +/** + * The command of a hook handler that runs `quest worktree hook`, directly or through a wrapper that names + * the `quest` executable, package, or flake attribute as a word, such as + * `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. Another tool's command that merely ends in + * `worktree hook` is not Quest's. + */ +function questCommand(handler: unknown): string | undefined { + if (!record(handler) || handler.type !== "command" || typeof handler.command !== "string") return undefined; + const command = handler.command.trim(); + const runner = /^(.*?)(?:^|\s)worktree hook$/.exec(command)?.[1]; + return runner !== undefined && /(?:^|[\s/#"'])quest(?=$|[\s"'@])/.test(runner) ? command : undefined; } /** - * Claude Code's settings file parsed and checked for the shape init edits, - * with its formatting. Throws an `InitRefused` naming the file when it is not. + * Claude Code settings `text` without Quest's hook handlers, nor the groups, events, and hooks object they + * leave empty, along with the handlers removed. Its content is null when nothing else remains, and the + * result undefined when there is nothing to remove or the settings are malformed, which Claude Code + * rejects whole, so their hooks never run. */ -export function parseSettings(text: string): { settings: Settings; indent: string; eol: string } { - const invalid = (why: string) => - new InitRefused( - CLAUDE_SETTINGS, - `${CLAUDE_SETTINGS} ${why}; fix it before running \`quest init\` or \`quest uninstall\``, - ); +export function removeHooks(text: string): { content: string | null; hooks: QuestHook[] } | undefined { let settings: unknown; try { settings = JSON.parse(text); } catch { - throw invalid("is not valid JSON"); + return undefined; } - if (!record(settings)) throw invalid("is not a JSON object"); - if (settings.hooks !== undefined) { - if (!record(settings.hooks)) throw invalid("has hooks that are not an object"); - for (const [event, groups] of Object.entries(settings.hooks)) - if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) - throw invalid(`has ${event} hooks that are not a list of hook groups`); - } - return { settings: settings as Settings, indent: /\n([ \t]+)"/.exec(text)?.[1] ?? " ", eol: newline(text) }; -} - -/** The settings file's text, in the formatting `parseSettings` read. */ -export function renderSettings(settings: Settings, indent: string, eol: string): string { - return `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`; -} - -/** - * The settings file with one Quest hook group added for each event that lacks one, or undefined when - * nothing changes. Another hook for an event whose single hook decides the outcome is a conflict. - */ -function installHooks(repo: Checkout): string | undefined { - const { settings, indent, eol } = - repo.stat(CLAUDE_SETTINGS) === "file" - ? parseSettings(repo.read(CLAUDE_SETTINGS)) - : { settings: {} as Settings, indent: " ", eol: "\n" }; - const hooks = (settings.hooks ??= {}); - let changed = false; - for (const event of HOOK_EVENTS) { - const groups = (hooks[event] ??= []); - const handlers = groups.flatMap((group) => group.hooks ?? []); - if (EXCLUSIVE_EVENTS.has(event) && handlers.some((handler) => !isQuestHook(handler))) - throw new InitRefused( - CLAUDE_SETTINGS, - `${CLAUDE_SETTINGS} already has a ${event} hook, and only one can manage Claude's worktrees; ` + - "remove it before running `quest init`", - ); - if (handlers.some(isQuestHook)) continue; - groups.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] }); - changed = true; + if (!record(settings) || !record(settings.hooks)) return undefined; + const removed: QuestHook[] = []; + const kept: [string, unknown][] = []; + for (const [event, groups] of Object.entries(settings.hooks)) { + if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) + return undefined; + const before = removed.length; + const remaining = (groups as { hooks?: unknown[] }[]).flatMap((group) => { + const handlers = group.hooks ?? []; + const others = handlers.filter((handler) => { + const command = questCommand(handler); + if (command !== undefined) removed.push({ event, command }); + return command === undefined; + }); + if (others.length === handlers.length) return [group]; + return others.length ? [{ ...group, hooks: others }] : []; + }); + if (removed.length === before) kept.push([event, groups]); + else if (remaining.length) kept.push([event, remaining]); } - return changed ? renderSettings(settings, indent, eol) : undefined; + if (!removed.length) return undefined; + // Rebuilt rather than edited, so an event named `__proto__` stays an ordinary key. + if (kept.length) settings.hooks = Object.fromEntries(kept); + else delete settings.hooks; + if (Object.keys(settings).length === 0) return { content: null, hooks: removed }; + const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; + const eol = newline(text); + return { content: `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`, hooks: removed }; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 3f9a34a1..9a749124 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,6 @@ // The command line's own contract: output streams, exit codes, and arguments. -import { writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -87,12 +87,27 @@ test("init and uninstall print each changed path and nothing else", () => { const init = run("--root", root, "init"); expect(init.code).toBe(0); expect(init.stdout).toMatch(/^(\S+\n)+$/); - expect(init.stdout).toContain("quest/README.md\n.claude/settings.json\nAGENTS.md\n"); + expect(init.stdout).toContain("quest/README.md\nAGENTS.md\n"); + expect(init.stderr).toBe(""); expect(run("--root", root, "init").stdout).toBe(""); expect(run("--root", root, "uninstall").stdout).toMatch(/^(\S+\n)+$/); expect(run("--root", root, "uninstall").stdout).toBe(""); }); +test("init names each hook entry it removes on stderr", () => { + const root = tempDir(); + mkdirSync(join(root, ".claude")); + const hook = { hooks: [{ type: "command", command: 'nix run ".#quest" -- worktree hook' }] }; + writeFileSync(join(root, ".claude/settings.json"), JSON.stringify({ hooks: { WorktreeCreate: [hook] } })); + const init = run("--root", root, "init"); + expect(init.code).toBe(0); + expect(init.stdout).toContain(".claude/settings.json\n"); + expect(init.stderr).toBe( + 'quest: removed the WorktreeCreate hook `nix run ".#quest" -- worktree hook` from .claude/settings.json; ' + + "Quest's worktree hooks are now opt-in in your own Claude settings (see the README)\n", + ); +}); + test("version", () => { expect(run("--version")).toEqual({ code: 0, stdout: `quest ${version}\n`, stderr: "" }); }); diff --git a/test/init.test.ts b/test/init.test.ts index eee7b651..0106e9ce 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,10 +14,12 @@ import { dirname, join } from "node:path"; import { expect, test } from "vitest"; -import { HOOK_COMMAND, REFERENCE_LINE, init, uninstall } from "../src/cli/setup"; +import { REFERENCE_LINE, init as apply, uninstall } from "../src/cli/setup"; import { SKILLS } from "../src/cli/skills"; import { tempDir } from "./fixture"; +/** The paths `quest init` changed under `dir`. */ +const init = (dir: string) => apply(dir).map((change) => change.path); const read = (dir: string, path: string) => readFileSync(join(dir, path), "utf8"); const isFile = (dir: string, path: string) => existsSync(join(dir, path)) && lstatSync(join(dir, path)).isFile(); const isSymlink = (dir: string, path: string) => { @@ -379,66 +381,113 @@ test("uninstall removes stubs that older versions installed", () => { expect(existsSync(join(dir, ".agents"))).toBe(false); }); -const QUEST_HOOK = { type: "command", command: HOOK_COMMAND }; -const SESSION_HOOK = { hooks: [{ type: "command", command: "bash .claude/hooks/direnv.sh" }] }; +const handler = (command: string) => ({ type: "command", command }); +const group = (command: string) => ({ hooks: [handler(command)] }); +const QUEST = "quest worktree hook"; +const WRAPPED = 'nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook'; +const SESSION = group("bash .claude/hooks/direnv.sh"); -test("init installs Quest's worktree hooks in Claude's project settings, and uninstall removes them", () => { +function settings(dir: string, value: unknown, indent: string | number = "\t"): string { + mkdirSync(join(dir, ".claude"), { recursive: true }); + const text = `${JSON.stringify(value, null, indent)}\n`; + writeFileSync(join(dir, ".claude/settings.json"), text); + return text; +} + +test("init writes no hooks", () => { const dir = tempDir(); - init(dir); - const settings = JSON.parse(read(dir, ".claude/settings.json")); - expect(Object.keys(settings.hooks)).toEqual(["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"]); - for (const groups of Object.values(settings.hooks)) expect(groups).toEqual([{ hooks: [QUEST_HOOK] }]); - uninstall(dir); - expect(existsSync(join(dir, ".claude"))).toBe(false); + expect(init(dir)).not.toContain(".claude/settings.json"); + expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); }); -test("init merges hooks into existing settings, keeping their formatting, and uninstall restores them", () => { +test("init removes the hooks an earlier version wrote, plain or wrapped, and the file they leave empty", () => { const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - const original = { - $schema: "https://json.schemastore.org/claude-code-settings.json", - hooks: { SessionStart: [SESSION_HOOK], SubagentStop: [SESSION_HOOK] }, + settings(dir, { + hooks: { + WorktreeCreate: [group(QUEST)], + WorktreeRemove: [group(WRAPPED)], + SubagentStart: [group("npx @moq-dev/quest@latest worktree hook")], + SubagentStop: [group("/usr/local/bin/quest worktree hook ")], + }, + }); + const change = apply(dir).find((change) => change.path === ".claude/settings.json"); + expect(change?.hooks).toEqual([ + { event: "WorktreeCreate", command: QUEST }, + { event: "WorktreeRemove", command: WRAPPED }, + { event: "SubagentStart", command: "npx @moq-dev/quest@latest worktree hook" }, + { event: "SubagentStop", command: "/usr/local/bin/quest worktree hook" }, + ]); + expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); + expect(isFile(dir, ".claude/skills/quest-start/SKILL.md")).toBe(true); + expect(init(dir)).toEqual([]); +}); + +test("init and uninstall keep other hooks and settings, and the file's formatting", () => { + const others = { + SessionStart: [SESSION], + PreCompact: [], + // Another tool's commands that end in `worktree hook` without running Quest. + WorktreeCreate: [group("othertool worktree hook")], + WorktreeRemove: [group("my-quest worktree hook"), group("tool --quest-mode worktree hook")], + SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], }; - const text = `${JSON.stringify(original, null, "\t")}\n`; - writeFileSync(join(dir, ".claude/settings.json"), text); - expect(init(dir)).toContain(".claude/settings.json"); - const merged = read(dir, ".claude/settings.json"); - expect(merged).toContain('\n\t"hooks"'); - const settings = JSON.parse(merged); - expect(settings.hooks.SessionStart).toEqual([SESSION_HOOK]); - expect(settings.hooks.SubagentStop).toEqual([SESSION_HOOK, { hooks: [QUEST_HOOK] }]); - expect(init(dir)).not.toContain(".claude/settings.json"); - expect(uninstall(dir)).toContain(".claude/settings.json"); - expect(read(dir, ".claude/settings.json")).toBe(text); + const kept = { $schema: "https://json.schemastore.org/claude-code-settings.json", hooks: others }; + for (const remove of [init, uninstall]) { + const dir = tempDir(); + settings(dir, { + $schema: kept.$schema, + hooks: { + SessionStart: [SESSION], + PreCompact: [], + WorktreeCreate: [group("othertool worktree hook")], + WorktreeRemove: [group("my-quest worktree hook"), group("tool --quest-mode worktree hook")], + SubagentStart: [group(WRAPPED)], + SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], + }, + }); + expect(remove(dir)).toContain(".claude/settings.json"); + const text = read(dir, ".claude/settings.json"); + expect(text).toBe(`${JSON.stringify(kept, null, "\t")}\n`); + expect(remove(dir)).not.toContain(".claude/settings.json"); + } +}); + +test("init and uninstall leave settings without Quest's hooks untouched", () => { + for (const content of [ + "{", + "[]", + JSON.stringify({ hooks: { SubagentStop: {} } }), + JSON.stringify({ hooks: { SubagentStop: [group(QUEST)], SessionStart: "x" } }), + '{ "hooks": { "SessionStart": [] } }\r\n', + ]) { + for (const remove of [init, uninstall]) { + const dir = tempDir(); + mkdirSync(join(dir, ".claude")); + writeFileSync(join(dir, ".claude/settings.json"), content); + expect(remove(dir)).not.toContain(".claude/settings.json"); + expect(read(dir, ".claude/settings.json")).toBe(content); + } + } }); -test("uninstall removes only Quest's handler from a group it shares", () => { +test("removing hooks keeps an event named __proto__ as an ordinary key", () => { const dir = tempDir(); mkdirSync(join(dir, ".claude")); - const shared = { matcher: "", hooks: [QUEST_HOOK, SESSION_HOOK.hooks[0]] }; - writeFileSync(join(dir, ".claude/settings.json"), JSON.stringify({ hooks: { SubagentStart: [shared] } })); - init(dir); + const proto = `"__proto__": [${JSON.stringify(SESSION)}]`; + writeFileSync( + join(dir, ".claude/settings.json"), + `{"hooks": {${proto}, "WorktreeCreate": [${JSON.stringify(group(QUEST))}]}}`, + ); uninstall(dir); - expect(JSON.parse(read(dir, ".claude/settings.json"))).toEqual({ - hooks: { SubagentStart: [{ matcher: "", hooks: SESSION_HOOK.hooks }] }, - }); + const hooks = JSON.parse(read(dir, ".claude/settings.json")).hooks; + expect(Object.keys(hooks)).toEqual(["__proto__"]); + expect(Object.getOwnPropertyDescriptor(hooks, "__proto__")?.value).toEqual([SESSION]); }); -for (const [why, content, error] of [ - [ - "another WorktreeCreate hook", - JSON.stringify({ hooks: { WorktreeCreate: [SESSION_HOOK] } }), - "already has a WorktreeCreate hook", - ], - ["invalid JSON", "{", "is not valid JSON"], - ["hooks that are not hook groups", JSON.stringify({ hooks: { SubagentStop: {} } }), "not a list of hook groups"], -] as const) { - test(`init refuses settings with ${why} and writes nothing`, () => { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - writeFileSync(join(dir, ".claude/settings.json"), content); - const before = snapshot(dir); - expect(() => init(dir)).toThrow(error); - expect(snapshot(dir)).toEqual(before); - }); -} +test("removing hooks keeps CRLF line endings and indentation", () => { + const dir = tempDir(); + const text = settings(dir, { model: "x", hooks: { WorktreeCreate: [group(QUEST)] } }, 4).replaceAll("\n", "\r\n"); + writeFileSync(join(dir, ".claude/settings.json"), text); + init(dir); + expect(read(dir, ".claude/settings.json")).toBe('{\r\n "model": "x"\r\n}\r\n'); +}); From ca73c912a4437bd05d3dde29f64328798e50948f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 23:23:02 -0800 Subject: [PATCH 3/8] fix(init): match only the command Quest's hook invokes Co-Authored-By: Claude Opus 5.5 --- src/core/init.ts | 14 ++++++++------ test/init.test.ts | 22 +++++++++++++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 238253ab..a9eadc26 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -248,16 +248,18 @@ const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** - * The command of a hook handler that runs `quest worktree hook`, directly or through a wrapper that names - * the `quest` executable, package, or flake attribute as a word, such as - * `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. Another tool's command that merely ends in - * `worktree hook` is not Quest's. + * A command that runs `quest worktree hook`: the word before `worktree hook`, past an optional `--`, is + * the `quest` executable, package, or flake attribute, as in `/usr/local/bin/quest worktree hook`, + * `npx @moq-dev/quest@latest worktree hook`, or `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. + * Another tool's command that merely ends in `worktree hook` is not Quest's. */ +const QUEST_COMMAND = /(?:^|\s)["']?(?:[^\s=]*[/#])?quest(?:[@?][^\s"']*)?["']?(?:\s+--)?\s+worktree hook$/; + +/** The trimmed command of a hook handler that runs Quest, or undefined for any other handler. */ function questCommand(handler: unknown): string | undefined { if (!record(handler) || handler.type !== "command" || typeof handler.command !== "string") return undefined; const command = handler.command.trim(); - const runner = /^(.*?)(?:^|\s)worktree hook$/.exec(command)?.[1]; - return runner !== undefined && /(?:^|[\s/#"'])quest(?=$|[\s"'@])/.test(runner) ? command : undefined; + return QUEST_COMMAND.test(command) ? command : undefined; } /** diff --git a/test/init.test.ts b/test/init.test.ts index 0106e9ce..596ce4b4 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -407,7 +407,11 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the WorktreeCreate: [group(QUEST)], WorktreeRemove: [group(WRAPPED)], SubagentStart: [group("npx @moq-dev/quest@latest worktree hook")], - SubagentStop: [group("/usr/local/bin/quest worktree hook ")], + SubagentStop: [ + group("/usr/local/bin/quest worktree hook "), + group("nix run github:moq-dev/quest -- worktree hook"), + group("bun run --silent quest worktree hook"), + ], }, }); const change = apply(dir).find((change) => change.path === ".claude/settings.json"); @@ -416,6 +420,8 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the { event: "WorktreeRemove", command: WRAPPED }, { event: "SubagentStart", command: "npx @moq-dev/quest@latest worktree hook" }, { event: "SubagentStop", command: "/usr/local/bin/quest worktree hook" }, + { event: "SubagentStop", command: "nix run github:moq-dev/quest -- worktree hook" }, + { event: "SubagentStop", command: "bun run --silent quest worktree hook" }, ]); expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); expect(isFile(dir, ".claude/skills/quest-start/SKILL.md")).toBe(true); @@ -428,7 +434,12 @@ test("init and uninstall keep other hooks and settings, and the file's formattin PreCompact: [], // Another tool's commands that end in `worktree hook` without running Quest. WorktreeCreate: [group("othertool worktree hook")], - WorktreeRemove: [group("my-quest worktree hook"), group("tool --quest-mode worktree hook")], + WorktreeRemove: [ + group("my-quest worktree hook"), + group("tool --quest-mode worktree hook"), + group("cd quest && othertool worktree hook"), + group("othertool --dir=/quest worktree hook"), + ], SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], }; const kept = { $schema: "https://json.schemastore.org/claude-code-settings.json", hooks: others }; @@ -440,7 +451,12 @@ test("init and uninstall keep other hooks and settings, and the file's formattin SessionStart: [SESSION], PreCompact: [], WorktreeCreate: [group("othertool worktree hook")], - WorktreeRemove: [group("my-quest worktree hook"), group("tool --quest-mode worktree hook")], + WorktreeRemove: [ + group("my-quest worktree hook"), + group("tool --quest-mode worktree hook"), + group("cd quest && othertool worktree hook"), + group("othertool --dir=/quest worktree hook"), + ], SubagentStart: [group(WRAPPED)], SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], }, From 54bc125f601204a6f905e70e0598293548630149 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 23:27:21 -0800 Subject: [PATCH 4/8] fix(init): require quest in command position to match its hook Co-Authored-By: Claude Opus 5.5 --- src/core/init.ts | 14 +++++++++----- test/init.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index a9eadc26..92e773e1 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -248,12 +248,16 @@ const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** - * A command that runs `quest worktree hook`: the word before `worktree hook`, past an optional `--`, is - * the `quest` executable, package, or flake attribute, as in `/usr/local/bin/quest worktree hook`, - * `npx @moq-dev/quest@latest worktree hook`, or `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. - * Another tool's command that merely ends in `worktree hook` is not Quest's. + * A command that runs `quest worktree hook`: the `quest` executable, package, or flake attribute, past an + * optional `--`, runs `worktree hook` either as the command itself, at the start of a shell segment after + * any variable assignments, or as what a launcher runs (`nix run`, `nix develop -c`, `bun run`, `npx`, + * `bunx`, `exec`, ...) with only flags between. That covers `/usr/local/bin/quest worktree hook`, + * `npx @moq-dev/quest@latest worktree hook`, and `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, + * while another tool given `quest` as an argument, as in `othertool --project quest worktree hook`, is + * not Quest's. */ -const QUEST_COMMAND = /(?:^|\s)["']?(?:[^\s=]*[/#])?quest(?:[@?][^\s"']*)?["']?(?:\s+--)?\s+worktree hook$/; +const QUEST_COMMAND = + /(?:(?:^|[;&|(]\s*)(?:\w+=\S*\s+)*|(?:^|\s)(?:run|exec|x|dlx|npx|bunx|pnpx|-c|--command)(?:\s+--?[\w-]+)*\s+)["']?(?:[^\s=]*[/#])?quest(?:[@?][^\s"']*)?["']?(?:\s+--)?\s+worktree hook$/; /** The trimmed command of a hook handler that runs Quest, or undefined for any other handler. */ function questCommand(handler: unknown): string | undefined { diff --git a/test/init.test.ts b/test/init.test.ts index 596ce4b4..4d1f218d 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -411,6 +411,8 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the group("/usr/local/bin/quest worktree hook "), group("nix run github:moq-dev/quest -- worktree hook"), group("bun run --silent quest worktree hook"), + group("nix develop --impure -c quest worktree hook"), + group("command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook"), ], }, }); @@ -422,6 +424,8 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the { event: "SubagentStop", command: "/usr/local/bin/quest worktree hook" }, { event: "SubagentStop", command: "nix run github:moq-dev/quest -- worktree hook" }, { event: "SubagentStop", command: "bun run --silent quest worktree hook" }, + { event: "SubagentStop", command: "nix develop --impure -c quest worktree hook" }, + { event: "SubagentStop", command: "command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook" }, ]); expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); expect(isFile(dir, ".claude/skills/quest-start/SKILL.md")).toBe(true); @@ -439,6 +443,8 @@ test("init and uninstall keep other hooks and settings, and the file's formattin group("tool --quest-mode worktree hook"), group("cd quest && othertool worktree hook"), group("othertool --dir=/quest worktree hook"), + group("othertool --dir /quest worktree hook"), + group("othertool --project quest worktree hook"), ], SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], }; @@ -456,6 +462,8 @@ test("init and uninstall keep other hooks and settings, and the file's formattin group("tool --quest-mode worktree hook"), group("cd quest && othertool worktree hook"), group("othertool --dir=/quest worktree hook"), + group("othertool --dir /quest worktree hook"), + group("othertool --project quest worktree hook"), ], SubagentStart: [group(WRAPPED)], SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], From 139255d0dd41f87bb25abe65146b3d399fe56adf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 23:31:53 -0800 Subject: [PATCH 5/8] fix(init): anchor Quest's hook match on recognized launchers Co-Authored-By: Claude Opus 5.5 --- src/core/init.ts | 31 ++++++++++++++++++++++--------- test/init.test.ts | 6 ++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 92e773e1..4d91ba40 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -248,16 +248,29 @@ const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** - * A command that runs `quest worktree hook`: the `quest` executable, package, or flake attribute, past an - * optional `--`, runs `worktree hook` either as the command itself, at the start of a shell segment after - * any variable assignments, or as what a launcher runs (`nix run`, `nix develop -c`, `bun run`, `npx`, - * `bunx`, `exec`, ...) with only flags between. That covers `/usr/local/bin/quest worktree hook`, - * `npx @moq-dev/quest@latest worktree hook`, and `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, - * while another tool given `quest` as an argument, as in `othertool --project quest worktree hook`, is - * not Quest's. + * A command that runs `quest worktree hook`. A shell segment, past any variable assignments and an `exec` + * or `env`, starts the `quest` executable directly or through a launcher that names it: `nix run` of a + * flake attribute or reference ending in `quest`, `nix develop` or `nix shell` with `-c quest`, a package + * runner such as `npx @moq-dev/quest@latest`, `bun run quest`, or `mise exec` or `direnv exec` running + * `quest`. Another tool given `quest` as an argument, as in `othertool --project quest worktree hook`, + * is not Quest's. */ -const QUEST_COMMAND = - /(?:(?:^|[;&|(]\s*)(?:\w+=\S*\s+)*|(?:^|\s)(?:run|exec|x|dlx|npx|bunx|pnpx|-c|--command)(?:\s+--?[\w-]+)*\s+)["']?(?:[^\s=]*[/#])?quest(?:[@?][^\s"']*)?["']?(?:\s+--)?\s+worktree hook$/; +const QUEST_COMMAND = (() => { + const quoted = (body: string) => String.raw`["']?${body}["']?`; + const executable = quoted(String.raw`(?:[^\s"'=;&|]*/)?quest`); + const flags = String.raw`(?:\s+-\S*)*`; + const args = String.raw`(?:\s+[^\s;&|]+)*?`; + const launch = [ + executable, + String.raw`nix${flags}\s+run${flags}\s+${quoted(String.raw`[^\s"']*[#/]quest(?:\?[^\s"']*)?`)}`, + String.raw`nix${flags}\s+(?:develop|shell)${args}\s+(?:-c|--command)\s+${executable}`, + String.raw`(?:npx|bunx|pnpx|bun\s+x|pnpm\s+dlx|(?:npm|pnpm)\s+exec)${flags}\s+${quoted(String.raw`(?:[^\s"'=;&|]*/)?quest(?:@[^\s"']*)?`)}`, + String.raw`bun\s+run${flags}\s+${executable}`, + String.raw`(?:mise\s+(?:exec|x)|direnv\s+exec)${args}\s+${executable}`, + ].join("|"); + const prefix = String.raw`(?:^|[;&|(]\s*)(?:\w+=\S*\s+)*(?:(?:exec|env)\s+(?:\w+=\S*\s+)*)?`; + return new RegExp(String.raw`${prefix}(?:${launch})\s+(?:--\s+)?worktree hook$`); +})(); /** The trimmed command of a hook handler that runs Quest, or undefined for any other handler. */ function questCommand(handler: unknown): string | undefined { diff --git a/test/init.test.ts b/test/init.test.ts index 4d1f218d..0c812fdf 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -412,6 +412,7 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the group("nix run github:moq-dev/quest -- worktree hook"), group("bun run --silent quest worktree hook"), group("nix develop --impure -c quest worktree hook"), + group("mise exec -- quest worktree hook"), group("command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook"), ], }, @@ -425,6 +426,7 @@ test("init removes the hooks an earlier version wrote, plain or wrapped, and the { event: "SubagentStop", command: "nix run github:moq-dev/quest -- worktree hook" }, { event: "SubagentStop", command: "bun run --silent quest worktree hook" }, { event: "SubagentStop", command: "nix develop --impure -c quest worktree hook" }, + { event: "SubagentStop", command: "mise exec -- quest worktree hook" }, { event: "SubagentStop", command: "command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook" }, ]); expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); @@ -445,6 +447,8 @@ test("init and uninstall keep other hooks and settings, and the file's formattin group("othertool --dir=/quest worktree hook"), group("othertool --dir /quest worktree hook"), group("othertool --project quest worktree hook"), + group("othertool -c /quest worktree hook"), + group("echo run quest worktree hook"), ], SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], }; @@ -464,6 +468,8 @@ test("init and uninstall keep other hooks and settings, and the file's formattin group("othertool --dir=/quest worktree hook"), group("othertool --dir /quest worktree hook"), group("othertool --project quest worktree hook"), + group("othertool -c /quest worktree hook"), + group("echo run quest worktree hook"), ], SubagentStart: [group(WRAPPED)], SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], From 8d8d2fc0d006b6e828d4ce6e05aa02206b6f6f51 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 08:40:06 -0800 Subject: [PATCH 6/8] fix(init)!: remove only exact quest worktree hook entries and warn on lookalikes The maintainer rejected the heuristic matcher (round-4 design checkpoint): Quest only ever wrote the exact `quest worktree hook`, so init and uninstall now delete only that command, on any event. Any other command hook ending in `worktree hook`, such as moq-dev/moq's `nix run ... -- worktree hook`, may be another tool's, so it stays and gets a stderr warning naming the event, command, and file; the setup PR lists it under "Left out". Drops QUEST_COMMAND and its launcher grammar. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- SETUP.md | 14 ++-- cloudflare/GITHUB.md | 6 +- cloudflare/src/github/service.ts | 9 ++- cloudflare/src/github/setup.ts | 22 ++++++- cloudflare/test/setup.test.ts | 32 ++++++++- quest/m2/hook-path-error.md | 7 +- src/cli/main.ts | 23 +++++-- src/cli/setup.ts | 10 +++ src/core/index.ts | 3 +- src/core/init.ts | 107 +++++++++++++++++-------------- test/cli.test.ts | 21 ++++-- test/init.test.ts | 90 +++++++++++--------------- 13 files changed, 219 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 1c5299cc..40bd1a51 100644 --- a/README.md +++ b/README.md @@ -1416,8 +1416,11 @@ Only one hook can manage Claude's worktrees, so remove any other Claude Code runs hooks with. For `claude --bg` and agent view, that is the environment of the shell that started the session. Earlier versions of `quest init` wrote these entries to a repository's `.claude/settings.json`; -`quest init` and `quest uninstall` now remove them there, along with wrappers -that run Quest, such as `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. +`quest init` and `quest uninstall` now remove those whose command is exactly +`quest worktree hook`. They leave any other command ending in `worktree hook`, +such as a wrapper like `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, +since it may be another tool's, and warn on stderr naming it. Remove such an +entry yourself if it runs Quest. The hook then creates every worktree Claude makes in the repository: for `--worktree`, `EnterWorktree`, isolated subagents and diff --git a/SETUP.md b/SETUP.md index 854aa11c..9c3453d4 100644 --- a/SETUP.md +++ b/SETUP.md @@ -49,9 +49,11 @@ starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets `merge.quest.driver` in the clone's git config, so local merges resolve concurrent edits to quest link lists. It writes no hooks: it removes the -entries earlier versions added to `.claude/settings.json` that run -`quest worktree hook`, directly or through a wrapper, keeping the file's other -settings, and names each on stderr. It +`quest worktree hook` entries earlier versions added to +`.claude/settings.json`, keeping the file's other settings, and names each on +stderr. It also warns about, but leaves, any other hook whose command ends in +`worktree hook`; if one runs Quest through a wrapper, tell the user and, with +their consent, remove it. It prints each path it changed and is safe to rerun. Rerun after an upgrade, it also rewrites the unmodified stubs whose description or setup link changed and removes the unmodified stubs of skills the new version no longer ships. @@ -115,8 +117,10 @@ work or `/quest-import` to bring in open GitHub issues. 1. Run `quest skill export` and follow it, so active quests become GitHub issues. Skip this only if the user says so. 2. Run `quest uninstall`. It removes only unmodified stubs of any version, the - `Quests: ` line, Quest's hook entries in `.claude/settings.json`, and the - merge driver, and never deletes the quest tree. + `Quests: ` line, Quest's `quest worktree hook` entries in + `.claude/settings.json`, and the merge driver, and never deletes the quest + tree. It warns about, but leaves, other hooks ending in `worktree hook`, as + init does. 3. Remove the pin added above: the mise entry, or the flake input and dev shell package. For an unpinned install, delete the `quest` binary the installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 79be74b5..23af37b8 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -608,8 +608,10 @@ Submitting it opens the App's setup pull request, with no runner involved the skill stubs this deployment's `quest` ships (rendered into `build/stubs.json` at build time), `quest/README.md` with the answer as its Goal, the `Quests: ` line in the root agent instructions, and the -`.gitattributes` line. Like `quest init`, it removes Quest's worktree hook -entries from `.claude/settings.json`, naming each in the body. Heading +`.gitattributes` line. Like `quest init`, it removes the +`quest worktree hook` entries from `.claude/settings.json`, naming each in the +body, and lists under "Left out" any other hook there whose command ends in +`worktree hook`, which it leaves since it may be another tool's. Heading syntax in the answer is escaped, and the planned `quest/README.md` must pass `quest check`, so a goal that would fail it, such as one linking a missing quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index d9d6c1a8..bb459c81 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -7,6 +7,7 @@ import { heldToken, check, isQuest, + lookalikeHooks, parse, planInit, ready, @@ -1426,10 +1427,11 @@ export class GitHubService { throw new GitHubError( "The default branch already has Quest files, so contributions can be enabled directly", ); - let changes; + let changes, lookalikes; try { const checkout = treeCheckout(root.tree, await this.#planFiles(token, repo, root.tree)); changes = planInit(checkout, STUBS, { goal: setupGoal(goal) }); + lookalikes = lookalikeHooks(checkout); } catch (error) { if (error instanceof InitRefused) throw new GitHubError(error.message); throw error; @@ -1442,7 +1444,10 @@ export class GitHubService { throw new GitHubError( `The goal would not pass \`quest check\` in quest/README.md (${problems.join("; ")}); reword it and try again.`, ); - const pull = setupPull(changes, { flake: root.tree.some((entry) => entry.path === "flake.nix") }); + const pull = setupPull(changes, { + flake: root.tree.some((entry) => entry.path === "flake.nix"), + lookalikes, + }); const branch = this.#disarming( token, diff --git a/cloudflare/src/github/setup.ts b/cloudflare/src/github/setup.ts index 0f75d5e6..fc34b240 100644 --- a/cloudflare/src/github/setup.ts +++ b/cloudflare/src/github/setup.ts @@ -3,6 +3,7 @@ // maintainer enrolling a repository that has no Quest files yet. import { type Checkout, + type HookEntry, type InitChange, InitRefused, type Kind, @@ -13,6 +14,7 @@ import { parse, } from "quest/core"; import stubs from "../../build/stubs.json"; +import { code } from "../jobs/trail"; /** The branch the App publishes the setup on, outside the quest claim namespace. */ export const SETUP_BRANCH = "kix/setup"; @@ -117,11 +119,16 @@ export function planCheck(changes: InitChange[], paths: Iterable): strin return check(documents, (path) => exists.has(path)).map((finding) => finding.message); } +/** Hook entries from a repository's settings, as untrusted text, for the pull request body. */ +function hookList(hooks: HookEntry[]): string { + return hooks.map((hook) => `${code(hook.command)} for ${code(hook.event)}`).join(", "); +} + /** What each planned change is for, in the pull request body. */ function purpose(change: InitChange): string { const path = change.path; if (change.hooks?.length) - return `removes Quest's worktree hooks (${change.hooks.map((hook) => `\`${hook.command}\` for ${hook.event}`).join(", ")}), which each user now opts into in their own Claude settings`; + return `removes Quest's worktree hooks (${hookList(change.hooks)}), which each user now opts into in their own Claude settings`; if (change.content === null) return "removes the stub of a skill Quest no longer ships"; if (path.endsWith("/SKILL.md")) return "a skill stub"; if (path === "quest/README.md") return "the quest tree's root, with the project's goal"; @@ -129,8 +136,12 @@ function purpose(change: InitChange): string { return "points agents at `quest context`"; } -/** The setup pull request's title and body for `changes`. */ -export function setupPull(changes: InitChange[], options: { flake: boolean }) { +/** + * The setup pull request's title and body for `changes`, in a repository with a `flake.nix` when `flake`, + * whose Claude Code settings keep `lookalikes`: hooks that may run Quest's through a wrapper. + */ +export function setupPull(changes: InitChange[], options: { flake: boolean; lookalikes?: HookEntry[] }) { + const lookalikes = options.lookalikes ?? []; const stubs = changes.filter((change) => change.content !== null && change.path.endsWith("/SKILL.md")); const others = changes.filter((change) => !stubs.includes(change)); const lines = [ @@ -151,6 +162,11 @@ export function setupPull(changes: InitChange[], options: { flake: boolean }) { "", "- Pinning `quest` in the repository's tool manager, so local agents run the version these stubs expect. kix's runners bring their own `quest`, so its jobs don't need it.", "- Adapting existing agent instructions to Quest beyond the one line added. kix's jobs don't need that either.", + ...(lookalikes.length + ? [ + `- Hooks in \`.claude/settings.json\` that end in \`worktree hook\` but aren't Quest's own \`quest worktree hook\`: ${hookList(lookalikes)}. If one runs Quest through a wrapper, remove it, since each user now opts into Quest's worktree hooks in their own Claude settings ([Claude Code checkouts](https://github.com/moq-dev/quest#claude-code-checkouts)).`, + ] + : []), ...(options.flake ? [] : [ diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 22e1f1bf..7b0bf6dd 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -135,6 +135,24 @@ describe("the setup pull request", () => { expect(await (await view()).text()).toContain("Contributions enabled"); }); + it("removes Quest's own hooks and names the lookalikes it leaves", async () => { + const group = (command: string) => ({ hooks: [{ type: "command", command }] }); + const wrapped = 'nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook'; + project.files.push({ + path: ".claude/settings.json", + content: JSON.stringify({ + hooks: { WorktreeCreate: [group("quest worktree hook")], WorktreeRemove: [group(wrapped)] }, + }), + }); + expect((await post("/setup", { id: "10" })).status).toBe(303); + const [pull] = opened(); + expect(pull!.body).toContain("removes Quest's worktree hooks (` quest worktree hook ` for ` WorktreeCreate `)"); + expect(pull!.body).toContain(`\` ${wrapped} \` for \` WorktreeRemove \`. If one runs Quest`); + expect(JSON.parse((await published()).get(".claude/settings.json")!)).toEqual({ + hooks: { WorktreeRemove: [group(wrapped)] }, + }); + }); + it("names the path init refuses, and writes nothing", async () => { project.files.push({ path: ".claude/skills/quest-start/SKILL.md", content: "# My own start\n" }); const response = await post("/setup", { id: "10" }); @@ -297,12 +315,20 @@ describe("the setup plan on GitHub", () => { expect(planCheck([readme(retired), { path: retired, content: null }], [retired])).not.toEqual([]); }); - it("names each Quest hook it removes", () => { + it("names each Quest hook it removes and each lookalike it leaves", () => { const hooks = [{ event: "WorktreeCreate", command: "quest worktree hook" }]; - const { body } = setupPull([{ path: ".claude/settings.json", content: null, hooks }], { flake: true }); + const lookalikes = [{ event: "WorktreeRemove", command: "nix run `.#quest`\n\n@someone -- worktree hook" }]; + const { body } = setupPull([{ path: ".claude/settings.json", content: null, hooks }], { + flake: true, + lookalikes, + }); + expect(body).toContain( + "- `.claude/settings.json`: removes Quest's worktree hooks (` quest worktree hook ` for ` WorktreeCreate `)", + ); expect(body).toContain( - "- `.claude/settings.json`: removes Quest's worktree hooks (`quest worktree hook` for WorktreeCreate)", + "aren't Quest's own `quest worktree hook`: `` nix run `.#quest` @someone -- worktree hook `` for ` WorktreeRemove `. If one runs Quest", ); + expect(setupPull([], { flake: true }).body).not.toContain("worktree hook"); }); it("refuses to follow a symlink", () => { diff --git a/quest/m2/hook-path-error.md b/quest/m2/hook-path-error.md index f64ec2da..e8807477 100644 --- a/quest/m2/hook-path-error.md +++ b/quest/m2/hook-path-error.md @@ -19,9 +19,10 @@ Decided 2026-11-04 (maintainer, /quest-plan). 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 match `quest init` and `quest uninstall` use for - Quest's own hooks (`removeHooks` in `src/core/init.ts`) must still - recognize it. + documented one. It lives only in user settings: `quest init` and + `quest uninstall` remove only the exact `quest worktree hook` from a + repository's settings and warn on any other command ending in + `worktree hook`, so a guarded entry pasted there draws that warning. - 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/src/cli/main.ts b/src/cli/main.ts index ae43a8b7..f547b27a 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,7 @@ import { Argument, Command, CommanderError, InvalidArgumentError, Option } from import { description, version } from "../../package.json"; import { + CLAUDE_SETTINGS, type Doc, type RankedQuest, blockers, @@ -53,7 +54,7 @@ import { landed } from "./landed"; import { type Report, fixLinks, moveQuest, removeQuest } from "./move"; import { type ResolveOptions, resolveFile } from "./resolve"; import { MODELS, ReviewInputError, processDependencies as reviewDependencies, review, roundLines } from "./review"; -import { heal, init, uninstall } from "./setup"; +import { heal, init, lookalikes, uninstall } from "./setup"; import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start"; import { CONTEXT, GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -74,6 +75,10 @@ export interface Output { /** A failure the user caused rather than the command, reported as a usage error. */ class UsageError extends Error {} +/** Where `init` and `uninstall` point a user whose repository settings held Quest's worktree hooks. */ +const OPT_IN = + 'Quest\'s worktree hooks are now an opt-in in your own Claude settings (see "Claude Code checkouts" in the README)'; + /** Run the CLI with `argv` (arguments only, no executable). Returns the exit code. */ export function main(argv: string[], out: Output): number | Promise { let pending: Promise | undefined; @@ -1207,6 +1212,15 @@ export function main(argv: string[], out: Output): number | Promise { out.stdout(options.stub ? found.stub : found.body); }); + /** Name each hook init and uninstall leave because it only looks like Quest's. */ + const warnLookalikes = (): void => { + for (const hook of lookalikes(root())) + warn( + `left the ${hook.event} hook \`${hook.command}\` in ${CLAUDE_SETTINGS}; if it runs Quest, remove it, ` + + `since ${OPT_IN}`, + ); + }; + program .command("init") .description( @@ -1217,10 +1231,8 @@ export function main(argv: string[], out: Output): number | Promise { const changes = init(root()); for (const { hooks = [] } of changes) for (const hook of hooks) - warn( - `removed the ${hook.event} hook \`${hook.command}\` from .claude/settings.json; ` + - "Quest's worktree hooks are now opt-in in your own Claude settings (see the README)", - ); + warn(`removed the ${hook.event} hook \`${hook.command}\` from ${CLAUDE_SETTINGS}; ${OPT_IN}`); + warnLookalikes(); const paths = changes.map((change) => change.path); for (const line of theme ? theme.init(paths) : paths) print(line); }); @@ -1230,6 +1242,7 @@ export function main(argv: string[], out: Output): number | Promise { .description("Remove Quest stubs, markers, worktree hooks, and the merge driver from this repository") .action(() => { const changes = uninstall(root()); + warnLookalikes(); for (const line of theme ? theme.uninstall(changes) : changes) print(line); }); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 07920728..3ed6210b 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -22,12 +22,14 @@ import { ATTRIBUTES_LINE, CLAUDE_SETTINGS, type Checkout, + type HookEntry, type InitChange, type Kind, REFERENCE_MARKER, SKILL_DIRS, installedStubs, lines, + lookalikeHooks, newline, planInit, removeHooks, @@ -58,6 +60,14 @@ export function init(root: string): InitChange[] { return changes; } +/** + * The command hooks in `root`'s Claude Code settings that end in `worktree hook` without being exactly + * Quest's, which init and uninstall leave in place, since they may be another tool's. + */ +export function lookalikes(root: string): HookEntry[] { + return lookalikeHooks(checkout(root)); +} + /** The checkout under `root`, as the core's file plan reads it. */ function checkout(root: string): Checkout { return { diff --git a/src/core/index.ts b/src/core/index.ts index e7715360..67bf1c31 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -36,17 +36,18 @@ export { ATTRIBUTES_LINE, CLAUDE_SETTINGS, type Checkout, + type HookEntry, type InitChange, InitRefused, type Kind, PREFIX, - type QuestHook, REFERENCE_LINE, REFERENCE_MARKER, SKILL_DIRS, type Stub, installedStubs, lines, + lookalikeHooks, newline, planInit, questReadme, diff --git a/src/core/init.ts b/src/core/init.ts index 4d91ba40..56d024d8 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -77,7 +77,7 @@ export interface InitChange { path: string; content: string | null; /** The Quest hook entries removed from Claude Code's settings by this change. */ - hooks?: QuestHook[]; + hooks?: HookEntry[]; } /** Init cannot install because of what sits at `path`; the repository needs a person's judgement. */ @@ -238,8 +238,11 @@ function appendLine(content: string, marker: string, line: string, old: readonly return `${content}${line}${eol}`; } -/** A hook entry that runs Quest: the event it handles and its command. */ -export interface QuestHook { +/** The command of the hook entries earlier versions wrote, the only one init and uninstall remove. */ +const HOOK_COMMAND = "quest worktree hook"; + +/** A command hook entry in Claude Code's settings: the event it handles and its command. */ +export interface HookEntry { event: string; command: string; } @@ -247,45 +250,13 @@ export interface QuestHook { const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -/** - * A command that runs `quest worktree hook`. A shell segment, past any variable assignments and an `exec` - * or `env`, starts the `quest` executable directly or through a launcher that names it: `nix run` of a - * flake attribute or reference ending in `quest`, `nix develop` or `nix shell` with `-c quest`, a package - * runner such as `npx @moq-dev/quest@latest`, `bun run quest`, or `mise exec` or `direnv exec` running - * `quest`. Another tool given `quest` as an argument, as in `othertool --project quest worktree hook`, - * is not Quest's. - */ -const QUEST_COMMAND = (() => { - const quoted = (body: string) => String.raw`["']?${body}["']?`; - const executable = quoted(String.raw`(?:[^\s"'=;&|]*/)?quest`); - const flags = String.raw`(?:\s+-\S*)*`; - const args = String.raw`(?:\s+[^\s;&|]+)*?`; - const launch = [ - executable, - String.raw`nix${flags}\s+run${flags}\s+${quoted(String.raw`[^\s"']*[#/]quest(?:\?[^\s"']*)?`)}`, - String.raw`nix${flags}\s+(?:develop|shell)${args}\s+(?:-c|--command)\s+${executable}`, - String.raw`(?:npx|bunx|pnpx|bun\s+x|pnpm\s+dlx|(?:npm|pnpm)\s+exec)${flags}\s+${quoted(String.raw`(?:[^\s"'=;&|]*/)?quest(?:@[^\s"']*)?`)}`, - String.raw`bun\s+run${flags}\s+${executable}`, - String.raw`(?:mise\s+(?:exec|x)|direnv\s+exec)${args}\s+${executable}`, - ].join("|"); - const prefix = String.raw`(?:^|[;&|(]\s*)(?:\w+=\S*\s+)*(?:(?:exec|env)\s+(?:\w+=\S*\s+)*)?`; - return new RegExp(String.raw`${prefix}(?:${launch})\s+(?:--\s+)?worktree hook$`); -})(); - -/** The trimmed command of a hook handler that runs Quest, or undefined for any other handler. */ -function questCommand(handler: unknown): string | undefined { - if (!record(handler) || handler.type !== "command" || typeof handler.command !== "string") return undefined; - const command = handler.command.trim(); - return QUEST_COMMAND.test(command) ? command : undefined; -} +type Groups = { hooks?: unknown[] }[]; /** - * Claude Code settings `text` without Quest's hook handlers, nor the groups, events, and hooks object they - * leave empty, along with the handlers removed. Its content is null when nothing else remains, and the - * result undefined when there is nothing to remove or the settings are malformed, which Claude Code - * rejects whole, so their hooks never run. + * Claude Code settings `text` with its hook groups by event, or undefined when it holds no hooks or is + * malformed, which Claude Code rejects whole, so its hooks never run. */ -export function removeHooks(text: string): { content: string | null; hooks: QuestHook[] } | undefined { +function parseHooks(text: string): { settings: Record; events: [string, Groups][] } | undefined { let settings: unknown; try { settings = JSON.parse(text); @@ -293,18 +264,40 @@ export function removeHooks(text: string): { content: string | null; hooks: Ques return undefined; } if (!record(settings) || !record(settings.hooks)) return undefined; - const removed: QuestHook[] = []; + const events = Object.entries(settings.hooks); + const valid = (groups: unknown) => + Array.isArray(groups) && groups.every((group) => record(group) && Array.isArray(group.hooks ?? [])); + if (!events.every(([, groups]) => valid(groups))) return undefined; + return { settings, events: events as [string, Groups][] }; +} + +/** The command a hook handler runs, or undefined when it is not a command hook. */ +function command(handler: unknown): string | undefined { + return record(handler) && handler.type === "command" && typeof handler.command === "string" + ? handler.command + : undefined; +} + +/** + * Claude Code settings `text` without the hook handlers whose command is exactly `quest worktree hook`, + * nor the groups, events, and hooks object they leave empty, along with the handlers removed. Its content + * is null when nothing else remains, and the result undefined when there is nothing to remove or the + * settings are malformed. + */ +export function removeHooks(text: string): { content: string | null; hooks: HookEntry[] } | undefined { + const parsed = parseHooks(text); + if (parsed === undefined) return undefined; + const { settings, events } = parsed; + const removed: HookEntry[] = []; const kept: [string, unknown][] = []; - for (const [event, groups] of Object.entries(settings.hooks)) { - if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) - return undefined; + for (const [event, groups] of events) { const before = removed.length; - const remaining = (groups as { hooks?: unknown[] }[]).flatMap((group) => { + const remaining = groups.flatMap((group) => { const handlers = group.hooks ?? []; const others = handlers.filter((handler) => { - const command = questCommand(handler); - if (command !== undefined) removed.push({ event, command }); - return command === undefined; + const quest = command(handler) === HOOK_COMMAND; + if (quest) removed.push({ event, command: HOOK_COMMAND }); + return !quest; }); if (others.length === handlers.length) return [group]; return others.length ? [{ ...group, hooks: others }] : []; @@ -321,3 +314,23 @@ export function removeHooks(text: string): { content: string | null; hooks: Ques const eol = newline(text); return { content: `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`, hooks: removed }; } + +/** + * The command hooks in `repo`'s Claude Code settings, other than Quest's own, whose command ends in + * `worktree hook`. One may run Quest through a wrapper, such as moq-dev/moq's + * `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, or may be another tool's, which no match can + * tell apart, so init and uninstall leave them and warn instead. + */ +export function lookalikeHooks(repo: Checkout): HookEntry[] { + const parsed = repo.stat(CLAUDE_SETTINGS) === "file" ? parseHooks(repo.read(CLAUDE_SETTINGS)) : undefined; + return (parsed?.events ?? []).flatMap(([event, groups]) => + groups.flatMap((group) => + (group.hooks ?? []).flatMap((handler) => { + const found = command(handler); + return found !== undefined && found !== HOOK_COMMAND && found.trimEnd().endsWith("worktree hook") + ? [{ event, command: found }] + : []; + }), + ), + ); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 9a749124..aeb82dbe 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -94,18 +94,29 @@ test("init and uninstall print each changed path and nothing else", () => { expect(run("--root", root, "uninstall").stdout).toBe(""); }); -test("init names each hook entry it removes on stderr", () => { +test("init and uninstall name on stderr each hook entry they remove or leave", () => { const root = tempDir(); mkdirSync(join(root, ".claude")); - const hook = { hooks: [{ type: "command", command: 'nix run ".#quest" -- worktree hook' }] }; - writeFileSync(join(root, ".claude/settings.json"), JSON.stringify({ hooks: { WorktreeCreate: [hook] } })); + const group = (command: string) => ({ hooks: [{ type: "command", command }] }); + const wrapped = 'nix run ".#quest" -- worktree hook'; + writeFileSync( + join(root, ".claude/settings.json"), + JSON.stringify({ hooks: { WorktreeCreate: [group("quest worktree hook"), group(wrapped)] } }), + ); + const optIn = + "Quest's worktree hooks are now an opt-in in your own Claude settings " + + '(see "Claude Code checkouts" in the README)'; + const left = `quest: left the WorktreeCreate hook \`${wrapped}\` in .claude/settings.json; if it runs Quest, remove it, since ${optIn}\n`; const init = run("--root", root, "init"); expect(init.code).toBe(0); expect(init.stdout).toContain(".claude/settings.json\n"); expect(init.stderr).toBe( - 'quest: removed the WorktreeCreate hook `nix run ".#quest" -- worktree hook` from .claude/settings.json; ' + - "Quest's worktree hooks are now opt-in in your own Claude settings (see the README)\n", + `quest: removed the WorktreeCreate hook \`quest worktree hook\` from .claude/settings.json; ${optIn}\n${left}`, ); + const uninstall = run("--root", root, "uninstall"); + expect(uninstall.code).toBe(0); + expect(uninstall.stdout).not.toContain(".claude/settings.json"); + expect(uninstall.stderr).toBe(left); }); test("version", () => { diff --git a/test/init.test.ts b/test/init.test.ts index 0c812fdf..f0853c28 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,7 +14,7 @@ import { dirname, join } from "node:path"; import { expect, test } from "vitest"; -import { REFERENCE_LINE, init as apply, uninstall } from "../src/cli/setup"; +import { REFERENCE_LINE, init as apply, lookalikes, uninstall } from "../src/cli/setup"; import { SKILLS } from "../src/cli/skills"; import { tempDir } from "./fixture"; @@ -400,84 +400,66 @@ test("init writes no hooks", () => { expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); }); -test("init removes the hooks an earlier version wrote, plain or wrapped, and the file they leave empty", () => { +test("init removes the hooks an earlier version wrote, on any event, and the file they leave empty", () => { const dir = tempDir(); settings(dir, { hooks: { WorktreeCreate: [group(QUEST)], - WorktreeRemove: [group(WRAPPED)], - SubagentStart: [group("npx @moq-dev/quest@latest worktree hook")], - SubagentStop: [ - group("/usr/local/bin/quest worktree hook "), - group("nix run github:moq-dev/quest -- worktree hook"), - group("bun run --silent quest worktree hook"), - group("nix develop --impure -c quest worktree hook"), - group("mise exec -- quest worktree hook"), - group("command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook"), - ], + WorktreeRemove: [group(QUEST)], + SubagentStart: [group(QUEST)], + SubagentStop: [{ hooks: [handler(QUEST), handler(QUEST)] }], + SessionEnd: [group(QUEST)], }, }); const change = apply(dir).find((change) => change.path === ".claude/settings.json"); - expect(change?.hooks).toEqual([ - { event: "WorktreeCreate", command: QUEST }, - { event: "WorktreeRemove", command: WRAPPED }, - { event: "SubagentStart", command: "npx @moq-dev/quest@latest worktree hook" }, - { event: "SubagentStop", command: "/usr/local/bin/quest worktree hook" }, - { event: "SubagentStop", command: "nix run github:moq-dev/quest -- worktree hook" }, - { event: "SubagentStop", command: "bun run --silent quest worktree hook" }, - { event: "SubagentStop", command: "nix develop --impure -c quest worktree hook" }, - { event: "SubagentStop", command: "mise exec -- quest worktree hook" }, - { event: "SubagentStop", command: "command -v quest >/dev/null || exit 2; QUEST_X=1 quest worktree hook" }, - ]); + expect(change?.hooks).toEqual( + ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop", "SubagentStop", "SessionEnd"].map( + (event) => ({ event, command: QUEST }), + ), + ); expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); expect(isFile(dir, ".claude/skills/quest-start/SKILL.md")).toBe(true); expect(init(dir)).toEqual([]); + expect(lookalikes(dir)).toEqual([]); }); -test("init and uninstall keep other hooks and settings, and the file's formatting", () => { +test("init and uninstall keep every other hook, reporting those that end in worktree hook", () => { + // Commands that may wrap Quest's hook, or may be another tool's: neither is removed. + const similar = { + WorktreeCreate: [group(WRAPPED), group("othertool worktree hook")], + WorktreeRemove: [group(" quest worktree hook"), group("cd quest && othertool worktree hook ")], + SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], + }; const others = { SessionStart: [SESSION], PreCompact: [], - // Another tool's commands that end in `worktree hook` without running Quest. - WorktreeCreate: [group("othertool worktree hook")], - WorktreeRemove: [ - group("my-quest worktree hook"), - group("tool --quest-mode worktree hook"), - group("cd quest && othertool worktree hook"), - group("othertool --dir=/quest worktree hook"), - group("othertool --dir /quest worktree hook"), - group("othertool --project quest worktree hook"), - group("othertool -c /quest worktree hook"), - group("echo run quest worktree hook"), - ], - SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], + Stop: [group("quest worktree hook --verbose"), { hooks: [{ type: "prompt", prompt: "quest worktree hook" }] }], + }; + const kept = { + $schema: "https://json.schemastore.org/claude-code-settings.json", + hooks: { ...others, ...similar }, }; - const kept = { $schema: "https://json.schemastore.org/claude-code-settings.json", hooks: others }; + const reported = [ + { event: "WorktreeCreate", command: WRAPPED }, + { event: "WorktreeCreate", command: "othertool worktree hook" }, + { event: "WorktreeRemove", command: " quest worktree hook" }, + { event: "WorktreeRemove", command: "cd quest && othertool worktree hook " }, + { event: "SubagentStop", command: "quest-like worktree hook" }, + ]; for (const remove of [init, uninstall]) { const dir = tempDir(); settings(dir, { $schema: kept.$schema, hooks: { - SessionStart: [SESSION], - PreCompact: [], - WorktreeCreate: [group("othertool worktree hook")], - WorktreeRemove: [ - group("my-quest worktree hook"), - group("tool --quest-mode worktree hook"), - group("cd quest && othertool worktree hook"), - group("othertool --dir=/quest worktree hook"), - group("othertool --dir /quest worktree hook"), - group("othertool --project quest worktree hook"), - group("othertool -c /quest worktree hook"), - group("echo run quest worktree hook"), - ], - SubagentStart: [group(WRAPPED)], + ...others, + ...similar, + SubagentStart: [group(QUEST)], SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], }, }); expect(remove(dir)).toContain(".claude/settings.json"); - const text = read(dir, ".claude/settings.json"); - expect(text).toBe(`${JSON.stringify(kept, null, "\t")}\n`); + expect(read(dir, ".claude/settings.json")).toBe(`${JSON.stringify(kept, null, "\t")}\n`); + expect(lookalikes(dir)).toEqual(reported); expect(remove(dir)).not.toContain(".claude/settings.json"); } }); From d6e260de581ff38c49b3e0303cd8f87721ce699c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 08:44:04 -0800 Subject: [PATCH 7/8] quest: leave moq's worktree hook entries to moq-dev/moq#7889 Co-Authored-By: Claude Opus 5.5 --- quest/m0/user-dev-shell/README.md | 2 +- quest/m0/user-dev-shell/repo-config.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/quest/m0/user-dev-shell/README.md b/quest/m0/user-dev-shell/README.md index e311ff92..0e2f1ca2 100644 --- a/quest/m0/user-dev-shell/README.md +++ b/quest/m0/user-dev-shell/README.md @@ -73,4 +73,4 @@ moq-dev/moq's `.codex/hooks/direnv.sh`. ## Required -- [Repositories drop dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - quest, moq, moq.pro, rind, and rust2 drop their dev-shell hooks, Quest worktree hooks, and `nix develop` instructions +- [Repositories drop dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - quest, moq, moq.pro, rind, and rust2 drop their dev-shell hooks and `nix develop` instructions diff --git a/quest/m0/user-dev-shell/repo-config.md b/quest/m0/user-dev-shell/repo-config.md index 19bceb61..e1e479b6 100644 --- a/quest/m0/user-dev-shell/repo-config.md +++ b/quest/m0/user-dev-shell/repo-config.md @@ -5,8 +5,8 @@ moq-dev/quest, moq, moq.pro, rind, and rust2 carry no dev-shell hooks and no instructions to wrap commands in Nix: each drops its direnv `SessionStart` hook from `.claude/settings.json` and `.codex/hooks.json` with the scripts -and tests behind them, moq drops its four Quest worktree hook entries, and -each AGENTS.md drops its `nix develop` instructions. Agents there still run +and tests behind them, and each AGENTS.md drops its `nix develop` +instructions. Agents there still run the pinned tools, through the maintainer's user-level hooks. One PR per repository. @@ -15,6 +15,10 @@ repository. Decided 2026-11-03 (maintainer, /quest-unblock); see the [epic](/quest/m0/user-dev-shell/README.md). +- moq's four Quest worktree hook entries are not this quest's: decided + 2026-11-04 (maintainer, /quest-complete), + [moq-dev/moq#7889](https://github.com/moq-dev/moq/pull/7889) removes them + on its own. - Keep every other hook: moq.pro's `submodule.sh`, `worktree.sh` (its own submodule-aware worktrees, not Quest's), and `tofu-secrets.py`. - CONTRIBUTING.md's human setup (`nix develop` to match CI) stays; only the From 21e0bf339c2144d1bf050472a4cf5dc0a5756bd9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 09:52:28 -0800 Subject: [PATCH 8/8] fix(init)!: drop hook cleanup; init and uninstall leave settings alone The maintainer decided no cleanup: nobody carries the entries earlier versions wrote. init writes no hooks, and neither init, uninstall, nor the Worker's setup PR removes, detects, or warns about existing entries in .claude/settings.json. removeHooks, lookalikeHooks, HookEntry, the stderr reporting, the setup PR's hook lists, and uninstall's exact removal go, along with their tests and docs. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +-- SETUP.md | 15 +--- cloudflare/GITHUB.md | 5 +- cloudflare/src/github/service.ts | 9 +-- cloudflare/src/github/setup.ts | 24 +----- cloudflare/test/setup.test.ts | 36 +-------- quest/m0/README.md | 14 ++-- quest/m2/hook-path-error.md | 5 +- src/cli/main.ts | 31 +------- src/cli/setup.ts | 28 +------ src/core/index.ts | 4 - src/core/init.ts | 116 +--------------------------- test/cli.test.ts | 28 +------ test/init.test.ts | 128 ++----------------------------- 14 files changed, 39 insertions(+), 414 deletions(-) diff --git a/README.md b/README.md index bed739e0..d27cff6d 100644 --- a/README.md +++ b/README.md @@ -1417,15 +1417,7 @@ entries into the file's `hooks`: Only one hook can manage Claude's worktrees, so remove any other `WorktreeCreate` or `WorktreeRemove` hook first. `quest` must be on the `PATH` Claude Code runs hooks with. For `claude --bg` and agent view, that is the -environment of the shell that started the session. Earlier versions of -`quest init` wrote these entries to a repository's `.claude/settings.json`; -`quest init` and `quest uninstall` now remove those whose command is exactly -`quest worktree hook`. They leave any other command ending in `worktree hook`, -such as a wrapper like `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, -since it may be another tool's, and warn on stderr naming it. Remove such an -entry yourself if it runs Quest. - -The hook then creates every worktree Claude makes +environment of the shell that started the session. The hook then creates every worktree Claude makes in the repository: for `--worktree`, `EnterWorktree`, isolated subagents and workflow agents, background sessions, and `claude remote-control --spawn worktree`. Claude no longer copies `.worktreeinclude` files into them. diff --git a/SETUP.md b/SETUP.md index 9c3453d4..5c7326ad 100644 --- a/SETUP.md +++ b/SETUP.md @@ -48,12 +48,7 @@ without creating symbolic links (existing linked layouts continue to work), crea starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets `merge.quest.driver` in the clone's git config, so local merges resolve -concurrent edits to quest link lists. It writes no hooks: it removes the -`quest worktree hook` entries earlier versions added to -`.claude/settings.json`, keeping the file's other settings, and names each on -stderr. It also warns about, but leaves, any other hook whose command ends in -`worktree hook`; if one runs Quest through a wrapper, tell the user and, with -their consent, remove it. It +concurrent edits to quest link lists. It writes no Claude Code hooks. It prints each path it changed and is safe to rerun. Rerun after an upgrade, it also rewrites the unmodified stubs whose description or setup link changed and removes the unmodified stubs of skills the new version no longer ships. @@ -68,8 +63,7 @@ across the user's repositories, through hooks in the user's own Claude Code settings rather than the repository's, since repository settings apply to every contributor. Offer them, explaining that they then apply in every repository the user opens in Claude Code and need `quest` on the `PATH` -Claude Code runs hooks with. If init just removed entries, say they were -those hooks. With consent, merge the entries in +Claude Code runs hooks with. With consent, merge the entries in [Claude Code checkouts](README.md#claude-code-checkouts) into `~/.claude/settings.json`, keeping its other settings. If it already has a `WorktreeCreate` or `WorktreeRemove` hook, ask the user which to keep. @@ -117,10 +111,7 @@ work or `/quest-import` to bring in open GitHub issues. 1. Run `quest skill export` and follow it, so active quests become GitHub issues. Skip this only if the user says so. 2. Run `quest uninstall`. It removes only unmodified stubs of any version, the - `Quests: ` line, Quest's `quest worktree hook` entries in - `.claude/settings.json`, and the merge driver, and never deletes the quest - tree. It warns about, but leaves, other hooks ending in `worktree hook`, as - init does. + `Quests: ` line, and the merge driver, and never deletes the quest tree. 3. Remove the pin added above: the mise entry, or the flake input and dev shell package. For an unpinned install, delete the `quest` binary the installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 23af37b8..7f53d50a 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -608,10 +608,7 @@ Submitting it opens the App's setup pull request, with no runner involved the skill stubs this deployment's `quest` ships (rendered into `build/stubs.json` at build time), `quest/README.md` with the answer as its Goal, the `Quests: ` line in the root agent instructions, and the -`.gitattributes` line. Like `quest init`, it removes the -`quest worktree hook` entries from `.claude/settings.json`, naming each in the -body, and lists under "Left out" any other hook there whose command ends in -`worktree hook`, which it leaves since it may be another tool's. Heading +`.gitattributes` line. Heading syntax in the answer is escaped, and the planned `quest/README.md` must pass `quest check`, so a goal that would fail it, such as one linking a missing quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index bb459c81..d9d6c1a8 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -7,7 +7,6 @@ import { heldToken, check, isQuest, - lookalikeHooks, parse, planInit, ready, @@ -1427,11 +1426,10 @@ export class GitHubService { throw new GitHubError( "The default branch already has Quest files, so contributions can be enabled directly", ); - let changes, lookalikes; + let changes; try { const checkout = treeCheckout(root.tree, await this.#planFiles(token, repo, root.tree)); changes = planInit(checkout, STUBS, { goal: setupGoal(goal) }); - lookalikes = lookalikeHooks(checkout); } catch (error) { if (error instanceof InitRefused) throw new GitHubError(error.message); throw error; @@ -1444,10 +1442,7 @@ export class GitHubService { throw new GitHubError( `The goal would not pass \`quest check\` in quest/README.md (${problems.join("; ")}); reword it and try again.`, ); - const pull = setupPull(changes, { - flake: root.tree.some((entry) => entry.path === "flake.nix"), - lookalikes, - }); + const pull = setupPull(changes, { flake: root.tree.some((entry) => entry.path === "flake.nix") }); const branch = this.#disarming( token, diff --git a/cloudflare/src/github/setup.ts b/cloudflare/src/github/setup.ts index fc34b240..8d860e23 100644 --- a/cloudflare/src/github/setup.ts +++ b/cloudflare/src/github/setup.ts @@ -3,7 +3,6 @@ // maintainer enrolling a repository that has no Quest files yet. import { type Checkout, - type HookEntry, type InitChange, InitRefused, type Kind, @@ -14,7 +13,6 @@ import { parse, } from "quest/core"; import stubs from "../../build/stubs.json"; -import { code } from "../jobs/trail"; /** The branch the App publishes the setup on, outside the quest claim namespace. */ export const SETUP_BRANCH = "kix/setup"; @@ -29,7 +27,7 @@ export type TreeEntry = { path: string; type: string; mode: string; sha: string /** Whether the plan may read the file at `path`, so the service fetches it with the tree. */ export function planned(path: string): boolean { - if (["AGENTS.md", "CLAUDE.md", ".gitattributes", ".claude/settings.json"].includes(path)) return true; + if (["AGENTS.md", "CLAUDE.md", ".gitattributes"].includes(path)) return true; return SKILL_DIRS.some((dir) => { const rest = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1).split("/") : []; return rest.length === 2 && rest[0]!.startsWith("quest-") && rest[1] === "SKILL.md"; @@ -119,16 +117,9 @@ export function planCheck(changes: InitChange[], paths: Iterable): strin return check(documents, (path) => exists.has(path)).map((finding) => finding.message); } -/** Hook entries from a repository's settings, as untrusted text, for the pull request body. */ -function hookList(hooks: HookEntry[]): string { - return hooks.map((hook) => `${code(hook.command)} for ${code(hook.event)}`).join(", "); -} - /** What each planned change is for, in the pull request body. */ function purpose(change: InitChange): string { const path = change.path; - if (change.hooks?.length) - return `removes Quest's worktree hooks (${hookList(change.hooks)}), which each user now opts into in their own Claude settings`; if (change.content === null) return "removes the stub of a skill Quest no longer ships"; if (path.endsWith("/SKILL.md")) return "a skill stub"; if (path === "quest/README.md") return "the quest tree's root, with the project's goal"; @@ -136,12 +127,8 @@ function purpose(change: InitChange): string { return "points agents at `quest context`"; } -/** - * The setup pull request's title and body for `changes`, in a repository with a `flake.nix` when `flake`, - * whose Claude Code settings keep `lookalikes`: hooks that may run Quest's through a wrapper. - */ -export function setupPull(changes: InitChange[], options: { flake: boolean; lookalikes?: HookEntry[] }) { - const lookalikes = options.lookalikes ?? []; +/** The setup pull request's title and body for `changes`. */ +export function setupPull(changes: InitChange[], options: { flake: boolean }) { const stubs = changes.filter((change) => change.content !== null && change.path.endsWith("/SKILL.md")); const others = changes.filter((change) => !stubs.includes(change)); const lines = [ @@ -162,11 +149,6 @@ export function setupPull(changes: InitChange[], options: { flake: boolean; look "", "- Pinning `quest` in the repository's tool manager, so local agents run the version these stubs expect. kix's runners bring their own `quest`, so its jobs don't need it.", "- Adapting existing agent instructions to Quest beyond the one line added. kix's jobs don't need that either.", - ...(lookalikes.length - ? [ - `- Hooks in \`.claude/settings.json\` that end in \`worktree hook\` but aren't Quest's own \`quest worktree hook\`: ${hookList(lookalikes)}. If one runs Quest through a wrapper, remove it, since each user now opts into Quest's worktree hooks in their own Claude settings ([Claude Code checkouts](https://github.com/moq-dev/quest#claude-code-checkouts)).`, - ] - : []), ...(options.flake ? [] : [ diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 7b0bf6dd..43d49951 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -4,7 +4,7 @@ import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { REFERENCE_LINE } from "quest/core"; import { app } from "../src/index"; -import { STUBS, planCheck, setupGoal, setupPull, treeCheckout } from "../src/github/setup"; +import { STUBS, planCheck, setupGoal, treeCheckout } from "../src/github/setup"; import { type Pull, type Repo, type World, fakeGitHub, intercept } from "../dev/github"; import { privateKey } from "./github-key"; import { owner } from "./owners"; @@ -135,24 +135,6 @@ describe("the setup pull request", () => { expect(await (await view()).text()).toContain("Contributions enabled"); }); - it("removes Quest's own hooks and names the lookalikes it leaves", async () => { - const group = (command: string) => ({ hooks: [{ type: "command", command }] }); - const wrapped = 'nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook'; - project.files.push({ - path: ".claude/settings.json", - content: JSON.stringify({ - hooks: { WorktreeCreate: [group("quest worktree hook")], WorktreeRemove: [group(wrapped)] }, - }), - }); - expect((await post("/setup", { id: "10" })).status).toBe(303); - const [pull] = opened(); - expect(pull!.body).toContain("removes Quest's worktree hooks (` quest worktree hook ` for ` WorktreeCreate `)"); - expect(pull!.body).toContain(`\` ${wrapped} \` for \` WorktreeRemove \`. If one runs Quest`); - expect(JSON.parse((await published()).get(".claude/settings.json")!)).toEqual({ - hooks: { WorktreeRemove: [group(wrapped)] }, - }); - }); - it("names the path init refuses, and writes nothing", async () => { project.files.push({ path: ".claude/skills/quest-start/SKILL.md", content: "# My own start\n" }); const response = await post("/setup", { id: "10" }); @@ -315,22 +297,6 @@ describe("the setup plan on GitHub", () => { expect(planCheck([readme(retired), { path: retired, content: null }], [retired])).not.toEqual([]); }); - it("names each Quest hook it removes and each lookalike it leaves", () => { - const hooks = [{ event: "WorktreeCreate", command: "quest worktree hook" }]; - const lookalikes = [{ event: "WorktreeRemove", command: "nix run `.#quest`\n\n@someone -- worktree hook" }]; - const { body } = setupPull([{ path: ".claude/settings.json", content: null, hooks }], { - flake: true, - lookalikes, - }); - expect(body).toContain( - "- `.claude/settings.json`: removes Quest's worktree hooks (` quest worktree hook ` for ` WorktreeCreate `)", - ); - expect(body).toContain( - "aren't Quest's own `quest worktree hook`: `` nix run `.#quest` @someone -- worktree hook `` for ` WorktreeRemove `. If one runs Quest", - ); - expect(setupPull([], { flake: true }).body).not.toContain("worktree hook"); - }); - it("refuses to follow a symlink", () => { const checkout = treeCheckout( [{ path: ".claude", type: "blob", mode: "120000", sha: "a".repeat(40) }], diff --git a/quest/m0/README.md b/quest/m0/README.md index 244ada26..a5f84e18 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -72,13 +72,13 @@ ownership-manifest design: - The binary carries the skills, the quest contract, and all hook logic. `quest init` installs only static stubs into a repository: one-line skill - stubs, one reference line in its root instructions, one `.gitattributes` - line, and Claude hook entries in `.claude/settings.json` that only call a - `quest` subcommand. Upgrading never rewrites repository files. (Amended - 2026-10-26 (user), replacing "Nothing is vendored", so `quest init` can - install the agent lifecycle hooks. Amended 2026-11-01 (user, /quest-plan): - `quest init` also rewrites a Quest-written stub nobody edited when its - text is out of date.) + stubs, one reference line in its root instructions, and one + `.gitattributes` line. Upgrading never rewrites repository files. (Amended + 2026-11-01 (user, /quest-plan): `quest init` also rewrites a Quest-written + stub nobody edited when its text is out of date. Amended 2026-11-03 + (maintainer, /quest-unblock): `quest init` writes no Claude hook entries; + Quest's worktree hooks are a user-level opt-in, see + [user-dev-shell](/quest/m0/user-dev-shell/README.md).) - The repository's own tool manager pins the version: mise (release binaries) or a nix flake input (any rev, built from source). Quest ships no launcher and no pin file; an install outside either is simply unpinned. diff --git a/quest/m2/hook-path-error.md b/quest/m2/hook-path-error.md index e8807477..b48d42b6 100644 --- a/quest/m2/hook-path-error.md +++ b/quest/m2/hook-path-error.md @@ -19,10 +19,7 @@ Decided 2026-11-04 (maintainer, /quest-plan). 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. It lives only in user settings: `quest init` and - `quest uninstall` remove only the exact `quest worktree hook` from a - repository's settings and warn on any other command ending in - `worktree hook`, so a guarded entry pasted there draws that warning. + 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/src/cli/main.ts b/src/cli/main.ts index f547b27a..b66a6706 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,7 +7,6 @@ import { Argument, Command, CommanderError, InvalidArgumentError, Option } from import { description, version } from "../../package.json"; import { - CLAUDE_SETTINGS, type Doc, type RankedQuest, blockers, @@ -54,7 +53,7 @@ import { landed } from "./landed"; import { type Report, fixLinks, moveQuest, removeQuest } from "./move"; import { type ResolveOptions, resolveFile } from "./resolve"; import { MODELS, ReviewInputError, processDependencies as reviewDependencies, review, roundLines } from "./review"; -import { heal, init, lookalikes, uninstall } from "./setup"; +import { heal, init, uninstall } from "./setup"; import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start"; import { CONTEXT, GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -75,10 +74,6 @@ export interface Output { /** A failure the user caused rather than the command, reported as a usage error. */ class UsageError extends Error {} -/** Where `init` and `uninstall` point a user whose repository settings held Quest's worktree hooks. */ -const OPT_IN = - 'Quest\'s worktree hooks are now an opt-in in your own Claude settings (see "Claude Code checkouts" in the README)'; - /** Run the CLI with `argv` (arguments only, no executable). Returns the exit code. */ export function main(argv: string[], out: Output): number | Promise { let pending: Promise | undefined; @@ -1212,37 +1207,19 @@ export function main(argv: string[], out: Output): number | Promise { out.stdout(options.stub ? found.stub : found.body); }); - /** Name each hook init and uninstall leave because it only looks like Quest's. */ - const warnLookalikes = (): void => { - for (const hook of lookalikes(root())) - warn( - `left the ${hook.event} hook \`${hook.command}\` in ${CLAUDE_SETTINGS}; if it runs Quest, remove it, ` + - `since ${OPT_IN}`, - ); - }; - program .command("init") - .description( - "Install skill stubs, a quest root, agent pointers, and the merge driver for this repository, and remove " + - "the Claude Code worktree hooks earlier versions added to .claude/settings.json", - ) + .description("Install skill stubs, a quest root, agent pointers, and the merge driver for this repository") .action(() => { const changes = init(root()); - for (const { hooks = [] } of changes) - for (const hook of hooks) - warn(`removed the ${hook.event} hook \`${hook.command}\` from ${CLAUDE_SETTINGS}; ${OPT_IN}`); - warnLookalikes(); - const paths = changes.map((change) => change.path); - for (const line of theme ? theme.init(paths) : paths) print(line); + for (const line of theme ? theme.init(changes) : changes) print(line); }); program .command("uninstall") - .description("Remove Quest stubs, markers, worktree hooks, and the merge driver from this repository") + .description("Remove Quest stubs, markers, and the merge driver installed by `quest init`") .action(() => { const changes = uninstall(root()); - warnLookalikes(); for (const line of theme ? theme.uninstall(changes) : changes) print(line); }); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 3ed6210b..109a83d6 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -20,19 +20,14 @@ import { dirname, join, resolve } from "node:path"; import { ATTRIBUTES_LINE, - CLAUDE_SETTINGS, type Checkout, - type HookEntry, - type InitChange, type Kind, REFERENCE_MARKER, SKILL_DIRS, installedStubs, lines, - lookalikeHooks, newline, planInit, - removeHooks, } from "../core"; import { gitOutput } from "./claims"; import { DRIVER, OLD_DRIVERS } from "./resolve"; @@ -41,11 +36,10 @@ import { SKILLS } from "./skills"; export { ATTRIBUTES_LINE, REFERENCE_LINE } from "../core"; /** - * Install Quest stubs and markers under `root`, and remove the hook entries earlier versions wrote. - * Returns the changes it made. A conflict throws before anything is written, so a refused init leaves - * the repository as it was. + * Install Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. + * A conflict throws before anything is written, so a refused init leaves the repository as it was. */ -export function init(root: string): InitChange[] { +export function init(root: string): string[] { const changes = planInit(checkout(root), SKILLS); for (const { path, content } of changes) { if (content === null) { @@ -57,15 +51,7 @@ export function init(root: string): InitChange[] { } } register(root); - return changes; -} - -/** - * The command hooks in `root`'s Claude Code settings that end in `worktree hook` without being exactly - * Quest's, which init and uninstall leave in place, since they may be another tool's. - */ -export function lookalikes(root: string): HookEntry[] { - return lookalikeHooks(checkout(root)); + return changes.map((change) => change.path); } /** The checkout under `root`, as the core's file plan reads it. */ @@ -132,12 +118,6 @@ function unregister(root: string): void { /** Remove Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. */ export function uninstall(root: string): string[] { const changes: string[] = []; - const settings = isFile(join(root, CLAUDE_SETTINGS)) ? removeHooks(read(root, CLAUDE_SETTINGS)) : undefined; - if (settings !== undefined) { - if (settings.content === null) rmSync(join(root, CLAUDE_SETTINGS)); - else writeFileSync(join(root, CLAUDE_SETTINGS), settings.content); - changes.push(CLAUDE_SETTINGS); - } for (const skills of SKILL_DIRS) { if (!isRealDir(join(root, skills))) continue; for (const dir of installedStubs(checkout(root), skills)) changes.push(removeStub(root, dir)); diff --git a/src/core/index.ts b/src/core/index.ts index 67bf1c31..f7cc523d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,9 +34,7 @@ export { type RankedQuest, type Ranking, type WaitingQuest, ranking, unblocked, export { MAINTAINER, WAITING, type Wait, onMaintainer, parseWait, waits } from "./waiting"; export { ATTRIBUTES_LINE, - CLAUDE_SETTINGS, type Checkout, - type HookEntry, type InitChange, InitRefused, type Kind, @@ -47,11 +45,9 @@ export { type Stub, installedStubs, lines, - lookalikeHooks, newline, planInit, questReadme, - removeHooks, } from "./init"; export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge"; diff --git a/src/core/init.ts b/src/core/init.ts index 56d024d8..173d4c31 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -5,10 +5,9 @@ // // The plan touches only skill stubs (writing missing or outdated ones and // removing those of retired skills), one reference line in the root agent -// instructions, one `.gitattributes` line, `quest/README.md` when missing, and -// the hook entries earlier versions wrote to `.claude/settings.json`, which it -// removes: Quest's worktree hooks are a user-level opt-in, since a -// repository's settings would force them on every contributor. +// instructions, one `.gitattributes` line, and `quest/README.md` when missing. +// It writes no Claude Code hooks: Quest's worktree hooks are a user-level +// opt-in, since a repository's settings would force them on every contributor. /** Installed skills carry this prefix so they don't collide with a repository's own. */ export const PREFIX = "quest-"; @@ -34,9 +33,6 @@ export const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; /** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ export const ATTRIBUTES_LINE = "quest/** merge=quest"; -/** Claude Code's shared project settings, where earlier versions installed the worktree hooks. */ -export const CLAUDE_SETTINGS = ".claude/settings.json"; - /** The quest tree's root, with the project's goal or a placeholder for it. */ export function questReadme(goal?: string): string { return `# Quests\n\n## Goal\n\n${goal?.trim() || "What this project is working toward."}\n`; @@ -69,15 +65,10 @@ export interface Stub { stub: string; } -/** - * One file the plan sets to `content`, or, with null content, deletes along with its directory once - * empty: a retired stub, or settings left empty once Quest's hooks are removed. - */ +/** One file the plan sets to `content`, or, with null content, a retired stub it deletes along with its directory once empty. */ export interface InitChange { path: string; content: string | null; - /** The Quest hook entries removed from Claude Code's settings by this change. */ - hooks?: HookEntry[]; } /** Init cannot install because of what sits at `path`; the repository needs a person's judgement. */ @@ -134,11 +125,9 @@ export function planInit(repo: Checkout, stubs: readonly Stub[], options: { goal const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => repo.stat(name) === "file") ?? "AGENTS.md"; checkPath(repo, instructions, "file"); checkPath(repo, ".gitattributes", "file"); - const settings = repo.stat(CLAUDE_SETTINGS) === "file" ? removeHooks(repo.read(CLAUDE_SETTINGS)) : undefined; const changes: InitChange[] = [...written, ...retired.map((dir) => ({ path: `${dir}/SKILL.md`, content: null }))]; if (repo.lstat(readme) === null) changes.push({ path: readme, content: questReadme(options.goal) }); - if (settings !== undefined) changes.push({ path: CLAUDE_SETTINGS, ...settings }); for (const [path, marker, line, old] of [ [instructions, REFERENCE_MARKER, REFERENCE_LINE, OLD_REFERENCE_LINES], [".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE, []], @@ -237,100 +226,3 @@ function appendLine(content: string, marker: string, line: string, old: readonly } return `${content}${line}${eol}`; } - -/** The command of the hook entries earlier versions wrote, the only one init and uninstall remove. */ -const HOOK_COMMAND = "quest worktree hook"; - -/** A command hook entry in Claude Code's settings: the event it handles and its command. */ -export interface HookEntry { - event: string; - command: string; -} - -const record = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -type Groups = { hooks?: unknown[] }[]; - -/** - * Claude Code settings `text` with its hook groups by event, or undefined when it holds no hooks or is - * malformed, which Claude Code rejects whole, so its hooks never run. - */ -function parseHooks(text: string): { settings: Record; events: [string, Groups][] } | undefined { - let settings: unknown; - try { - settings = JSON.parse(text); - } catch { - return undefined; - } - if (!record(settings) || !record(settings.hooks)) return undefined; - const events = Object.entries(settings.hooks); - const valid = (groups: unknown) => - Array.isArray(groups) && groups.every((group) => record(group) && Array.isArray(group.hooks ?? [])); - if (!events.every(([, groups]) => valid(groups))) return undefined; - return { settings, events: events as [string, Groups][] }; -} - -/** The command a hook handler runs, or undefined when it is not a command hook. */ -function command(handler: unknown): string | undefined { - return record(handler) && handler.type === "command" && typeof handler.command === "string" - ? handler.command - : undefined; -} - -/** - * Claude Code settings `text` without the hook handlers whose command is exactly `quest worktree hook`, - * nor the groups, events, and hooks object they leave empty, along with the handlers removed. Its content - * is null when nothing else remains, and the result undefined when there is nothing to remove or the - * settings are malformed. - */ -export function removeHooks(text: string): { content: string | null; hooks: HookEntry[] } | undefined { - const parsed = parseHooks(text); - if (parsed === undefined) return undefined; - const { settings, events } = parsed; - const removed: HookEntry[] = []; - const kept: [string, unknown][] = []; - for (const [event, groups] of events) { - const before = removed.length; - const remaining = groups.flatMap((group) => { - const handlers = group.hooks ?? []; - const others = handlers.filter((handler) => { - const quest = command(handler) === HOOK_COMMAND; - if (quest) removed.push({ event, command: HOOK_COMMAND }); - return !quest; - }); - if (others.length === handlers.length) return [group]; - return others.length ? [{ ...group, hooks: others }] : []; - }); - if (removed.length === before) kept.push([event, groups]); - else if (remaining.length) kept.push([event, remaining]); - } - if (!removed.length) return undefined; - // Rebuilt rather than edited, so an event named `__proto__` stays an ordinary key. - if (kept.length) settings.hooks = Object.fromEntries(kept); - else delete settings.hooks; - if (Object.keys(settings).length === 0) return { content: null, hooks: removed }; - const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; - const eol = newline(text); - return { content: `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`, hooks: removed }; -} - -/** - * The command hooks in `repo`'s Claude Code settings, other than Quest's own, whose command ends in - * `worktree hook`. One may run Quest through a wrapper, such as moq-dev/moq's - * `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`, or may be another tool's, which no match can - * tell apart, so init and uninstall leave them and warn instead. - */ -export function lookalikeHooks(repo: Checkout): HookEntry[] { - const parsed = repo.stat(CLAUDE_SETTINGS) === "file" ? parseHooks(repo.read(CLAUDE_SETTINGS)) : undefined; - return (parsed?.events ?? []).flatMap(([event, groups]) => - groups.flatMap((group) => - (group.hooks ?? []).flatMap((handler) => { - const found = command(handler); - return found !== undefined && found !== HOOK_COMMAND && found.trimEnd().endsWith("worktree hook") - ? [{ event, command: found }] - : []; - }), - ), - ); -} diff --git a/test/cli.test.ts b/test/cli.test.ts index aeb82dbe..288a0be5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,6 @@ // The command line's own contract: output streams, exit codes, and arguments. -import { mkdirSync, writeFileSync } from "node:fs"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -88,37 +88,11 @@ test("init and uninstall print each changed path and nothing else", () => { expect(init.code).toBe(0); expect(init.stdout).toMatch(/^(\S+\n)+$/); expect(init.stdout).toContain("quest/README.md\nAGENTS.md\n"); - expect(init.stderr).toBe(""); expect(run("--root", root, "init").stdout).toBe(""); expect(run("--root", root, "uninstall").stdout).toMatch(/^(\S+\n)+$/); expect(run("--root", root, "uninstall").stdout).toBe(""); }); -test("init and uninstall name on stderr each hook entry they remove or leave", () => { - const root = tempDir(); - mkdirSync(join(root, ".claude")); - const group = (command: string) => ({ hooks: [{ type: "command", command }] }); - const wrapped = 'nix run ".#quest" -- worktree hook'; - writeFileSync( - join(root, ".claude/settings.json"), - JSON.stringify({ hooks: { WorktreeCreate: [group("quest worktree hook"), group(wrapped)] } }), - ); - const optIn = - "Quest's worktree hooks are now an opt-in in your own Claude settings " + - '(see "Claude Code checkouts" in the README)'; - const left = `quest: left the WorktreeCreate hook \`${wrapped}\` in .claude/settings.json; if it runs Quest, remove it, since ${optIn}\n`; - const init = run("--root", root, "init"); - expect(init.code).toBe(0); - expect(init.stdout).toContain(".claude/settings.json\n"); - expect(init.stderr).toBe( - `quest: removed the WorktreeCreate hook \`quest worktree hook\` from .claude/settings.json; ${optIn}\n${left}`, - ); - const uninstall = run("--root", root, "uninstall"); - expect(uninstall.code).toBe(0); - expect(uninstall.stdout).not.toContain(".claude/settings.json"); - expect(uninstall.stderr).toBe(left); -}); - test("version", () => { expect(run("--version")).toEqual({ code: 0, stdout: `quest ${version}\n`, stderr: "" }); }); diff --git a/test/init.test.ts b/test/init.test.ts index f0853c28..3ceb2d7b 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,12 +14,10 @@ import { dirname, join } from "node:path"; import { expect, test } from "vitest"; -import { REFERENCE_LINE, init as apply, lookalikes, uninstall } from "../src/cli/setup"; +import { REFERENCE_LINE, init, uninstall } from "../src/cli/setup"; import { SKILLS } from "../src/cli/skills"; import { tempDir } from "./fixture"; -/** The paths `quest init` changed under `dir`. */ -const init = (dir: string) => apply(dir).map((change) => change.path); const read = (dir: string, path: string) => readFileSync(join(dir, path), "utf8"); const isFile = (dir: string, path: string) => existsSync(join(dir, path)) && lstatSync(join(dir, path)).isFile(); const isSymlink = (dir: string, path: string) => { @@ -381,125 +379,13 @@ test("uninstall removes stubs that older versions installed", () => { expect(existsSync(join(dir, ".agents"))).toBe(false); }); -const handler = (command: string) => ({ type: "command", command }); -const group = (command: string) => ({ hooks: [handler(command)] }); -const QUEST = "quest worktree hook"; -const WRAPPED = 'nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook'; -const SESSION = group("bash .claude/hooks/direnv.sh"); - -function settings(dir: string, value: unknown, indent: string | number = "\t"): string { - mkdirSync(join(dir, ".claude"), { recursive: true }); - const text = `${JSON.stringify(value, null, indent)}\n`; - writeFileSync(join(dir, ".claude/settings.json"), text); - return text; -} - -test("init writes no hooks", () => { - const dir = tempDir(); - expect(init(dir)).not.toContain(".claude/settings.json"); - expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); -}); - -test("init removes the hooks an earlier version wrote, on any event, and the file they leave empty", () => { +test("init and uninstall leave Claude Code's project settings alone", () => { const dir = tempDir(); - settings(dir, { - hooks: { - WorktreeCreate: [group(QUEST)], - WorktreeRemove: [group(QUEST)], - SubagentStart: [group(QUEST)], - SubagentStop: [{ hooks: [handler(QUEST), handler(QUEST)] }], - SessionEnd: [group(QUEST)], - }, - }); - const change = apply(dir).find((change) => change.path === ".claude/settings.json"); - expect(change?.hooks).toEqual( - ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop", "SubagentStop", "SessionEnd"].map( - (event) => ({ event, command: QUEST }), - ), - ); + init(dir); expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); - expect(isFile(dir, ".claude/skills/quest-start/SKILL.md")).toBe(true); - expect(init(dir)).toEqual([]); - expect(lookalikes(dir)).toEqual([]); -}); - -test("init and uninstall keep every other hook, reporting those that end in worktree hook", () => { - // Commands that may wrap Quest's hook, or may be another tool's: neither is removed. - const similar = { - WorktreeCreate: [group(WRAPPED), group("othertool worktree hook")], - WorktreeRemove: [group(" quest worktree hook"), group("cd quest && othertool worktree hook ")], - SubagentStop: [SESSION, { matcher: "", hooks: [handler("quest-like worktree hook")] }], - }; - const others = { - SessionStart: [SESSION], - PreCompact: [], - Stop: [group("quest worktree hook --verbose"), { hooks: [{ type: "prompt", prompt: "quest worktree hook" }] }], - }; - const kept = { - $schema: "https://json.schemastore.org/claude-code-settings.json", - hooks: { ...others, ...similar }, - }; - const reported = [ - { event: "WorktreeCreate", command: WRAPPED }, - { event: "WorktreeCreate", command: "othertool worktree hook" }, - { event: "WorktreeRemove", command: " quest worktree hook" }, - { event: "WorktreeRemove", command: "cd quest && othertool worktree hook " }, - { event: "SubagentStop", command: "quest-like worktree hook" }, - ]; - for (const remove of [init, uninstall]) { - const dir = tempDir(); - settings(dir, { - $schema: kept.$schema, - hooks: { - ...others, - ...similar, - SubagentStart: [group(QUEST)], - SubagentStop: [SESSION, { matcher: "", hooks: [handler(QUEST), handler("quest-like worktree hook")] }], - }, - }); - expect(remove(dir)).toContain(".claude/settings.json"); - expect(read(dir, ".claude/settings.json")).toBe(`${JSON.stringify(kept, null, "\t")}\n`); - expect(lookalikes(dir)).toEqual(reported); - expect(remove(dir)).not.toContain(".claude/settings.json"); - } -}); - -test("init and uninstall leave settings without Quest's hooks untouched", () => { - for (const content of [ - "{", - "[]", - JSON.stringify({ hooks: { SubagentStop: {} } }), - JSON.stringify({ hooks: { SubagentStop: [group(QUEST)], SessionStart: "x" } }), - '{ "hooks": { "SessionStart": [] } }\r\n', - ]) { - for (const remove of [init, uninstall]) { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - writeFileSync(join(dir, ".claude/settings.json"), content); - expect(remove(dir)).not.toContain(".claude/settings.json"); - expect(read(dir, ".claude/settings.json")).toBe(content); - } - } -}); - -test("removing hooks keeps an event named __proto__ as an ordinary key", () => { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - const proto = `"__proto__": [${JSON.stringify(SESSION)}]`; - writeFileSync( - join(dir, ".claude/settings.json"), - `{"hooks": {${proto}, "WorktreeCreate": [${JSON.stringify(group(QUEST))}]}}`, - ); - uninstall(dir); - const hooks = JSON.parse(read(dir, ".claude/settings.json")).hooks; - expect(Object.keys(hooks)).toEqual(["__proto__"]); - expect(Object.getOwnPropertyDescriptor(hooks, "__proto__")?.value).toEqual([SESSION]); -}); - -test("removing hooks keeps CRLF line endings and indentation", () => { - const dir = tempDir(); - const text = settings(dir, { model: "x", hooks: { WorktreeCreate: [group(QUEST)] } }, 4).replaceAll("\n", "\r\n"); - writeFileSync(join(dir, ".claude/settings.json"), text); + const text = `${JSON.stringify({ hooks: { WorktreeCreate: [{ hooks: [{ type: "command", command: "quest worktree hook" }] }] } })}\n`; + write(dir, ".claude/settings.json", text); init(dir); - expect(read(dir, ".claude/settings.json")).toBe('{\r\n "model": "x"\r\n}\r\n'); + uninstall(dir); + expect(read(dir, ".claude/settings.json")).toBe(text); });