diff --git a/README.md b/README.md index 4c160a8..752e4da 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ open-plan-annotator agent-setup open-plan-annotator help agent ``` +### Skipping the Annotator + +Set `OPEN_PLAN_ANNOTATOR_SKIP_FILE` to a file path. While that file exists, the Claude Code hook exits without a decision and Claude Code shows its native plan approval instead. The file is checked on every hook call, so you can toggle it mid session, for example while continuing a session through Remote Control where the local browser UI is unreachable: + +```sh +export OPEN_PLAN_ANNOTATOR_SKIP_FILE="$HOME/.open-plan-annotator-skip" +touch "$OPEN_PLAN_ANNOTATOR_SKIP_FILE" # use native approval +rm "$OPEN_PLAN_ANNOTATOR_SKIP_FILE" # back to the annotator +``` + ## Updates - **Claude Code**: navigate to the `/plugin` -> "Installed" -> `open-plan-annotator` entry and select "Update", then restart Claude Code. diff --git a/bin/open-plan-annotator.mjs b/bin/open-plan-annotator.mjs index f62584d..c4f2b52 100755 --- a/bin/open-plan-annotator.mjs +++ b/bin/open-plan-annotator.mjs @@ -11,6 +11,7 @@ import { codesignFixCommand, ensureValidCodeSignature, hasValidCodeSignature } f import { detectPackageManager } from "../shared/packageManager.mjs"; import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs"; import { buildRuntimeEnv } from "../shared/runtimeEnv.mjs"; +import { shouldSkipHook } from "../shared/skipHook.mjs"; import { buildUpdateMessage } from "../shared/updateMessage.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -51,6 +52,10 @@ if (cliMode === "unknown") { process.exit(1); } +if (cliMode === "hook" && shouldSkipHook()) { + process.exit(0); +} + // Buffer stdin immediately so it's not lost if we need to download first. // Skip when stdin is a TTY (manual invocation) to avoid blocking forever. let stdinBuffer; @@ -102,6 +107,7 @@ function runRuntime(binaryPath, { allowResign }) { env: childEnv, }); + child.stdin.on("error", () => {}); child.stdin.write(stdinBuffer); child.stdin.end(); diff --git a/bin/open-plan-annotator.test.ts b/bin/open-plan-annotator.test.ts index b0dad84..71ad256 100644 --- a/bin/open-plan-annotator.test.ts +++ b/bin/open-plan-annotator.test.ts @@ -36,11 +36,11 @@ interface WrapperResult { stdioClosedAfterMs: number; } -function runHook(root: string): Promise { +function runHook(root: string, env: NodeJS.ProcessEnv = {}): Promise { const start = Date.now(); const child = spawn("node", [path.join(root, "bin", "open-plan-annotator.mjs")], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OPEN_PLAN_ANNOTATOR_SKIP_INSTALL: "1" }, + env: { ...process.env, OPEN_PLAN_ANNOTATOR_SKIP_INSTALL: "1", ...env }, }); child.stdin.end(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "ExitPlanMode", tool_input: {} })); @@ -87,4 +87,15 @@ describe.if(process.platform !== "win32")("open-plan-annotator wrapper (hook mod expect(result.stdout).toBe('{"ok":true}\n'); expect(result.stdioClosedAfterMs).toBeLessThan(2000); }); + + test("exits 0 without running the runtime when the skip file exists", async () => { + const root = createPluginTree("#!/bin/sh\necho '{\"ok\":true}'\n"); + const skipFile = path.join(root, "skip"); + fs.writeFileSync(skipFile, ""); + + const result = await runHook(root, { OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); + }); }); diff --git a/package.json b/package.json index 1b258b9..e7c0da4 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "shared/runtimeResolver.mjs", "shared/macosCodesign.mjs", "shared/runtimeEnv.mjs", + "shared/skipHook.mjs", "shared/updateHints.mjs", "shared/versionInfo.mjs", "shared/planReview.mjs", diff --git a/shared/skipHook.mjs b/shared/skipHook.mjs new file mode 100644 index 0000000..1b34d36 --- /dev/null +++ b/shared/skipHook.mjs @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function shouldSkipHook(env = process.env) { + const skipFile = env.OPEN_PLAN_ANNOTATOR_SKIP_FILE; + return Boolean(skipFile) && fs.existsSync(skipFile); +} diff --git a/shared/skipHook.test.ts b/shared/skipHook.test.ts new file mode 100644 index 0000000..79c56d3 --- /dev/null +++ b/shared/skipHook.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { shouldSkipHook } from "./skipHook.mjs"; + +describe("shouldSkipHook", () => { + test("does not skip when the env var is unset", () => { + expect(shouldSkipHook({})).toBe(false); + }); + + test("does not skip when the file is missing", () => { + const skipFile = path.join(os.tmpdir(), "opa-skip-missing"); + expect(shouldSkipHook({ OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile })).toBe(false); + }); + + test("skips when the file exists", () => { + const skipFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "opa-skip-")), "skip"); + fs.writeFileSync(skipFile, ""); + expect(shouldSkipHook({ OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile })).toBe(true); + }); +});