open-plan-annotator 1.0.16 → 1.0.20

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.16"
8
+ "version": "1.0.20"
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.0.20"
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.16",
20
+ "version": "1.0.20",
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.16",
4
+ "version": "1.0.20",
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,15 @@
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 { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
10
+ import { buildUpdateInstructions } from "../shared/updateHints.mjs";
9
11
 
10
12
  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
13
  const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
14
14
 
15
15
  const arg = process.argv[2];
@@ -25,6 +25,11 @@ if (cliMode === "help") {
25
25
  process.exit(0);
26
26
  }
27
27
 
28
+ if (cliMode === "doctor") {
29
+ printDoctor();
30
+ process.exit(0);
31
+ }
32
+
28
33
  if (cliMode === "unknown") {
29
34
  console.error(buildUnknownCommandPrefix(arg));
30
35
  console.error("Run `open-plan-annotator --help` for usage.");
@@ -44,46 +49,8 @@ if (cliMode === "hook") {
44
49
  stdinBuffer = Buffer.alloc(0);
45
50
  }
46
51
 
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
52
  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
- }
53
+ console.log(buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager() }));
87
54
  process.exit(0);
88
55
  }
89
56
 
@@ -96,13 +63,22 @@ function detectPackageManager() {
96
63
  return "npm";
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
+ let runtime;
67
+ try {
68
+ runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
69
+ } catch (error) {
70
+ console.error(`open-plan-annotator: ${error instanceof Error ? error.message : String(error)}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ const child = spawn(runtime.binaryPath, process.argv.slice(2), {
103
75
  stdio: ["pipe", "pipe", "inherit"],
104
76
  detached: true,
105
- env: { ...process.env, OPEN_PLAN_PKG_MANAGER: detectPackageManager() },
77
+ env: {
78
+ ...process.env,
79
+ OPEN_PLAN_HOST: process.env.OPEN_PLAN_HOST || "claude-code",
80
+ OPEN_PLAN_PKG_MANAGER: detectPackageManager(),
81
+ },
106
82
  });
107
83
 
108
84
  child.stdin.write(stdinBuffer);
@@ -149,3 +125,26 @@ child.on("error", (err) => {
149
125
  console.error("open-plan-annotator: failed to spawn binary:", err.message);
150
126
  process.exit(1);
151
127
  });
128
+
129
+ function printDoctor() {
130
+ const platformKey = `${process.platform}-${process.arch}`;
131
+
132
+ try {
133
+ const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
134
+ console.log([
135
+ `open-plan-annotator v${VERSION}`,
136
+ `platform: ${platformKey}`,
137
+ `runtime package: ${runtime.packageName}`,
138
+ `runtime path: ${runtime.binaryPath}`,
139
+ `update: ${buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager() })}`,
140
+ ].join("\n"));
141
+ } catch (error) {
142
+ console.log([
143
+ `open-plan-annotator v${VERSION}`,
144
+ `platform: ${platformKey}`,
145
+ `runtime: missing`,
146
+ `error: ${error instanceof Error ? error.message : String(error)}`,
147
+ `update: ${buildUpdateInstructions({ host: process.env.OPEN_PLAN_HOST, packageManager: detectPackageManager() })}`,
148
+ ].join("\n"));
149
+ }
150
+ }
@@ -1,15 +1,11 @@
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 { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
6
7
 
7
8
  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
9
 
14
10
  /**
15
11
  * @typedef {{
@@ -102,27 +98,6 @@ function validateHookOutput(value) {
102
98
  throw new Error("unsupported decision payload");
103
99
  }
104
100
 
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
101
  function detectPackageManager() {
127
102
  const ua = process.env.npm_config_user_agent || "";
128
103
  if (ua.startsWith("pnpm")) return "pnpm";
@@ -131,55 +106,11 @@ function detectPackageManager() {
131
106
  return "npm";
132
107
  }
133
108
 
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
109
  /**
179
110
  * @param {{ plan: string, sessionId?: string, cwd?: string }} options
180
111
  */
181
112
  export async function runPlanReview(options) {
182
- ensureBinary();
113
+ const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
183
114
 
184
115
  const payload = buildHookPayload(options);
185
116
 
@@ -197,11 +128,12 @@ export async function runPlanReview(options) {
197
128
 
198
129
  // Spawn detached so the binary can outlive this call — it keeps its
199
130
  // HTTP server alive for ~10s after emitting the JSON hook response.
200
- const child = spawn(BINARY_PATH, [], {
131
+ const child = spawn(runtime.binaryPath, [], {
201
132
  cwd,
202
133
  stdio: ["pipe", "pipe", "pipe"],
203
134
  env: {
204
135
  ...process.env,
136
+ OPEN_PLAN_HOST: "opencode",
205
137
  OPEN_PLAN_PKG_MANAGER: process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager(),
206
138
  },
207
139
  detached: true,
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, approved, and feedback 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"),
@@ -176,18 +176,10 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => {
176
176
  cwd: ctx.directory,
177
177
  });
178
178
 
179
- const basePayload = {
180
- plan_status: result.approved ? "approved" : "rejected",
181
- next_state: result.approved ? "EXECUTION" : "PLAN_DRAFT",
182
- approved: result.approved,
183
- feedback: result.approved ? null : (result.feedback ?? "Plan changes requested."),
184
- };
179
+ const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested.");
185
180
 
186
181
  if (result.approved) {
187
- const lines = [
188
- "Plan approved by the user.",
189
- "Do NOT call `submit_plan` again. The planning phase is finished.",
190
- ];
182
+ const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."];
191
183
 
192
184
  if (args.summary) {
193
185
  lines.push(`Summary: ${args.summary}`);
@@ -202,25 +194,23 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => {
202
194
  }
203
195
  }
204
196
 
205
- lines.push("Begin implementing the approved plan now write code and make changes.");
206
- return {
207
- ...basePayload,
208
- guidance: lines.join("\n\n"),
209
- };
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");
210
202
  }
211
203
 
212
- return {
213
- ...basePayload,
214
- guidance: [
215
- "Plan needs revision.",
216
- "",
217
- "## User feedback",
218
- "",
219
- basePayload.feedback,
220
- "",
221
- "Revise the plan using this feedback, then submit the revised draft once via `submit_plan`.",
222
- ].join("\n"),
223
- };
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");
224
214
  },
225
215
  }),
226
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.approved).toBe(true);
36
- expect(result.feedback).toBeNull();
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,9 +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.approved).toBe(false);
54
- expect(result.feedback).toBe(bridgeFeedback);
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.");
55
53
  });
56
54
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
- "version": "1.0.16",
3
+ "version": "1.0.20",
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.0.20",
65
+ "@open-plan-annotator/runtime-darwin-x64": "1.0.20",
66
+ "@open-plan-annotator/runtime-linux-arm64": "1.0.20",
67
+ "@open-plan-annotator/runtime-linux-x64": "1.0.20"
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,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
- };