open-plan-annotator 1.0.17 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,19 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Interactive plan annotation plugin for Claude Code",
8
- "version": "1.0.17"
8
+ "version": "1.1.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "open-plan-annotator",
13
- "source": "./",
13
+ "source": {
14
+ "npm": {
15
+ "package": "open-plan-annotator",
16
+ "version": "1.1.0"
17
+ }
18
+ },
14
19
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
15
- "version": "1.0.17",
20
+ "version": "1.1.0",
16
21
  "author": {
17
22
  "name": "ndom91"
18
23
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
3
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
4
- "version": "1.0.17",
4
+ "version": "1.1.0",
5
5
  "author": {
6
6
  "name": "ndom91"
7
7
  },
package/README.md CHANGED
@@ -23,11 +23,9 @@ Everything runs locally. Nothing leaves your machine.
23
23
  ## Install
24
24
 
25
25
  > [!NOTE]
26
- > The first run might take up to 30s to pop open the UI. If your agentic coding
27
- > tool installed it via pnpm, they block post-install scripts by default so
28
- > Claude / OpenCode will trigger the download of the correct binary for your
29
- > platform upon first use, i.e. when first transitioning out of plan mode.
30
- > All subsequent runs will be instant.
26
+ > `open-plan-annotator` now ships as one package-managed install. The npm package
27
+ > contains the plugin glue and resolves a platform runtime package locally. There
28
+ > is no first-run binary download and no in-app self-update path.
31
29
 
32
30
  ### Claude Code
33
31
 
@@ -38,7 +36,7 @@ From within Claude Code, add the marketplace and install the plugin:
38
36
  /plugin install open-plan-annotator@ndom91-open-plan-annotator
39
37
  ```
40
38
 
41
- This registers the `ExitPlanMode` hook that launches the annotation UI.
39
+ This installs the npm-backed plugin and registers the `ExitPlanMode` hook that launches the annotation UI.
42
40
 
43
41
  ### OpenCode
44
42
 
@@ -57,6 +55,8 @@ OpenCode will install the package and load it automatically. The plugin:
57
55
  - Returns structured feedback to the agent on approval or rejection
58
56
  - Optionally hands off to an implementation agent after approval
59
57
 
58
+ To update, refresh the plugin through OpenCode and restart the app so it reloads the latest package-managed runtime.
59
+
60
60
  #### Implementation Handoff
61
61
 
62
62
  By default, after a plan is approved the plugin sends "Proceed with implementation." to a `build` agent. To customize or disable this, create `open-plan-annotator.json` in your project's `.opencode/` directory or globally in `~/.config/opencode/`:
@@ -74,7 +74,7 @@ Set `enabled` to `false` to disable auto-handoff. Project config overrides globa
74
74
 
75
75
  ### Manual Install
76
76
 
77
- If you want to run the binary standalone or build from source:
77
+ If you want to run the CLI standalone or install the package globally:
78
78
 
79
79
  ```sh
80
80
  npm install -g open-plan-annotator
@@ -95,6 +95,18 @@ Then load it directly in Claude Code:
95
95
  claude --plugin-dir ./open-plan-annotator
96
96
  ```
97
97
 
98
+ ## Updates
99
+
100
+ - OpenCode: update the installed npm plugin through OpenCode, then restart OpenCode.
101
+ - Claude Code: update the marketplace/plugin install, then restart Claude Code.
102
+ - Standalone/global install: update the npm package (`npm`, `pnpm`, or `bun`), then rerun `open-plan-annotator`.
103
+
104
+ The built-in `doctor` command reports the resolved runtime package and runtime path:
105
+
106
+ ```sh
107
+ open-plan-annotator doctor
108
+ ```
109
+
98
110
  ## Keyboard Shortcuts
99
111
 
100
112
  | Action | Shortcut | Description |
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execFileSync, spawn } from "node:child_process";
3
+ import { spawn } from "node:child_process";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { buildCliHelpText, buildUnknownCommandPrefix } from "../shared/cliHelp.mjs";
8
8
  import { resolveCliMode } from "../shared/cliMode.mjs";
9
+ import { detectPackageManager } from "../shared/packageManager.mjs";
10
+ import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
11
+ import { buildUpdateInstructions } from "../shared/updateHints.mjs";
9
12
 
10
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const binaryPath = path.join(__dirname, "open-plan-annotator-binary");
12
- const installScript = path.join(__dirname, "..", "install.mjs");
13
14
  const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
14
15
 
15
16
  const arg = process.argv[2];
@@ -25,6 +26,11 @@ if (cliMode === "help") {
25
26
  process.exit(0);
26
27
  }
27
28
 
29
+ if (cliMode === "doctor") {
30
+ printDoctor();
31
+ process.exit(0);
32
+ }
33
+
28
34
  if (cliMode === "unknown") {
29
35
  console.error(buildUnknownCommandPrefix(arg));
30
36
  console.error("Run `open-plan-annotator --help` for usage.");
@@ -44,65 +50,27 @@ if (cliMode === "hook") {
44
50
  stdinBuffer = Buffer.alloc(0);
45
51
  }
46
52
 
47
- let justInstalled = false;
48
- if (!fs.existsSync(binaryPath)) {
49
- // Auto-download the binary (handles pnpm blocking postinstall)
50
- console.error("open-plan-annotator: binary not found, downloading...");
51
- try {
52
- execFileSync(process.execPath, [installScript], {
53
- stdio: ["ignore", 2, "inherit"],
54
- });
55
- } catch (e) {
56
- console.error(
57
- "\nopen-plan-annotator: failed to download binary.\n" +
58
- "Try running manually: node " + installScript + "\n"
59
- );
60
- process.exit(1);
61
- }
62
-
63
- if (!fs.existsSync(binaryPath)) {
64
- console.error(
65
- "open-plan-annotator: binary still not found after install.\n" +
66
- "Try running manually: node " + installScript + "\n"
67
- );
68
- process.exit(1);
69
- }
70
- justInstalled = true;
71
- }
72
-
73
- // Handle `open-plan-annotator update|upgrade` subcommand
74
53
  if (cliMode === "update") {
75
- if (justInstalled) {
76
- console.log("Binary installed (v" + VERSION + ")");
77
- process.exit(0);
78
- }
79
- try {
80
- execFileSync(binaryPath, ["update"], {
81
- stdio: "inherit",
82
- env: { ...process.env, OPEN_PLAN_PKG_MANAGER: detectPackageManager() },
83
- });
84
- } catch (e) {
85
- process.exit(e.status || 1);
86
- }
54
+ console.log(buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager({ installPath: fileURLToPath(import.meta.url) }) }));
87
55
  process.exit(0);
88
56
  }
89
57
 
90
- // Detect package manager so the binary can suggest the right update command
91
- function detectPackageManager() {
92
- const ua = process.env.npm_config_user_agent || "";
93
- if (ua.startsWith("pnpm")) return "pnpm";
94
- if (ua.startsWith("yarn")) return "yarn";
95
- if (ua.startsWith("bun")) return "bun";
96
- return "npm";
58
+ let runtime;
59
+ try {
60
+ runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
61
+ } catch (error) {
62
+ console.error(`open-plan-annotator: ${error instanceof Error ? error.message : String(error)}`);
63
+ process.exit(1);
97
64
  }
98
65
 
99
- // Spawn the binary with detached so it can outlive this wrapper.
100
- // We pipe stdout to detect the JSON hook output, then forward it and exit
101
- // immediately — the binary keeps its server alive in the background.
102
- const child = spawn(binaryPath, process.argv.slice(2), {
66
+ const child = spawn(runtime.binaryPath, process.argv.slice(2), {
103
67
  stdio: ["pipe", "pipe", "inherit"],
104
68
  detached: true,
105
- env: { ...process.env, OPEN_PLAN_PKG_MANAGER: detectPackageManager() },
69
+ env: {
70
+ ...process.env,
71
+ OPEN_PLAN_HOST: process.env.OPEN_PLAN_HOST || "claude-code",
72
+ OPEN_PLAN_PKG_MANAGER: detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
73
+ },
106
74
  });
107
75
 
108
76
  child.stdin.write(stdinBuffer);
@@ -149,3 +117,26 @@ child.on("error", (err) => {
149
117
  console.error("open-plan-annotator: failed to spawn binary:", err.message);
150
118
  process.exit(1);
151
119
  });
120
+
121
+ function printDoctor() {
122
+ const platformKey = `${process.platform}-${process.arch}`;
123
+
124
+ try {
125
+ const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
126
+ console.log([
127
+ `open-plan-annotator v${VERSION}`,
128
+ `platform: ${platformKey}`,
129
+ `runtime package: ${runtime.packageName}`,
130
+ `runtime path: ${runtime.binaryPath}`,
131
+ `update: ${buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager({ installPath: fileURLToPath(import.meta.url) }) })}`,
132
+ ].join("\n"));
133
+ } catch (error) {
134
+ console.log([
135
+ `open-plan-annotator v${VERSION}`,
136
+ `platform: ${platformKey}`,
137
+ `runtime: missing`,
138
+ `error: ${error instanceof Error ? error.message : String(error)}`,
139
+ `update: ${buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager({ installPath: fileURLToPath(import.meta.url) }) })}`,
140
+ ].join("\n"));
141
+ }
142
+ }
@@ -1,15 +1,12 @@
1
- import { execFileSync, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync, statSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { detectPackageManager } from "../shared/packageManager.mjs";
7
+ import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
6
8
 
7
9
  const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
8
- const LOCAL_BINARY_PATH = join(PKG_ROOT, "bin", "open-plan-annotator-binary");
9
- const INSTALL_SCRIPT = join(PKG_ROOT, "install.mjs");
10
-
11
- /** Resolved path to the binary (may differ from LOCAL_BINARY_PATH if found on PATH). */
12
- let BINARY_PATH = LOCAL_BINARY_PATH;
13
10
 
14
11
  /**
15
12
  * @typedef {{
@@ -102,84 +99,11 @@ function validateHookOutput(value) {
102
99
  throw new Error("unsupported decision payload");
103
100
  }
104
101
 
105
- /**
106
- * Check if `open-plan-annotator` is available on PATH.
107
- * The CLI wrapper handles binary discovery/download and stdin/stdout
108
- * forwarding, so we can spawn it directly as a fallback when the local
109
- * binary isn't available (e.g. OpenCode loads the plugin from its own
110
- * node_modules but the binary only exists in a global pnpm install).
111
- * @returns {string | undefined}
112
- */
113
- function findWrapperOnPath() {
114
- const cmd = process.platform === "win32" ? "where" : "which";
115
- try {
116
- const result = execFileSync(cmd, ["open-plan-annotator"], {
117
- encoding: "utf-8",
118
- stdio: ["ignore", "pipe", "ignore"],
119
- }).trim().split("\n")[0];
120
- return result || undefined;
121
- } catch {
122
- return undefined;
123
- }
124
- }
125
-
126
- function detectPackageManager() {
127
- const ua = process.env.npm_config_user_agent || "";
128
- if (ua.startsWith("pnpm")) return "pnpm";
129
- if (ua.startsWith("yarn")) return "yarn";
130
- if (ua.startsWith("bun")) return "bun";
131
- return "npm";
132
- }
133
-
134
- /** Ensure the compiled binary exists, downloading if necessary. */
135
- function ensureBinary() {
136
- if (existsSync(LOCAL_BINARY_PATH)) {
137
- BINARY_PATH = LOCAL_BINARY_PATH;
138
- return;
139
- }
140
-
141
- // Try to find node on PATH for running the install script
142
- try {
143
- execFileSync(process.execPath, [INSTALL_SCRIPT], {
144
- cwd: PKG_ROOT,
145
- stdio: ["ignore", "pipe", "pipe"],
146
- });
147
- } catch {
148
- // Retry with "node" explicitly in case process.execPath is bun
149
- try {
150
- execFileSync("node", [INSTALL_SCRIPT], {
151
- cwd: PKG_ROOT,
152
- stdio: ["ignore", "pipe", "pipe"],
153
- });
154
- } catch {
155
- // ignore — we'll check below
156
- }
157
- }
158
-
159
- if (existsSync(LOCAL_BINARY_PATH)) {
160
- BINARY_PATH = LOCAL_BINARY_PATH;
161
- return;
162
- }
163
-
164
- // Fallback: use the CLI wrapper from PATH (e.g. global pnpm install).
165
- // The wrapper handles binary discovery/download and stdio forwarding.
166
- const wrapperPath = findWrapperOnPath();
167
- if (wrapperPath) {
168
- BINARY_PATH = wrapperPath;
169
- return;
170
- }
171
-
172
- throw new Error(
173
- `open-plan-annotator: binary not found at ${LOCAL_BINARY_PATH}. ` +
174
- `Try running: node ${INSTALL_SCRIPT}`,
175
- );
176
- }
177
-
178
102
  /**
179
103
  * @param {{ plan: string, sessionId?: string, cwd?: string }} options
180
104
  */
181
105
  export async function runPlanReview(options) {
182
- ensureBinary();
106
+ const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
183
107
 
184
108
  const payload = buildHookPayload(options);
185
109
 
@@ -197,12 +121,14 @@ export async function runPlanReview(options) {
197
121
 
198
122
  // Spawn detached so the binary can outlive this call — it keeps its
199
123
  // HTTP server alive for ~10s after emitting the JSON hook response.
200
- const child = spawn(BINARY_PATH, [], {
124
+ const child = spawn(runtime.binaryPath, [], {
201
125
  cwd,
202
126
  stdio: ["pipe", "pipe", "pipe"],
203
127
  env: {
204
128
  ...process.env,
205
- OPEN_PLAN_PKG_MANAGER: process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager(),
129
+ OPEN_PLAN_HOST: "opencode",
130
+ OPEN_PLAN_PKG_MANAGER:
131
+ process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
206
132
  },
207
133
  detached: true,
208
134
  });
package/opencode/index.js CHANGED
@@ -162,7 +162,7 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => {
162
162
  tool: {
163
163
  submit_plan: tool({
164
164
  description:
165
- "Submit a markdown plan for interactive user review. Returns a structured result with plan_status, next_state, feedback, and guidance fields.",
165
+ "Submit a markdown plan for interactive user review. Returns plain-text execution or revision instructions for the agent.",
166
166
 
167
167
  args: {
168
168
  plan: tool.schema.string().describe("The complete implementation plan in markdown format"),
@@ -178,17 +178,8 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => {
178
178
 
179
179
  const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested.");
180
180
 
181
- const basePayload = {
182
- plan_status: result.approved ? "approved" : "rejected",
183
- next_state: result.approved ? "EXECUTION" : "PLAN_DRAFT",
184
- feedback,
185
- };
186
-
187
181
  if (result.approved) {
188
- const lines = [
189
- "Plan approved by the user.",
190
- "Do NOT call `submit_plan` again. The planning phase is finished.",
191
- ];
182
+ const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."];
192
183
 
193
184
  if (args.summary) {
194
185
  lines.push(`Summary: ${args.summary}`);
@@ -203,25 +194,23 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => {
203
194
  }
204
195
  }
205
196
 
206
- lines.push("Begin implementing the approved plan now write code and make changes.");
207
- return {
208
- ...basePayload,
209
- guidance: lines.join("\n\n"),
210
- };
197
+ lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan.");
198
+ lines.push("Execute the approved plan directly now — write code, create files, and make changes.");
199
+ lines.push("Do not call `submit_plan` again unless the user explicitly requests re-planning.");
200
+
201
+ return lines.join("\n\n");
211
202
  }
212
203
 
213
- return {
214
- ...basePayload,
215
- guidance: [
216
- "Plan needs revision.",
217
- "",
218
- "## User feedback",
219
- "",
220
- feedback,
221
- "",
222
- "Revise the plan using this feedback, then submit the revised draft once via `submit_plan`.",
223
- ].join("\n"),
224
- };
204
+ return [
205
+ "Plan review status: plan_status=rejected.",
206
+ "State transition: next_state=PLAN_DRAFT.",
207
+ "",
208
+ "## User feedback",
209
+ "",
210
+ feedback,
211
+ "",
212
+ "Revise the plan using this feedback, then submit the revised draft once via `submit_plan`.",
213
+ ].join("\n");
225
214
  },
226
215
  }),
227
216
  },
@@ -19,8 +19,8 @@ function createPluginContext() {
19
19
  };
20
20
  }
21
21
 
22
- describe("submit_plan return schema contract", () => {
23
- test("approved decision maps to approved execution payload", async () => {
22
+ describe("submit_plan tool output", () => {
23
+ test("returns plain text execution instructions after approval", async () => {
24
24
  mock.module("./bridge.js", () => ({
25
25
  runPlanReview: async () => ({ approved: true }),
26
26
  }));
@@ -30,17 +30,15 @@ describe("submit_plan return schema contract", () => {
30
30
 
31
31
  const result = await plugin.tool.submit_plan.execute({ plan: "# Plan" }, { sessionID: "session-1" });
32
32
 
33
- expect(result.plan_status).toBe("approved");
34
- expect(result.next_state).toBe("EXECUTION");
35
- expect(result.feedback).toBe("");
36
- expect(result.guidance).toContain("Plan approved by the user.");
33
+ expect(typeof result).toBe("string");
34
+ expect(result).toContain("plan_status=approved");
35
+ expect(result).toContain("next_state=EXECUTION");
36
+ expect(result).toContain("Do not call `submit_plan` again");
37
37
  });
38
38
 
39
- test("rejected decision maps to plan redraft payload with bridge feedback", async () => {
40
- const bridgeFeedback = "Need to add rollback steps.";
41
-
39
+ test("returns plain text revision instructions after rejection", async () => {
42
40
  mock.module("./bridge.js", () => ({
43
- runPlanReview: async () => ({ approved: false, feedback: bridgeFeedback }),
41
+ runPlanReview: async () => ({ approved: false, feedback: "Need rollback steps." }),
44
42
  }));
45
43
 
46
44
  const { OpenPlanAnnotatorPlugin } = await import(`./index.js?rejected-${Date.now()}`);
@@ -48,29 +46,9 @@ describe("submit_plan return schema contract", () => {
48
46
 
49
47
  const result = await plugin.tool.submit_plan.execute({ plan: "# Plan" }, { sessionID: "session-2" });
50
48
 
51
- expect(result.plan_status).toBe("rejected");
52
- expect(result.next_state).toBe("PLAN_DRAFT");
53
- expect(result.feedback).toBe(bridgeFeedback);
54
- expect(result.guidance).toContain("## User feedback");
55
- });
56
-
57
- test("all top-level submit_plan fields are strings for display safety", async () => {
58
- mock.module("./bridge.js", () => ({
59
- runPlanReview: async () => ({ approved: true }),
60
- }));
61
-
62
- const { OpenPlanAnnotatorPlugin } = await import(`./index.js?display-safe-${Date.now()}`);
63
- const plugin = await OpenPlanAnnotatorPlugin(createPluginContext());
64
-
65
- const result = await plugin.tool.submit_plan.execute(
66
- { plan: "# Plan", summary: "Short summary" },
67
- { sessionID: "session-3" },
68
- );
69
-
70
- for (const value of Object.values(result)) {
71
- expect(typeof value).toBe("string");
72
- }
73
-
74
- expect(result).not.toHaveProperty("approved");
49
+ expect(typeof result).toBe("string");
50
+ expect(result).toContain("plan_status=rejected");
51
+ expect(result).toContain("next_state=PLAN_DRAFT");
52
+ expect(result).toContain("Need rollback steps.");
75
53
  });
76
54
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
- "version": "1.0.17",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Fully local plugin for interactive plan annotation from your Agentic assistants",
6
6
  "author": "ndom91",
@@ -17,13 +17,15 @@
17
17
  "annotation",
18
18
  "code-review"
19
19
  ],
20
+ "workspaces": [
21
+ "packages/*"
22
+ ],
20
23
  "main": "opencode/index.js",
21
24
  "bin": {
22
25
  "open-plan-annotator": "bin/open-plan-annotator.mjs"
23
26
  },
24
27
  "files": [
25
28
  "bin/open-plan-annotator.mjs",
26
- "install.mjs",
27
29
  "shared/",
28
30
  ".claude-plugin/",
29
31
  "opencode/",
@@ -32,14 +34,12 @@
32
34
  "README.md"
33
35
  ],
34
36
  "scripts": {
35
- "postinstall": "node install.mjs",
36
37
  "test": "bun test",
37
38
  "typecheck": "tsgo --noEmit --project ui/tsconfig.json && tsgo --noEmit --project server/tsconfig.json",
38
39
  "build:ui": "cd ui && bun run vite build",
39
40
  "build:platforms": "node scripts/build-platforms.mjs",
40
41
  "build": "bun run build:ui && node scripts/build-platforms.mjs",
41
- "tarball": "node scripts/tarball.mjs",
42
- "release": "bun run build && node scripts/tarball.mjs",
42
+ "release": "bun run build",
43
43
  "pack:check": "node scripts/check-package-files.mjs",
44
44
  "dev:ui": "cd ui && bun run vite --port 5173",
45
45
  "dev:server": "NODE_ENV=development bun run server/index.ts",
@@ -59,5 +59,11 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@opencode-ai/plugin": "^1.2.14"
62
+ },
63
+ "optionalDependencies": {
64
+ "@open-plan-annotator/runtime-darwin-arm64": "1.1.0",
65
+ "@open-plan-annotator/runtime-darwin-x64": "1.1.0",
66
+ "@open-plan-annotator/runtime-linux-arm64": "1.1.0",
67
+ "@open-plan-annotator/runtime-linux-x64": "1.1.0"
62
68
  }
63
69
  }
@@ -3,7 +3,8 @@ const REPOSITORY_URL = "https://github.com/ndom91/open-plan-annotator";
3
3
  const HELP_USAGE_LINES = [
4
4
  "open-plan-annotator Show this help",
5
5
  "open-plan-annotator < event.json Run as a Claude Code hook (debug)",
6
- "open-plan-annotator update Update the binary to the latest version",
6
+ "open-plan-annotator doctor Show resolved runtime details",
7
+ "open-plan-annotator update Show package-managed update guidance",
7
8
  "open-plan-annotator upgrade Alias for update",
8
9
  "open-plan-annotator --version Print version",
9
10
  "open-plan-annotator --help Show this help",
@@ -8,7 +8,8 @@ describe("buildCliHelpText", () => {
8
8
  Usage:
9
9
  open-plan-annotator Show this help
10
10
  open-plan-annotator < event.json Run as a Claude Code hook (debug)
11
- open-plan-annotator update Update the binary to the latest version
11
+ open-plan-annotator doctor Show resolved runtime details
12
+ open-plan-annotator update Show package-managed update guidance
12
13
  open-plan-annotator upgrade Alias for update
13
14
  open-plan-annotator --version Print version
14
15
  open-plan-annotator --help Show this help
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @typedef {"hook" | "update" | "help" | "version" | "unknown"} CliMode
2
+ * @typedef {"hook" | "update" | "doctor" | "help" | "version" | "unknown"} CliMode
3
3
  */
4
4
 
5
5
  /**
@@ -17,6 +17,7 @@ export function resolveCliMode(arg, options = {}) {
17
17
  }
18
18
 
19
19
  if (arg === "update" || arg === "upgrade") return "update";
20
+ if (arg === "doctor") return "doctor";
20
21
  if (arg === "--help" || arg === "-h") return "help";
21
22
  if (arg === "--version" || arg === "-v") return "version";
22
23
  return "unknown";
@@ -18,6 +18,10 @@ describe("resolveCliMode", () => {
18
18
  expect(resolveCliMode("upgrade")).toBe("update");
19
19
  });
20
20
 
21
+ test("recognizes doctor subcommand", () => {
22
+ expect(resolveCliMode("doctor")).toBe("doctor");
23
+ });
24
+
21
25
  test("recognizes help and version flags", () => {
22
26
  expect(resolveCliMode("--help")).toBe("help");
23
27
  expect(resolveCliMode("-h")).toBe("help");
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ function detectFromValue(value) {
5
+ if (!value) return undefined;
6
+ const normalized = value.toLowerCase();
7
+ if (normalized.includes("pnpm")) return "pnpm";
8
+ if (normalized.includes("yarn")) return "yarn";
9
+ if (normalized.includes("bun")) return "bun";
10
+ if (normalized.includes("npm")) return "npm";
11
+ return undefined;
12
+ }
13
+
14
+ export function detectPackageManager(options = {}) {
15
+ const env = options.env ?? process.env;
16
+ const installPath = options.installPath;
17
+
18
+ const explicit = detectFromValue(env.OPEN_PLAN_PKG_MANAGER);
19
+ if (explicit) return explicit;
20
+
21
+ const userAgent = env.npm_config_user_agent;
22
+ if (typeof userAgent === "string") {
23
+ const name = userAgent.split("/")[0];
24
+ const detected = detectFromValue(name);
25
+ if (detected) return detected;
26
+ }
27
+
28
+ const packageManager = detectFromValue(env.npm_package_manager);
29
+ if (packageManager) return packageManager;
30
+
31
+ const execPath = detectFromValue(env.npm_execpath);
32
+ if (execPath) return execPath;
33
+
34
+ const resolvedInstallPath = installPath ?? fileURLToPath(import.meta.url);
35
+ const pathHint = detectFromValue(path.normalize(resolvedInstallPath));
36
+ if (pathHint) return pathHint;
37
+
38
+ return "npm";
39
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detectPackageManager } from "./packageManager.mjs";
3
+
4
+ describe("detectPackageManager", () => {
5
+ test("prefers explicit OPEN_PLAN_PKG_MANAGER", () => {
6
+ expect(detectPackageManager({ env: { OPEN_PLAN_PKG_MANAGER: "pnpm" } })).toBe("pnpm");
7
+ });
8
+
9
+ test("detects package manager from npm user agent", () => {
10
+ expect(detectPackageManager({ env: { npm_config_user_agent: "pnpm/10.0.0 node/v22.0.0" } })).toBe("pnpm");
11
+ });
12
+
13
+ test("detects package manager from npm execpath", () => {
14
+ expect(detectPackageManager({ env: { npm_execpath: "/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs" } })).toBe(
15
+ "pnpm",
16
+ );
17
+ });
18
+
19
+ test("detects package manager from install path hints", () => {
20
+ expect(
21
+ detectPackageManager({
22
+ env: {},
23
+ installPath: "/Users/test/Library/pnpm/global/5/node_modules/open-plan-annotator/bin/open-plan-annotator.mjs",
24
+ }),
25
+ ).toBe("pnpm");
26
+ });
27
+
28
+ test("falls back to npm", () => {
29
+ expect(detectPackageManager({ env: {}, installPath: "/tmp/open-plan-annotator/bin/open-plan-annotator.mjs" })).toBe(
30
+ "npm",
31
+ );
32
+ });
33
+ });
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const RUNTIME_PACKAGE_MAP = {
7
+ "darwin-arm64": "@open-plan-annotator/runtime-darwin-arm64",
8
+ "darwin-x64": "@open-plan-annotator/runtime-darwin-x64",
9
+ "linux-arm64": "@open-plan-annotator/runtime-linux-arm64",
10
+ "linux-x64": "@open-plan-annotator/runtime-linux-x64",
11
+ };
12
+
13
+ export function getRuntimePlatformKey(platform = process.platform, arch = process.arch) {
14
+ return `${platform}-${arch}`;
15
+ }
16
+
17
+ export function getRuntimePackageName(platform = process.platform, arch = process.arch) {
18
+ return RUNTIME_PACKAGE_MAP[getRuntimePlatformKey(platform, arch)];
19
+ }
20
+
21
+ export function resolveRuntimeBinary(options = {}) {
22
+ const platform = options.platform ?? process.platform;
23
+ const arch = options.arch ?? process.arch;
24
+ const packageName = getRuntimePackageName(platform, arch);
25
+
26
+ if (!packageName) {
27
+ throw new Error(`Unsupported platform ${getRuntimePlatformKey(platform, arch)}`);
28
+ }
29
+
30
+ const requireFrom = createRequire(options.parentUrl ?? import.meta.url);
31
+ const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
32
+
33
+ let packageJsonPath;
34
+ try {
35
+ packageJsonPath = requireFrom.resolve(`${packageName}/package.json`);
36
+ } catch {
37
+ const workspaceBinaryPath = path.join(workspaceRoot, "packages", packageName.split("/").at(-1) ?? "", "bin", "open-plan-annotator");
38
+ if (fs.existsSync(workspaceBinaryPath)) {
39
+ return {
40
+ packageName,
41
+ packageRoot: path.dirname(path.dirname(workspaceBinaryPath)),
42
+ binaryPath: workspaceBinaryPath,
43
+ };
44
+ }
45
+
46
+ throw new Error(
47
+ `Missing runtime package ${packageName}. Reinstall open-plan-annotator for ${getRuntimePlatformKey(platform, arch)}.`,
48
+ );
49
+ }
50
+
51
+ const packageRoot = path.dirname(packageJsonPath);
52
+ const binaryPath = path.join(packageRoot, "bin", "open-plan-annotator");
53
+
54
+ if (!fs.existsSync(binaryPath)) {
55
+ throw new Error(`Runtime package ${packageName} is installed but ${binaryPath} is missing. Rebuild or reinstall it.`);
56
+ }
57
+
58
+ return {
59
+ packageName,
60
+ packageRoot,
61
+ binaryPath,
62
+ };
63
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { getRuntimePackageName, resolveRuntimeBinary } from "./runtimeResolver.mjs";
7
+
8
+ describe("runtimeResolver", () => {
9
+ test("maps supported platforms to runtime packages", () => {
10
+ expect(getRuntimePackageName("darwin", "arm64")).toBe("@open-plan-annotator/runtime-darwin-arm64");
11
+ expect(getRuntimePackageName("linux", "x64")).toBe("@open-plan-annotator/runtime-linux-x64");
12
+ });
13
+
14
+ test("throws for unsupported platforms", () => {
15
+ expect(() => resolveRuntimeBinary({ platform: "win32", arch: "x64" })).toThrow("Unsupported platform win32-x64");
16
+ });
17
+
18
+ test("resolves binary from installed runtime package", () => {
19
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "opa-runtime-"));
20
+ const packageRoot = path.join(tempRoot, "node_modules", "@open-plan-annotator", "runtime-linux-x64");
21
+ const binaryPath = path.join(packageRoot, "bin", "open-plan-annotator");
22
+
23
+ fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
24
+ fs.writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@open-plan-annotator/runtime-linux-x64"}');
25
+ fs.writeFileSync(binaryPath, "binary");
26
+
27
+ const resolved = resolveRuntimeBinary({
28
+ platform: "linux",
29
+ arch: "x64",
30
+ parentUrl: pathToFileURL(path.join(tempRoot, "index.mjs")).href,
31
+ });
32
+
33
+ expect(resolved.packageName).toBe("@open-plan-annotator/runtime-linux-x64");
34
+ expect(fs.realpathSync(resolved.binaryPath)).toBe(fs.realpathSync(binaryPath));
35
+ });
36
+ });
@@ -0,0 +1,14 @@
1
+ export function buildUpdateInstructions(options = {}) {
2
+ const host = options.host ?? process.env.OPEN_PLAN_HOST;
3
+ const packageManager = options.packageManager ?? "npm";
4
+
5
+ if (host === "opencode") {
6
+ return "Refresh the OpenCode plugin install, then restart OpenCode.";
7
+ }
8
+
9
+ if (host === "claude-code") {
10
+ return "Refresh the Claude Code plugin or marketplace install, then restart Claude Code.";
11
+ }
12
+
13
+ return `Update open-plan-annotator via ${packageManager}, then rerun it.`;
14
+ }
package/install.mjs DELETED
@@ -1,218 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import crypto from "node:crypto";
4
- import fs from "node:fs";
5
- import https from "node:https";
6
- import path from "node:path";
7
- import {
8
- PLATFORM_ASSET_BASENAME_MAP,
9
- REPO,
10
- getPlatformAssetArchiveName,
11
- getPlatformKey,
12
- parseChecksumManifest,
13
- selectChecksumAsset,
14
- } from "./shared/releaseAssets.mjs";
15
- import { fileURLToPath } from "node:url";
16
- import zlib from "node:zlib";
17
-
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
- const VERSION = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
20
-
21
- function getReleaseApiUrl() {
22
- return `https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}`;
23
- }
24
-
25
- function fetch(url, redirects) {
26
- if (redirects === undefined) redirects = 5;
27
- return new Promise((resolve, reject) => {
28
- https
29
- .get(url, { headers: { "User-Agent": "open-plan-annotator-install" } }, (res) => {
30
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
31
- if (redirects <= 0) return reject(new Error("Too many redirects"));
32
- return fetch(res.headers.location, redirects - 1).then(resolve, reject);
33
- }
34
- if (res.statusCode < 200 || res.statusCode >= 300) {
35
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
36
- }
37
- const chunks = [];
38
- res.on("data", (c) => chunks.push(c));
39
- res.on("end", () => resolve(Buffer.concat(chunks)));
40
- })
41
- .on("error", reject);
42
- });
43
- }
44
-
45
- async function fetchJson(url) {
46
- const buffer = await fetch(url);
47
- return JSON.parse(buffer.toString("utf8"));
48
- }
49
-
50
- function sha256Hex(buffer) {
51
- return crypto.createHash("sha256").update(buffer).digest("hex");
52
- }
53
-
54
- async function resolveReleaseAssetAndChecksum(options) {
55
- const opts = options || {};
56
- const fetchJsonImpl = opts.fetchJson || fetchJson;
57
- const fetchBuffer = opts.fetch || fetch;
58
- const releaseApiUrl = opts.releaseApiUrl || getReleaseApiUrl();
59
- const version = opts.version || VERSION;
60
-
61
- const release = await fetchJsonImpl(releaseApiUrl);
62
- const releaseAssets = Array.isArray(release.assets) ? release.assets : [];
63
- const key = opts.platformKey || getPlatformKey();
64
- const assetName = getPlatformAssetArchiveName(key);
65
- if (!assetName) {
66
- throw new Error(`Unsupported platform ${key}`);
67
- }
68
-
69
- const asset = releaseAssets.find((entry) => entry.name === assetName);
70
- if (!asset) {
71
- throw new Error(`Release v${version} is missing asset ${assetName}`);
72
- }
73
-
74
- const checksumAsset = selectChecksumAsset(releaseAssets);
75
- if (!checksumAsset) {
76
- throw new Error(`Release v${version} does not contain a checksum manifest asset`);
77
- }
78
-
79
- const checksumManifest = (await fetchBuffer(checksumAsset.browser_download_url)).toString("utf8");
80
- const checksums = parseChecksumManifest(checksumManifest);
81
- const expectedSha256 = checksums.get(assetName);
82
- if (!expectedSha256) {
83
- throw new Error(`Checksum manifest does not contain ${assetName}`);
84
- }
85
-
86
- return {
87
- assetName,
88
- assetUrl: asset.browser_download_url,
89
- expectedSha256,
90
- };
91
- }
92
-
93
- function errorMessage(err) {
94
- return err && err.message ? err.message : String(err);
95
- }
96
-
97
- async function downloadVerifiedArchive(options) {
98
- const opts = options || {};
99
- const resolveRelease = opts.resolveReleaseAssetAndChecksum || resolveReleaseAssetAndChecksum;
100
- const fetchBuffer = opts.fetch || fetch;
101
- const checksumRequirement =
102
- "open-plan-annotator requires release checksum/sha256sum availability and will not install without verification.";
103
-
104
- let releaseInfo;
105
-
106
- try {
107
- releaseInfo = await resolveRelease();
108
- } catch (err) {
109
- throw new Error(`Unable to verify release checksums: ${errorMessage(err)} ${checksumRequirement}`);
110
- }
111
-
112
- const { assetName, assetUrl, expectedSha256 } = releaseInfo;
113
- const archiveBuffer = await fetchBuffer(assetUrl);
114
- const actualSha256 = sha256Hex(archiveBuffer);
115
-
116
- if (actualSha256 !== expectedSha256) {
117
- throw new Error(
118
- `Checksum verification failed for ${assetName} (expected ${expectedSha256}, got ${actualSha256}). ${checksumRequirement}`,
119
- );
120
- }
121
-
122
- return archiveBuffer;
123
- }
124
-
125
- function extractBinaryFromTarGz(buffer) {
126
- const tarBuffer = zlib.gunzipSync(buffer);
127
- let offset = 0;
128
-
129
- while (offset < tarBuffer.length) {
130
- const header = tarBuffer.subarray(offset, offset + 512);
131
- offset += 512;
132
-
133
- const name = header.toString("utf-8", 0, 100).replace(/\0.*/g, "");
134
- const sizeStr = header.toString("utf-8", 124, 136).replace(/\0.*/g, "").trim();
135
- const size = parseInt(sizeStr, 8);
136
-
137
- if (!name || isNaN(size)) break;
138
-
139
- if (name === "open-plan-annotator" || name.endsWith("/open-plan-annotator")) {
140
- return tarBuffer.subarray(offset, offset + size);
141
- }
142
-
143
- offset += Math.ceil(size / 512) * 512;
144
- }
145
-
146
- throw new Error("Binary 'open-plan-annotator' not found in archive");
147
- }
148
-
149
- async function main() {
150
- const destDir = path.join(__dirname, "bin");
151
- const destPath = path.join(destDir, "open-plan-annotator-binary");
152
- const tempPath = `${destPath}.tmp-${process.pid}-${Date.now()}`;
153
-
154
- // Skip if binary already exists
155
- if (fs.existsSync(destPath)) {
156
- return;
157
- }
158
-
159
- console.error(`Downloading open-plan-annotator for ${getPlatformKey()}...`);
160
- const archiveBuffer = await downloadVerifiedArchive();
161
-
162
- try {
163
- const binaryBuffer = extractBinaryFromTarGz(archiveBuffer);
164
-
165
- if (!fs.existsSync(destDir)) {
166
- fs.mkdirSync(destDir, { recursive: true });
167
- }
168
-
169
- fs.writeFileSync(tempPath, binaryBuffer, { mode: 0o755 });
170
- fs.renameSync(tempPath, destPath);
171
- fs.chmodSync(destPath, 0o755);
172
- console.error(`Installed open-plan-annotator to ${destPath}`);
173
- } catch (err) {
174
- try {
175
- fs.unlinkSync(tempPath);
176
- } catch {
177
- // Temp file may not exist
178
- }
179
- throw err;
180
- }
181
- }
182
-
183
- function shouldSkipInstall() {
184
- return Boolean(process.env.OPEN_PLAN_ANNOTATOR_SKIP_INSTALL || process.env.npm_config_dev);
185
- }
186
-
187
- function runCli() {
188
- if (shouldSkipInstall()) {
189
- process.exit(0);
190
- }
191
-
192
- main().catch((err) => {
193
- console.error("Failed to install open-plan-annotator binary:", err.message);
194
- console.error("You can try manually running: node", path.join(__dirname, "install.mjs"));
195
- process.exit(1);
196
- });
197
- }
198
-
199
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
200
- runCli();
201
- }
202
-
203
- export {
204
- VERSION,
205
- PLATFORM_ASSET_BASENAME_MAP as PLATFORM_MAP,
206
- getPlatformKey,
207
- getReleaseApiUrl,
208
- fetch,
209
- fetchJson,
210
- sha256Hex,
211
- parseChecksumManifest,
212
- selectChecksumAsset,
213
- resolveReleaseAssetAndChecksum,
214
- extractBinaryFromTarGz,
215
- downloadVerifiedArchive,
216
- shouldSkipInstall,
217
- main,
218
- };
@@ -1,12 +0,0 @@
1
- export interface ReleaseAsset {
2
- name: string;
3
- browser_download_url: string;
4
- }
5
-
6
- export declare const REPO: string;
7
- export declare const PLATFORM_ASSET_BASENAME_MAP: Record<string, string>;
8
-
9
- export declare function getPlatformKey(platform?: string, arch?: string): string;
10
- export declare function getPlatformAssetArchiveName(platformKey?: string): string | null;
11
- export declare function parseChecksumManifest(manifestText: string): Map<string, string>;
12
- export declare function selectChecksumAsset(assets: ReleaseAsset[]): ReleaseAsset | null;
@@ -1,65 +0,0 @@
1
- const REPO = "ndom91/open-plan-annotator";
2
-
3
- const PLATFORM_ASSET_BASENAME_MAP = {
4
- "darwin-arm64": "open-plan-annotator-darwin-arm64",
5
- "darwin-x64": "open-plan-annotator-darwin-x64",
6
- "linux-x64": "open-plan-annotator-linux-x64",
7
- "linux-arm64": "open-plan-annotator-linux-arm64",
8
- };
9
-
10
- function getPlatformKey(platform = process.platform, arch = process.arch) {
11
- return `${platform}-${arch}`;
12
- }
13
-
14
- function getPlatformAssetArchiveName(platformKey = getPlatformKey()) {
15
- const assetBaseName = PLATFORM_ASSET_BASENAME_MAP[platformKey];
16
- if (!assetBaseName) {
17
- return null;
18
- }
19
- return `${assetBaseName}.tar.gz`;
20
- }
21
-
22
- function parseChecksumManifest(manifestText) {
23
- const checksums = new Map();
24
-
25
- for (const rawLine of manifestText.split(/\r?\n/)) {
26
- const line = rawLine.trim();
27
- if (!line || line.startsWith("#")) continue;
28
-
29
- const bsdStyle = line.match(/^SHA256\s*\(([^)]+)\)\s*=\s*([a-fA-F0-9]{64})$/);
30
- if (bsdStyle) {
31
- checksums.set(bsdStyle[1].trim(), bsdStyle[2].toLowerCase());
32
- continue;
33
- }
34
-
35
- const gnuStyle = line.match(/^([a-fA-F0-9]{64})\s+[* ]?(.+)$/);
36
- if (gnuStyle) {
37
- checksums.set(gnuStyle[2].trim(), gnuStyle[1].toLowerCase());
38
- }
39
- }
40
-
41
- return checksums;
42
- }
43
-
44
- function selectChecksumAsset(assets) {
45
- const checksumAssets = assets
46
- .filter((asset) => {
47
- const lower = asset.name.toLowerCase();
48
- return (
49
- (lower.includes("sha256") || lower.includes("checksum")) &&
50
- (lower.endsWith(".txt") || lower.endsWith(".sha256") || lower.endsWith(".sha256sum") || lower.endsWith(".sha256sums"))
51
- );
52
- })
53
- .sort((a, b) => a.name.localeCompare(b.name));
54
-
55
- return checksumAssets[0] ?? null;
56
- }
57
-
58
- export {
59
- REPO,
60
- PLATFORM_ASSET_BASENAME_MAP,
61
- getPlatformKey,
62
- getPlatformAssetArchiveName,
63
- parseChecksumManifest,
64
- selectChecksumAsset,
65
- };