forgedock 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -56,7 +56,7 @@ $ /work-on #1230 "orchestrate: Layer 5 co-change signal is dead code"
56
56
 
57
57
  ### Try it in 30 seconds — on a throwaway repo, nothing to lose
58
58
 
59
- **Requires:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
59
+ **Requires for this demo:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
60
60
 
61
61
  ```bash
62
62
  npx forgedock demo # spins up a risk-free demo repo and shows you the pipeline end to end
@@ -224,12 +224,23 @@ More ship today (web-property analytics, browser QA sweeps, self-benchmarking)
224
224
 
225
225
  ## Install
226
226
 
227
- **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
227
+ **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai/) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
228
228
 
229
229
  ```bash
230
230
  npx forgedock # checks your environment, installs commands into ~/.claude/commands/ (available in every Claude Code session on this machine), detects your repo, and hands you a reviewed forge.yaml
231
231
  ```
232
232
 
233
+ For OpenCode, install namespaced native commands without changing your provider
234
+ or `opencode.json`:
235
+
236
+ ```bash
237
+ npx forgedock opencode install
238
+ # restart OpenCode, then: /forge/work-on <issue>
239
+ ```
240
+
241
+ See [OpenCode support](docs/OPENCODE.md) for architecture, token-loading rules,
242
+ current parity boundaries, and lifecycle commands.
243
+
233
244
  **Install is always global**, to `~/.claude/commands/`. `--global` is still accepted on the command line for backward compatibility but has no effect — there's no other install location to opt out of.
234
245
 
235
246
  > **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI contract for the standing-query re-resolution canary in the orchestrator.
4
+ */
5
+ import { shouldReResolve } from "./resolve.mjs";
6
+
7
+ const [kind, pattern, enabled, maxRounds, rounds] = process.argv.slice(2);
8
+
9
+ const classified = { kind, pattern, args: [] };
10
+ const config = {
11
+ enabled: enabled === "false" ? false : enabled,
12
+ maxRounds: maxRounds === "unbounded" ? undefined : Number(maxRounds),
13
+ };
14
+
15
+ process.stdout.write(`${JSON.stringify(shouldReResolve(classified, config, Number(rounds)))}\n`);
@@ -0,0 +1,28 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const canary = join(here, "orchestrate-canary.mjs");
9
+
10
+ function runCanary(...args) {
11
+ const result = spawnSync(process.execPath, [canary, ...args], { encoding: "utf8" });
12
+ assert.equal(result.status, 0, result.stderr);
13
+ return JSON.parse(result.stdout);
14
+ }
15
+
16
+ describe("orchestrate re-resolution canary CLI", () => {
17
+ it("re-resolves a query with the documented argument contract", () => {
18
+ assert.deepEqual(runCanary("query", "priority", "true", "unbounded", "0"), {
19
+ reResolve: true,
20
+ reason: 'query pattern "priority" is a standing predicate — re-resolving',
21
+ });
22
+ });
23
+
24
+ it("preserves literal-set and max-round termination", () => {
25
+ assert.equal(runCanary("literal", "literal-numbers", "true", "3", "0").reResolve, false);
26
+ assert.equal(runCanary("query", "milestone", "true", "3", "3").reResolve, false);
27
+ });
28
+ });
package/bin/forgedock.mjs CHANGED
@@ -3150,6 +3150,9 @@ function help() {
3150
3150
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
3151
3151
  ["npx forgedock update", "Pull latest & reinstall"],
3152
3152
  ["npx forgedock uninstall", "Remove commands"],
3153
+ ["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
3154
+ ["npx forgedock opencode status", "Check the OpenCode adapter"],
3155
+ ["npx forgedock opencode uninstall", "Remove ForgeDock-owned OpenCode files"],
3153
3156
  ["npx forgedock version", "Print the installed version and check for updates"],
3154
3157
  ["npx forgedock help", "Show this help"],
3155
3158
  ];
@@ -3711,7 +3714,7 @@ async function watch() {
3711
3714
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3712
3715
  const KNOWN_COMMANDS = new Set([
3713
3716
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3714
- "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3717
+ "run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3715
3718
  "version", "--version", "-v",
3716
3719
  ]);
3717
3720
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3801,9 +3804,75 @@ switch (command) {
3801
3804
  case "update":
3802
3805
  await update();
3803
3806
  break;
3807
+ case "opencode": {
3808
+ const action = restArgs[0] || "install";
3809
+ const {
3810
+ getOpenCodeAdapterStatus,
3811
+ installOpenCodeAdapter,
3812
+ uninstallOpenCodeAdapter,
3813
+ } = await import("./opencode-adapter.mjs");
3814
+ if (action === "install") {
3815
+ const persist = await persistHome({ forgeHome: FORGE_HOME, home: HOME });
3816
+ if (persist.forgeHome === FORGE_HOME && isEphemeralCachePath(FORGE_HOME)) {
3817
+ throw new Error(
3818
+ `Cannot install OpenCode commands from ephemeral package path ${FORGE_HOME}: ` +
3819
+ `${persist.reason || "failed to persist ForgeDock under ~/.forge"}.`,
3820
+ );
3821
+ }
3822
+ const result = await installOpenCodeAdapter({
3823
+ forgeHome: persist.forgeHome || FORGE_HOME,
3824
+ home: HOME,
3825
+ includeExtras: restArgs.includes("--extras"),
3826
+ });
3827
+ process.stdout.write(
3828
+ `Installed ${result.commandCount} OpenCode commands under ${result.configDir}.\n` +
3829
+ "Restart OpenCode, then run /forge/work-on <issue>.\n",
3830
+ );
3831
+ if (result.migration.removedInstructionsFile || result.migration.removedConfigEntries > 0) {
3832
+ process.stdout.write(
3833
+ `Removed legacy OpenCode adapter artifacts (${result.migration.removedConfigEntries} config entries).\n`,
3834
+ );
3835
+ }
3836
+ for (const warning of result.migration.warnings) process.stderr.write(`Warning: ${warning}\n`);
3837
+ } else if (action === "status") {
3838
+ const result = await getOpenCodeAdapterStatus({ home: HOME });
3839
+ if (!result.installed) {
3840
+ process.stdout.write(`OpenCode adapter is not installed under ${result.configDir}.\n`);
3841
+ exitCode = 1;
3842
+ } else if (!result.healthy) {
3843
+ process.stdout.write(
3844
+ `OpenCode adapter is incomplete (${result.missing.length} missing file(s)). ` +
3845
+ "Re-run: npx forgedock opencode install\n",
3846
+ );
3847
+ exitCode = 1;
3848
+ } else {
3849
+ process.stdout.write(
3850
+ `OpenCode adapter is healthy (${result.manifest.commandCount} commands) under ${result.configDir}.\n`,
3851
+ );
3852
+ }
3853
+ } else if (action === "uninstall") {
3854
+ const result = await uninstallOpenCodeAdapter({ home: HOME });
3855
+ process.stdout.write(`Removed ${result.removed} ForgeDock-owned OpenCode file(s).\n`);
3856
+ } else {
3857
+ process.stderr.write(
3858
+ `Unknown OpenCode action: ${action}. Use install, status, or uninstall.\n`,
3859
+ );
3860
+ exitCode = 1;
3861
+ }
3862
+ break;
3863
+ }
3804
3864
  case "run":
3805
3865
  await run();
3806
3866
  break;
3867
+ case "backend-check": {
3868
+ const { checkExecutionBackend } = await import("./runner.mjs");
3869
+ const result = checkExecutionBackend();
3870
+ if (!restArgs.includes("--quiet")) {
3871
+ process.stdout.write(`${result.ready ? "ready" : "unavailable"}: ${result.backend} (${result.reason})\n`);
3872
+ }
3873
+ if (!result.ready) exitCode = 1;
3874
+ break;
3875
+ }
3807
3876
  case "run-issue": {
3808
3877
  const { runFromCli } = await import("./engine-cli.mjs");
3809
3878
  try {
@@ -0,0 +1,435 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { parseInstallTier } from "./journey.mjs";
10
+
11
+ const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
12
+ const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
13
+ const LEGACY_SENTINEL = "<!-- ForgeDock managed";
14
+ const MANIFEST_VERSION = 1;
15
+
16
+ function portablePath(path) {
17
+ return path.replaceAll("\\", "/");
18
+ }
19
+
20
+ function stripJsonc(raw) {
21
+ let result = "";
22
+ let i = 0;
23
+ while (i < raw.length) {
24
+ const ch = raw[i];
25
+ if (ch === '"') {
26
+ result += ch;
27
+ i++;
28
+ while (i < raw.length) {
29
+ const stringChar = raw[i];
30
+ result += stringChar;
31
+ if (stringChar === "\\" && i + 1 < raw.length) {
32
+ i++;
33
+ result += raw[i];
34
+ } else if (stringChar === '"') {
35
+ break;
36
+ }
37
+ i++;
38
+ }
39
+ i++;
40
+ continue;
41
+ }
42
+ if (ch === "/" && raw[i + 1] === "/") {
43
+ while (i < raw.length && raw[i] !== "\n") i++;
44
+ continue;
45
+ }
46
+ if (ch === "/" && raw[i + 1] === "*") {
47
+ i += 2;
48
+ while (i + 1 < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
49
+ if (i + 1 < raw.length) i += 2;
50
+ continue;
51
+ }
52
+ if (ch === ",") {
53
+ let next = i + 1;
54
+ while (/[\s]/.test(raw[next] || "")) next++;
55
+ if (raw[next] === "}" || raw[next] === "]") {
56
+ i++;
57
+ continue;
58
+ }
59
+ }
60
+ result += ch;
61
+ i++;
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export function shellPath(path, platform = process.platform) {
67
+ const portable = portablePath(path);
68
+ if (platform !== "win32") return portable;
69
+ const drive = portable.match(/^([A-Za-z]):\/(.*)$/);
70
+ return drive ? `/${drive[1].toLowerCase()}/${drive[2]}` : portable;
71
+ }
72
+
73
+ function yamlString(value) {
74
+ return JSON.stringify(value.replace(/[\r\n]+/g, " ").trim());
75
+ }
76
+
77
+ function parseDescription(content) {
78
+ const frontmatter = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
79
+ if (!frontmatter) return "";
80
+ for (const line of frontmatter[1].split(/\r?\n/)) {
81
+ const match = line.match(/^description:\s*(.*)$/);
82
+ if (!match) continue;
83
+ return match[1].trim().replace(/^(["'])(.*)\1$/, "$2");
84
+ }
85
+ return "";
86
+ }
87
+
88
+ function isLegacyCommandDefinition(name, definition) {
89
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
90
+ const keys = Object.keys(definition).sort();
91
+ return (
92
+ keys.length === 2 &&
93
+ keys[0] === "description" &&
94
+ keys[1] === "template" &&
95
+ typeof definition.template === "string" &&
96
+ definition.template.includes(`/commands/${name}.md`) &&
97
+ String(definition.description || "").includes("ForgeDock")
98
+ );
99
+ }
100
+
101
+ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
102
+ const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
103
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
104
+ if (env.XDG_CONFIG_HOME) return join(resolve(env.XDG_CONFIG_HOME), "opencode");
105
+ return join(resolvedHome, ".config", "opencode");
106
+ }
107
+
108
+ export function renderOpenCodeCommand({ description, forgeHome, command }) {
109
+ const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
110
+ const commandsPath = portablePath(join(forgeHome, "commands"));
111
+ return `---
112
+ description: ${yamlString(`ForgeDock: ${description}`)}
113
+ agent: build
114
+ ---
115
+ ${COMMAND_SENTINEL}
116
+
117
+ Run the authoritative ForgeDock workflow at \`${specPath}\` with these exact arguments:
118
+
119
+ $ARGUMENTS
120
+
121
+ Use \`read\` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.
122
+
123
+ OpenCode runtime mapping:
124
+
125
+ - \`Skill(skill="x", args="y")\` means lazily read \`${commandsPath}/\${x.replaceAll(":", "/")}.md\` and execute that workflow in the current context with the exact arguments. Colon separators become slash separators; existing slash separators remain unchanged. This matches Claude Code Skill's in-conversation loading; it is not a reason to spawn a subagent.
126
+ - \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool. Preserve requested isolation and parallelism, use \`general\` for implementation/review and \`explore\` for read-only discovery, and resume by task ID when requested. If background tasks are unavailable, launch independent foreground tasks concurrently where possible and use the workflow's GitHub-label polling fallback; never inline a required isolated review.
127
+ - Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
128
+ - OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
129
+ - If a Claude-version, Claude-transcript, or Claude-cache rule has no OpenCode equivalent, ignore only that runtime-specific optimization and preserve the workflow invariant it was intended to protect.
130
+ `;
131
+ }
132
+
133
+ export function renderOpenCodePlugin(forgeHome) {
134
+ const gitBashHome = shellPath(forgeHome, "win32");
135
+ return `${PLUGIN_SENTINEL}
136
+ import { existsSync } from "node:fs"
137
+ import { join } from "node:path"
138
+
139
+ const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
140
+ const GIT_BASH_FORGE_HOME = ${JSON.stringify(gitBashHome)}
141
+ let shellForgeHome = NATIVE_FORGE_HOME
142
+
143
+ export const ForgeDockPlugin = async () => ({
144
+ config: async (config) => {
145
+ if (config.subagent_depth === undefined) config.subagent_depth = 2
146
+ if (process.platform === "win32" && !config.shell) {
147
+ const candidates = [
148
+ process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
149
+ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe"),
150
+ ].filter(Boolean)
151
+ const gitBash = candidates.find((candidate) => existsSync(candidate))
152
+ if (gitBash) config.shell = gitBash
153
+ }
154
+ if (/bash(?:\.exe)?$/i.test(String(config.shell || ""))) {
155
+ shellForgeHome = GIT_BASH_FORGE_HOME
156
+ }
157
+ },
158
+ "shell.env": async (_input, output) => {
159
+ output.env.FORGE_HOME = shellForgeHome
160
+ output.env.FORGE_RUNTIME = "opencode"
161
+ },
162
+ })
163
+ `;
164
+ }
165
+
166
+ async function atomicWrite(path, content) {
167
+ await mkdir(dirname(path), { recursive: true });
168
+ const tmp = `${path}.forgedock.tmp-${process.pid}`;
169
+ try {
170
+ await writeFile(tmp, content, "utf8");
171
+ await rename(tmp, path);
172
+ } catch (error) {
173
+ await rm(tmp, { force: true }).catch(() => {});
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ async function readManifest(path) {
179
+ try {
180
+ const value = JSON.parse(await readFile(path, "utf8"));
181
+ if (
182
+ value?.version === MANIFEST_VERSION &&
183
+ Array.isArray(value.files) &&
184
+ value.files.every((file) => typeof file === "string") &&
185
+ typeof value.digest === "string"
186
+ ) return value;
187
+ } catch {
188
+ // A missing or malformed manifest means there are no trusted owned files.
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function digestFiles(files) {
194
+ return createHash("sha256")
195
+ .update(
196
+ [...files]
197
+ .sort((a, b) => a.rel.localeCompare(b.rel))
198
+ .map((item) => `${item.rel}\0${item.content}`)
199
+ .join("\0"),
200
+ )
201
+ .digest("hex");
202
+ }
203
+
204
+ function pathInside(root, candidate) {
205
+ const rel = relative(resolve(root), resolve(candidate));
206
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
207
+ }
208
+
209
+ async function isManagedFile(path) {
210
+ try {
211
+ const content = await readFile(path, "utf8");
212
+ return content.includes(COMMAND_SENTINEL) || content.includes(PLUGIN_SENTINEL);
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ async function removeOwnedFiles(configDir, files) {
219
+ let removed = 0;
220
+ for (const rel of files) {
221
+ const path = join(configDir, rel);
222
+ if (!pathInside(configDir, path) || !(await isManagedFile(path))) continue;
223
+ await unlink(path).catch((error) => {
224
+ if (error.code !== "ENOENT") throw error;
225
+ });
226
+ removed++;
227
+ }
228
+ return removed;
229
+ }
230
+
231
+ async function discoverEntrypoints(forgeHome, includeExtras) {
232
+ const commandsDir = join(forgeHome, "commands");
233
+ const entries = await readdir(commandsDir, { withFileTypes: true });
234
+ const commands = [];
235
+ for (const entry of entries) {
236
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
237
+ const source = join(commandsDir, entry.name);
238
+ const tier = parseInstallTier(source);
239
+ if (tier === "internal" || (tier === "extras" && !includeExtras)) continue;
240
+ const content = await readFile(source, "utf8");
241
+ const description = parseDescription(content);
242
+ if (!description) continue;
243
+ commands.push({
244
+ name: entry.name.slice(0, -3),
245
+ description,
246
+ tier,
247
+ });
248
+ }
249
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
250
+ }
251
+
252
+ async function migrateLegacyAdapter({ configDir, home }) {
253
+ const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
254
+ const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
255
+ const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
256
+ let legacyInstructionsOwned = false;
257
+ try {
258
+ const content = await readFile(legacyInstructions, "utf8");
259
+ if (content.includes(LEGACY_SENTINEL)) {
260
+ legacyInstructionsOwned = true;
261
+ }
262
+ } catch (error) {
263
+ if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
264
+ }
265
+
266
+ const configPaths = new Set([
267
+ join(resolvedHome, ".config", "opencode", "opencode.json"),
268
+ join(configDir, "opencode.json"),
269
+ ]);
270
+ const legacyCommands = new Set(["work-on", "review-pr", "quality-gate", "orchestrate"]);
271
+ const staged = [];
272
+ let parseFailed = false;
273
+ for (const configPath of configPaths) {
274
+ if (!existsSync(configPath)) continue;
275
+ let config;
276
+ try {
277
+ config = JSON.parse(stripJsonc(await readFile(configPath, "utf8")));
278
+ } catch (error) {
279
+ result.warnings.push(`Could not migrate legacy entries in ${configPath}: ${error.message}`);
280
+ parseFailed = true;
281
+ continue;
282
+ }
283
+
284
+ let changed = false;
285
+ if (legacyInstructionsOwned && Array.isArray(config.instructions)) {
286
+ const before = config.instructions.length;
287
+ config.instructions = config.instructions.filter((item) => {
288
+ if (typeof item !== "string") return true;
289
+ return portablePath(resolve(item)) !== portablePath(resolve(legacyInstructions));
290
+ });
291
+ const removed = before - config.instructions.length;
292
+ result.removedConfigEntries += removed;
293
+ changed ||= removed > 0;
294
+ if (config.instructions.length === 0) delete config.instructions;
295
+ }
296
+ if (config.command && typeof config.command === "object" && !Array.isArray(config.command)) {
297
+ for (const name of legacyCommands) {
298
+ const definition = config.command[name];
299
+ if (isLegacyCommandDefinition(name, definition)) {
300
+ delete config.command[name];
301
+ result.removedConfigEntries++;
302
+ changed = true;
303
+ }
304
+ }
305
+ if (Object.keys(config.command).length === 0) delete config.command;
306
+ }
307
+ if (changed) {
308
+ staged.push({
309
+ path: configPath,
310
+ original: await readFile(configPath, "utf8"),
311
+ content: `${JSON.stringify(config, null, 2)}\n`,
312
+ });
313
+ }
314
+ }
315
+ if (parseFailed) return result;
316
+ const written = [];
317
+ try {
318
+ for (const item of staged) {
319
+ await atomicWrite(item.path, item.content);
320
+ written.push(item);
321
+ }
322
+ } catch (error) {
323
+ for (const item of written) await atomicWrite(item.path, item.original).catch(() => {});
324
+ result.warnings.push(`Could not write legacy migration: ${error.message}`);
325
+ return result;
326
+ }
327
+ if (legacyInstructionsOwned) {
328
+ await unlink(legacyInstructions);
329
+ result.removedInstructionsFile = true;
330
+ }
331
+ return result;
332
+ }
333
+
334
+ export async function installOpenCodeAdapter({
335
+ forgeHome,
336
+ home,
337
+ env = process.env,
338
+ includeExtras = false,
339
+ } = {}) {
340
+ if (!forgeHome) throw new Error("forgeHome is required");
341
+ if (!existsSync(join(forgeHome, "commands"))) {
342
+ throw new Error(`ForgeDock commands directory not found: ${join(forgeHome, "commands")}`);
343
+ }
344
+
345
+ const configDir = resolveOpenCodeConfigDir({ home, env });
346
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
347
+ const previous = (await readManifest(manifestPath)) || { files: [] };
348
+ const commands = await discoverEntrypoints(forgeHome, includeExtras);
349
+ const rendered = [];
350
+
351
+ for (const command of commands) {
352
+ const rel = portablePath(join("commands", "forge", `${command.name}.md`));
353
+ const content = renderOpenCodeCommand({
354
+ command: command.name,
355
+ description: command.description,
356
+ forgeHome,
357
+ });
358
+ rendered.push({ rel, content });
359
+ }
360
+
361
+ const pluginRel = portablePath(join("plugins", "forgedock.js"));
362
+ const pluginContent = renderOpenCodePlugin(forgeHome);
363
+ rendered.push({ rel: pluginRel, content: pluginContent });
364
+
365
+ // Preflight every collision before the first write so a rejected install
366
+ // cannot leave unmanifested command files behind.
367
+ for (const item of rendered) {
368
+ const path = join(configDir, item.rel);
369
+ if (existsSync(path) && !(await isManagedFile(path))) {
370
+ throw new Error(`Refusing to overwrite user-owned OpenCode file: ${path}`);
371
+ }
372
+ }
373
+ for (const item of rendered) await atomicWrite(join(configDir, item.rel), item.content);
374
+
375
+ const nextFiles = rendered.map((item) => item.rel).sort();
376
+ const stale = previous.files.filter((file) => !nextFiles.includes(file));
377
+ const removed = await removeOwnedFiles(configDir, stale);
378
+ const digest = digestFiles(rendered);
379
+ const manifest = {
380
+ version: MANIFEST_VERSION,
381
+ forgeHome,
382
+ includeExtras,
383
+ commandCount: commands.length,
384
+ files: nextFiles,
385
+ digest,
386
+ };
387
+ await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
388
+ const migration = await migrateLegacyAdapter({ configDir, home });
389
+
390
+ return { configDir, manifestPath, commandCount: commands.length, removed, digest, migration };
391
+ }
392
+
393
+ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
394
+ const configDir = resolveOpenCodeConfigDir({ home, env });
395
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
396
+ if (!existsSync(manifestPath)) return { installed: false, healthy: false, configDir, missing: [] };
397
+ const manifest = await readManifest(manifestPath);
398
+ if (!manifest) {
399
+ return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
400
+ }
401
+ const missing = [];
402
+ const current = [];
403
+ for (const rel of manifest.files) {
404
+ const path = join(configDir, rel);
405
+ if (!existsSync(path) || !(await isManagedFile(path))) {
406
+ missing.push(rel);
407
+ continue;
408
+ }
409
+ current.push({ rel, content: await readFile(path, "utf8") });
410
+ }
411
+ const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
412
+ return {
413
+ installed: true,
414
+ healthy: missing.length === 0 && integrity,
415
+ configDir,
416
+ manifest,
417
+ missing,
418
+ integrity: integrity ? "valid" : "digest-mismatch",
419
+ };
420
+ }
421
+
422
+ export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
423
+ const configDir = resolveOpenCodeConfigDir({ home, env });
424
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
425
+ const manifest = (await readManifest(manifestPath)) || { files: [] };
426
+ const removed = await removeOwnedFiles(configDir, manifest.files);
427
+ await unlink(manifestPath).catch((error) => {
428
+ if (error.code !== "ENOENT") throw error;
429
+ });
430
+ await rmdir(join(configDir, "forgedock")).catch((error) => {
431
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
432
+ });
433
+ const migration = await migrateLegacyAdapter({ configDir, home });
434
+ return { configDir, removed, migration };
435
+ }
package/bin/registry.mjs CHANGED
@@ -513,12 +513,13 @@ export function getInstalledCommandsDriftStatus(forgeHome) {
513
513
  let sourceVersion = "";
514
514
  try {
515
515
  const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
516
- sourceVersion = pkg.version || "";
516
+ sourceVersion = typeof pkg.version === "string" ? pkg.version.trim() : "";
517
517
  } catch {
518
518
  // Missing/unreadable/corrupt package.json — cannot determine the source
519
519
  // version at all; degrade to unknown rather than guessing.
520
520
  return notFound;
521
521
  }
522
+ if (!sourceVersion) return notFound;
522
523
 
523
524
  // Prefer the version registry.mjs last recorded for persistHome()'s write
524
525
  // (the single source of truth update()/install also write through),