forgedock 1.6.0 → 1.7.1

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.
@@ -0,0 +1,961 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import {
9
+ basename,
10
+ dirname,
11
+ isAbsolute,
12
+ join,
13
+ posix,
14
+ relative,
15
+ resolve,
16
+ sep,
17
+ win32,
18
+ } from "node:path";
19
+ import { findMarkdownFiles } from "./journey.mjs";
20
+
21
+ const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
22
+ const SKILL_SENTINEL = "<!-- forgedock:managed-opencode-skill -->";
23
+ const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
24
+ const LEGACY_SENTINEL = "<!-- ForgeDock managed — do not remove this line -->";
25
+ const MANIFEST_VERSION = 1;
26
+ const ADAPTER_LOCK_STALE_AGE_MS = 30_000;
27
+ const ADAPTER_LOCK_HEARTBEAT_MS = 10_000;
28
+ const ADAPTER_LOCK_RETRY_DELAYS_MS = [10, 20, 40, 80, 150];
29
+ const LEGACY_COMMAND_CONTRACTS = {
30
+ "work-on": {
31
+ description: "Run the ForgeDock full issue pipeline (investigate \u2192 build \u2192 review \u2192 merge)",
32
+ templateSuffix: " and execute the pipeline for issue {{args}}.",
33
+ },
34
+ "review-pr": {
35
+ description: "Run the ForgeDock PR review pipeline",
36
+ templateSuffix: " and execute the PR review for PR {{args}}.",
37
+ },
38
+ "quality-gate": {
39
+ description: "Run ForgeDock pre-commit quality checks",
40
+ templateSuffix: " and run all quality gate checks.",
41
+ },
42
+ orchestrate: {
43
+ description: "Run ForgeDock parallel multi-issue orchestration",
44
+ templateSuffix: " and orchestrate the issues: {{args}}.",
45
+ },
46
+ };
47
+
48
+ function portablePath(path) {
49
+ return path.replaceAll("\\", "/");
50
+ }
51
+
52
+ function legacyPathFlavor(path) {
53
+ if (typeof path !== "string") return null;
54
+ if (posix.isAbsolute(path)) return "posix";
55
+ if (win32.isAbsolute(path)) return "win32";
56
+ return null;
57
+ }
58
+
59
+ function normalizeLegacyPath(path, flavor) {
60
+ const pathFlavor = flavor || legacyPathFlavor(path);
61
+ if (!pathFlavor) return null;
62
+ const pathApi = pathFlavor === "win32" ? win32 : posix;
63
+ if (!pathApi.isAbsolute(path)) return null;
64
+
65
+ let normalized = pathApi.normalize(path);
66
+ if (pathFlavor === "win32") {
67
+ normalized = portablePath(normalized).toLowerCase();
68
+ }
69
+ if (normalized !== "/" && !/^[a-z]:\/$/i.test(normalized)) {
70
+ normalized = normalized.replace(/\/+$/, "");
71
+ }
72
+ return normalized;
73
+ }
74
+
75
+ function stripJsonc(raw) {
76
+ let result = "";
77
+ let i = 0;
78
+ while (i < raw.length) {
79
+ const ch = raw[i];
80
+ if (ch === '"') {
81
+ result += ch;
82
+ i++;
83
+ while (i < raw.length) {
84
+ const stringChar = raw[i];
85
+ result += stringChar;
86
+ if (stringChar === "\\" && i + 1 < raw.length) {
87
+ i++;
88
+ result += raw[i];
89
+ } else if (stringChar === '"') {
90
+ break;
91
+ }
92
+ i++;
93
+ }
94
+ i++;
95
+ continue;
96
+ }
97
+ if (ch === "/" && raw[i + 1] === "/") {
98
+ while (i < raw.length && raw[i] !== "\n") i++;
99
+ continue;
100
+ }
101
+ if (ch === "/" && raw[i + 1] === "*") {
102
+ i += 2;
103
+ while (i + 1 < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
104
+ if (i + 1 < raw.length) i += 2;
105
+ continue;
106
+ }
107
+ if (ch === ",") {
108
+ let next = i + 1;
109
+ while (/[\s]/.test(raw[next] || "")) next++;
110
+ if (raw[next] === "}" || raw[next] === "]") {
111
+ i++;
112
+ continue;
113
+ }
114
+ }
115
+ result += ch;
116
+ i++;
117
+ }
118
+ return result;
119
+ }
120
+
121
+ export function shellPath(path, platform = process.platform) {
122
+ const portable = portablePath(path);
123
+ if (platform !== "win32") return portable;
124
+ const drive = portable.match(/^([A-Za-z]):\/(.*)$/);
125
+ return drive ? `/${drive[1].toLowerCase()}/${drive[2]}` : portable;
126
+ }
127
+
128
+ function yamlString(value) {
129
+ return JSON.stringify(value.replace(/[\r\n]+/g, " ").trim());
130
+ }
131
+
132
+ function parseDescription(content) {
133
+ const frontmatter = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
134
+ if (!frontmatter) return "";
135
+ for (const line of frontmatter[1].split(/\r?\n/)) {
136
+ const match = line.match(/^description:\s*(.*)$/);
137
+ if (!match) continue;
138
+ return match[1].trim().replace(/^(["'])(.*)\1$/, "$2");
139
+ }
140
+ return "";
141
+ }
142
+
143
+ /**
144
+ * Map a source workflow path to OpenCode's native skill-name contract.
145
+ * OpenCode skill names cannot contain path separators, so nested source paths
146
+ * use a stable hyphen separator (for example, work-on/investigate).
147
+ */
148
+ export function normalizeOpenCodeSkillName(command) {
149
+ const name = portablePath(command)
150
+ .replace(/\.md$/i, "")
151
+ .split("/")
152
+ .filter(Boolean)
153
+ .map((segment) => segment.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))
154
+ .filter(Boolean)
155
+ .join("-")
156
+ .replace(/-{2,}/g, "-");
157
+ if (!name) throw new Error(`Cannot register empty OpenCode skill name for workflow: ${command}`);
158
+ if (name.length > 64) {
159
+ throw new Error(`OpenCode skill name exceeds 64 characters for workflow: ${command}`);
160
+ }
161
+ return name;
162
+ }
163
+
164
+ function isLegacyCommandDefinition(name, definition, forgeHome) {
165
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
166
+ const keys = Object.keys(definition).sort();
167
+ const contract = LEGACY_COMMAND_CONTRACTS[name];
168
+ if (
169
+ !contract ||
170
+ keys.length !== 2 ||
171
+ keys[0] !== "description" ||
172
+ keys[1] !== "template" ||
173
+ definition.description !== contract.description ||
174
+ typeof definition.template !== "string"
175
+ ) return false;
176
+
177
+ const template = portablePath(definition.template);
178
+ const suffix = `/commands/${name}.md${contract.templateSuffix}`;
179
+ if (!template.startsWith("Read ") || !template.endsWith(suffix)) return false;
180
+ const pathFlavor = legacyPathFlavor(forgeHome);
181
+ if (!pathFlavor) return false;
182
+ const rawSuffix = definition.template.slice(-suffix.length);
183
+ if (portablePath(rawSuffix) !== suffix || (pathFlavor === "posix" && rawSuffix !== suffix)) return false;
184
+ const expectedHome = normalizeLegacyPath(forgeHome);
185
+ if (!expectedHome) return false;
186
+ const templateHome = definition.template.slice("Read ".length, -suffix.length);
187
+ return normalizeLegacyPath(templateHome, pathFlavor) === expectedHome;
188
+ }
189
+
190
+ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
191
+ const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
192
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
193
+ if (env.XDG_CONFIG_HOME) return join(resolve(env.XDG_CONFIG_HOME), "opencode");
194
+ return join(resolvedHome, ".config", "opencode");
195
+ }
196
+
197
+ function openCodeReviewDispatchContract() {
198
+ return [
199
+ "OpenCode review-dispatch override:",
200
+ "Before applying the workflow's Claude-specific Task/Agent availability check, treat an OpenCode runtime marker (`FORGE_RUNTIME=opencode`, `OPENCODE_SESSION_ID`, `OPENCODE_PID`, or `OPENCODE`) as native capability context and set `DISPATCH_TOOL=task`.",
201
+ "Do not enter the `Neither tool is available` branch solely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background: true }`; use `general` for implementation/review and `explore` for read-only discovery.",
202
+ "Use `background: true` for independent orchestration and review work. OpenCode returns a running task marker immediately and injects a later task-result event into the parent; process each event immediately instead of waiting for the slowest sibling.",
203
+ "If lowercase native `task` is genuinely absent from the current tool registry, post `FORGE:REVIEW_BLOCKED` and stop; never replace the required isolated review with inline work or another controller.",
204
+ ].join("\n");
205
+ }
206
+
207
+ export function renderOpenCodeCommand({ description, forgeHome, command }) {
208
+ const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
209
+ const commandsPath = portablePath(join(forgeHome, "commands"));
210
+ const nativeSkillExpression = '${x.replaceAll(":", "-").replaceAll("/", "-")}';
211
+ return [
212
+ "---",
213
+ `description: ${yamlString(`ForgeDock: ${description}`)}`,
214
+ "agent: build",
215
+ "---",
216
+ COMMAND_SENTINEL,
217
+ "",
218
+ "Run the authoritative ForgeDock workflow at `" + specPath + "` with these exact arguments:",
219
+ "",
220
+ "$ARGUMENTS",
221
+ "",
222
+ "Use `read` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.",
223
+ "",
224
+ "OpenCode runtime mapping:",
225
+ "",
226
+ "- `Skill(skill=\"x\", args=\"y\")` means use the registered native OpenCode skill named `" + nativeSkillExpression + "` in the current context with the exact arguments. Its authoritative source is `" + commandsPath + "/${x.replaceAll(\":\", \"/\")}.md`. If the native skill or source is unavailable, stop with `FORGE_OPENCODE_CAPABILITY_ERROR` and an actionable path; never invoke `forgedock run-issue`, `npx forgedock run-issue`, or recursive `opencode run` as a fallback.",
227
+ "- `Task(...)` or a permitted `Agent(...)` means use OpenCode's `task` tool with a top-level argument object shaped like `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background: true }`. `subagent_type` is mandatory: map Claude `general-purpose` to `general` for implementation/review and `codebase-explorer` to `explore` for read-only discovery. If the source omits a type, set `subagent_type: \"general\"` before calling the tool; never emit a call containing only `description` and `prompt`. Unsupported types must stop with `FORGE_OPENCODE_CAPABILITY_ERROR`. Preserve requested isolation and parallelism, resume by task ID when requested, and never inline a required isolated review.",
228
+ openCodeReviewDispatchContract(),
229
+ "- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.",
230
+ "- OpenCode injects `FORGE_HOME` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.",
231
+ "- 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.",
232
+ ].join("\n") + "\n";
233
+ }
234
+
235
+ export function renderOpenCodeSkill({ description, forgeHome, command }) {
236
+ const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
237
+ const commandsPath = portablePath(join(forgeHome, "commands"));
238
+ const name = normalizeOpenCodeSkillName(command);
239
+ return `---
240
+ name: ${name}
241
+ description: ${yamlString(`ForgeDock: ${description}`)}
242
+ compatibility: opencode
243
+ metadata:
244
+ forgedock: "managed"
245
+ source: ${yamlString(command)}
246
+ ---
247
+ ${SKILL_SENTINEL}
248
+
249
+ Load and execute the authoritative ForgeDock workflow at \`${specPath}\` in the current context.
250
+
251
+ The parent workflow's exact arguments are already present in the current context. Preserve them; do not invent new arguments or launch a second controller. Keep loading token-efficient: read only this workflow and the next spec explicitly reached by its dispatcher.
252
+
253
+ ${openCodeReviewDispatchContract()}
254
+
255
+ If the workflow source or a required native capability is unavailable, stop and report exactly:
256
+ \`FORGE_OPENCODE_CAPABILITY_ERROR\`: ForgeDock workflow \`${command}\` is unavailable at \`${specPath}\`.
257
+ Do not invoke \`forgedock run-issue\`, \`npx forgedock run-issue\`, or recursive \`opencode run\` to recover.
258
+ OpenCode runtime mapping:
259
+
260
+ - \`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.
261
+ - \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool with \`{ description, prompt, subagent_type, background }\`. Translate \`general-purpose\` to \`general\` and \`codebase-explorer\` to \`explore\`; never omit \`subagent_type\`. Resume by task ID when requested. A successful background call returns \`<task id=... state="running">\`; a later \`state="completed"\` or \`state="error"\` task result is the completion event for that one issue. Do not wait for all tasks before processing an event.
262
+ - Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
263
+ - OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
264
+ - 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.
265
+ `;
266
+ }
267
+
268
+ export function renderOpenCodePlugin(forgeHome) {
269
+ const gitBashHome = shellPath(forgeHome, "win32");
270
+ const runtimeGuard = String.raw`
271
+ const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR"
272
+
273
+ function commandPattern(executable, subcommand) {
274
+ const assignments = "(?:(?:[A-Za-z_][A-Za-z0-9_]*)=(?:\"[^\"]*\"|'[^']*'|[^\\s;&|]+)\\s+)*"
275
+ const wrappers = "(?:(?:env|command|exec)\\s+)*"
276
+ const npx = "(?:npx(?:\\s+--[^\\s;&|]+)*\\s+)?"
277
+ const path = "(?:(?:[^\\s;&|/\\\\]+[/\\\\])+)?"
278
+ const suffix = subcommand ? "\\s+" + subcommand + "(?:\\s|$)" : "(?:\\s|$)"
279
+ return new RegExp("(?:^|[;&|]\\s*)" + assignments + wrappers + npx + path + executable + "(?:\\.cmd|\\.exe)?" + suffix, "i")
280
+ }
281
+
282
+ function blockedOperation(command) {
283
+ const normalized = String(command || "").replace(/\\r?\\n/g, " ")
284
+ if (commandPattern("claude", "").test(normalized)) return "claude"
285
+ if (commandPattern("forgedock", "run-issue").test(normalized)) return "forgedock run-issue"
286
+ if (commandPattern("opencode", "run").test(normalized)) return "opencode run"
287
+ return ""
288
+ }
289
+
290
+ function capabilityError(operation) {
291
+ const error = new Error(FORGE_OPENCODE_CAPABILITY_ERROR + ": " + operation + " is unavailable in an OpenCode ForgeDock workflow. Use native Skill/Task dispatch instead.")
292
+ error.code = FORGE_OPENCODE_CAPABILITY_ERROR
293
+ return error
294
+ }
295
+
296
+ function normalizeTaskArgs(args) {
297
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
298
+ throw capabilityError("task arguments are invalid")
299
+ }
300
+
301
+ const sourceType = args.subagent_type
302
+ const subagentType = sourceType === undefined || sourceType === null || sourceType === ""
303
+ ? "general"
304
+ : sourceType === "general-purpose"
305
+ ? "general"
306
+ : sourceType === "codebase-explorer"
307
+ ? "explore"
308
+ : sourceType
309
+
310
+ if (subagentType !== "general" && subagentType !== "explore") {
311
+ throw capabilityError("task subagent_type " + String(sourceType) + " is unsupported")
312
+ }
313
+
314
+ args.subagent_type = subagentType
315
+ // The shared OpenCode workflow requires streaming task completions. The only
316
+ // supported opt-out is the explicit environment flag, so an omitted or
317
+ // false per-call value cannot silently reintroduce a wave barrier.
318
+ if (process.env[BACKGROUND_FLAG] !== "false") args.background = true
319
+ }
320
+ `;
321
+ return `${PLUGIN_SENTINEL}
322
+ import { existsSync } from "node:fs"
323
+ import { join } from "node:path"
324
+
325
+ const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
326
+ const GIT_BASH_FORGE_HOME = ${JSON.stringify(gitBashHome)}
327
+ const BACKGROUND_FLAG = "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"
328
+
329
+ // ForgeDock orchestration relies on per-task completion events rather than wave barriers.
330
+ // Keep an explicit false opt-out for users who need the experimental feature disabled.
331
+ if (process.env[BACKGROUND_FLAG] === undefined) process.env[BACKGROUND_FLAG] = "true"
332
+
333
+ let shellForgeHome = NATIVE_FORGE_HOME
334
+ ${runtimeGuard}
335
+
336
+ export const ForgeDockPlugin = async () => ({
337
+ config: async (config) => {
338
+ // Two nested subagent levels cover orchestrate -> work-on -> review/gate
339
+ // without enabling unnecessary recursive fan-out.
340
+ if (config.subagent_depth === undefined) config.subagent_depth = 2
341
+ // OpenCode otherwise adds task: deny to every subagent session, which prevents
342
+ // an orchestrated work-on child from spawning its isolated review agents.
343
+ config.agent ??= {}
344
+ config.agent.general ??= {}
345
+ config.agent.general.permission ??= {}
346
+ if (config.agent.general.permission.task === undefined && config.permission?.task === undefined) {
347
+ config.agent.general.permission.task = "allow"
348
+ }
349
+ if (process.platform === "win32" && !config.shell) {
350
+ const candidates = [
351
+ process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
352
+ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe"),
353
+ ].filter(Boolean)
354
+ const gitBash = candidates.find((candidate) => existsSync(candidate))
355
+ if (gitBash) config.shell = gitBash
356
+ }
357
+ if (/bash(?:\.exe)?$/i.test(String(config.shell || ""))) {
358
+ shellForgeHome = GIT_BASH_FORGE_HOME
359
+ }
360
+ },
361
+ "tool.execute.before": async (input, output) => {
362
+ if (input.tool === "task") {
363
+ normalizeTaskArgs(output?.args)
364
+ return
365
+ }
366
+ if (input.tool !== "bash") return
367
+ const operation = blockedOperation(output?.args?.command)
368
+ if (operation) throw capabilityError(operation)
369
+ },
370
+ "shell.env": async (_input, output) => {
371
+ output.env.FORGE_HOME = shellForgeHome
372
+ output.env.FORGE_RUNTIME = "opencode"
373
+ output.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS =
374
+ process.env[BACKGROUND_FLAG] ?? "true"
375
+ },
376
+ })
377
+ `;
378
+ }
379
+
380
+ function pathInside(root, candidate) {
381
+ const rel = relative(resolve(root), resolve(candidate));
382
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
383
+ }
384
+
385
+ async function assertSafePath(root, candidate) {
386
+ const resolvedRoot = resolve(root);
387
+ const resolvedCandidate = resolve(candidate);
388
+ if (!pathInside(resolvedRoot, resolvedCandidate)) {
389
+ throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
390
+ }
391
+
392
+ let current = resolvedCandidate;
393
+ while (true) {
394
+ let stat;
395
+ try {
396
+ stat = await lstat(current);
397
+ } catch (error) {
398
+ if (error.code !== "ENOENT") throw error;
399
+ }
400
+ if (stat?.isSymbolicLink()) {
401
+ throw new Error(`Refusing to access symlinked OpenCode path: ${current}`);
402
+ }
403
+ if (current === resolvedRoot) return;
404
+ const parent = dirname(current);
405
+ if (parent === current || !pathInside(resolvedRoot, parent)) {
406
+ throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
407
+ }
408
+ current = parent;
409
+ }
410
+ }
411
+
412
+ async function isSafePath(root, candidate) {
413
+ try {
414
+ await assertSafePath(root, candidate);
415
+ return true;
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+
421
+ async function resolveSafePath(root, candidate, { createParent = false } = {}) {
422
+ await assertSafePath(root, candidate);
423
+ if (createParent) await mkdir(dirname(candidate), { recursive: true });
424
+ await assertSafePath(root, candidate);
425
+
426
+ let resolvedRoot;
427
+ let resolvedParent;
428
+ try {
429
+ resolvedRoot = await realpath(resolve(root));
430
+ resolvedParent = await realpath(dirname(candidate));
431
+ } catch (error) {
432
+ if (error.code === "ENOENT") return null;
433
+ throw error;
434
+ }
435
+ if (!pathInside(resolvedRoot, resolvedParent)) {
436
+ throw new Error(`Refusing to access symlinked OpenCode path: ${dirname(candidate)}`);
437
+ }
438
+
439
+ const safeCandidate = join(resolvedParent, basename(candidate));
440
+ let stat;
441
+ try {
442
+ stat = await lstat(safeCandidate);
443
+ } catch (error) {
444
+ if (error.code !== "ENOENT") throw error;
445
+ }
446
+ if (stat?.isSymbolicLink()) {
447
+ throw new Error(`Refusing to access symlinked OpenCode path: ${safeCandidate}`);
448
+ }
449
+ return { path: safeCandidate, root: resolvedRoot };
450
+ }
451
+
452
+ async function tryResolveSafePath(root, candidate) {
453
+ try {
454
+ return await resolveSafePath(root, candidate);
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ async function acquireAdapterLock(configDir) {
461
+ const lockPath = join(configDir, "forgedock", "install.lock");
462
+ const safeLock = await resolveSafePath(configDir, lockPath, { createParent: true });
463
+ if (!safeLock) throw new Error(`Unable to resolve safe OpenCode lock path: ${lockPath}`);
464
+
465
+ for (let attempt = 0; attempt <= ADAPTER_LOCK_RETRY_DELAYS_MS.length; attempt++) {
466
+ try {
467
+ return { handle: await open(safeLock.path, "wx"), path: safeLock.path };
468
+ } catch (error) {
469
+ if (error.code !== "EEXIST") throw error;
470
+ let reclaimed = false;
471
+ try {
472
+ const stat = await lstat(safeLock.path);
473
+ if (Date.now() - stat.mtimeMs >= ADAPTER_LOCK_STALE_AGE_MS) {
474
+ const current = await lstat(safeLock.path);
475
+ if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
476
+ await unlink(safeLock.path);
477
+ reclaimed = true;
478
+ }
479
+ }
480
+ } catch {
481
+ // The lock may have been released or replaced between checks.
482
+ }
483
+ if (reclaimed) continue;
484
+ if (attempt === ADAPTER_LOCK_RETRY_DELAYS_MS.length) break;
485
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, ADAPTER_LOCK_RETRY_DELAYS_MS[attempt]));
486
+ }
487
+ }
488
+ throw new Error("Another OpenCode adapter operation is in progress; try again shortly");
489
+ }
490
+
491
+ async function withAdapterLock(configDir, operation) {
492
+ const lock = await acquireAdapterLock(configDir);
493
+ const heartbeat = setInterval(() => {
494
+ lock.handle.utimes(new Date(), new Date()).catch(() => {});
495
+ }, ADAPTER_LOCK_HEARTBEAT_MS);
496
+ heartbeat.unref?.();
497
+ try {
498
+ return await operation();
499
+ } finally {
500
+ clearInterval(heartbeat);
501
+ await lock.handle.close().catch(() => {});
502
+ await unlink(lock.path).catch(() => {});
503
+ await rmdir(dirname(lock.path)).catch(() => {});
504
+ }
505
+ }
506
+
507
+ async function atomicWrite(path, content, root) {
508
+ const safe = root ? await resolveSafePath(root, path, { createParent: true }) : null;
509
+ if (root && !safe) throw new Error(`Unable to resolve safe OpenCode path: ${path}`);
510
+ const target = safe?.path || path;
511
+ if (!root) await mkdir(dirname(target), { recursive: true });
512
+ const tmp = `${target}.forgedock.tmp-${process.pid}-${randomUUID()}`;
513
+ try {
514
+ await writeFile(tmp, content, "utf8");
515
+ await rename(tmp, target);
516
+ } catch (error) {
517
+ await rm(tmp, { force: true }).catch(() => {});
518
+ throw error;
519
+ }
520
+ }
521
+
522
+ async function readManifest(path, root) {
523
+ let target = path;
524
+ if (root) {
525
+ try {
526
+ const safe = await resolveSafePath(root, path);
527
+ if (!safe) return null;
528
+ target = safe.path;
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+ try {
534
+ const raw = await readRegularFile(target);
535
+ if (raw === null) return null;
536
+ const value = JSON.parse(raw);
537
+ if (
538
+ value?.version === MANIFEST_VERSION &&
539
+ Array.isArray(value.files) &&
540
+ value.files.every((file) => typeof file === "string") &&
541
+ typeof value.digest === "string"
542
+ ) return value;
543
+ } catch {
544
+ // A missing or malformed manifest means there are no trusted owned files.
545
+ }
546
+ return null;
547
+ }
548
+
549
+ function digestFiles(files) {
550
+ return createHash("sha256")
551
+ .update(
552
+ [...files]
553
+ .sort((a, b) => a.rel.localeCompare(b.rel))
554
+ .map((item) => `${item.rel}\0${item.content}`)
555
+ .join("\0"),
556
+ )
557
+ .digest("hex");
558
+ }
559
+
560
+ async function readRegularFile(path) {
561
+ try {
562
+ const stat = await lstat(path);
563
+ if (!stat.isFile()) return null;
564
+ return await readFile(path, "utf8");
565
+ } catch {
566
+ return null;
567
+ }
568
+ }
569
+
570
+ function hasManagedSentinel(content) {
571
+ return content.includes(COMMAND_SENTINEL) || content.includes(SKILL_SENTINEL) || content.includes(PLUGIN_SENTINEL);
572
+ }
573
+
574
+ async function isManagedFile(path) {
575
+ const content = await readRegularFile(path);
576
+ return content !== null && hasManagedSentinel(content);
577
+ }
578
+
579
+ async function removeEmptyParentDirs(configDir, filePath) {
580
+ const root = resolve(configDir);
581
+ let directory = dirname(filePath);
582
+ while (resolve(directory) !== root && pathInside(root, directory)) {
583
+ await rmdir(directory).catch((error) => {
584
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
585
+ });
586
+ directory = dirname(directory);
587
+ }
588
+ }
589
+
590
+ async function removeOwnedFiles(configDir, files) {
591
+ let removed = 0;
592
+ for (const rel of files) {
593
+ const path = join(configDir, rel);
594
+ const safe = await tryResolveSafePath(configDir, path);
595
+ if (!safe || !(await isManagedFile(safe.path))) continue;
596
+ await unlink(safe.path).catch((error) => {
597
+ if (error.code !== "ENOENT") throw error;
598
+ });
599
+ await removeEmptyParentDirs(safe.root, safe.path);
600
+ removed++;
601
+ }
602
+ return removed;
603
+ }
604
+
605
+ async function discoverEntrypoints(forgeHome, includeExtras) {
606
+ const commandsDir = join(forgeHome, "commands");
607
+ const sources = await findMarkdownFiles(commandsDir, { includeExtras });
608
+ const commands = [];
609
+ const skillNames = new Map();
610
+ for (const source of sources) {
611
+ const sourcePath = portablePath(relative(commandsDir, source)).replace(/\.md$/i, "");
612
+ const content = await readFile(source, "utf8");
613
+ const description = parseDescription(content);
614
+ if (!description) continue;
615
+ const nativeName = normalizeOpenCodeSkillName(sourcePath);
616
+ const existing = skillNames.get(nativeName);
617
+ if (existing && existing !== sourcePath) {
618
+ throw new Error(
619
+ `OpenCode skill name collision: ${nativeName} maps both ${existing} and ${sourcePath}`,
620
+ );
621
+ }
622
+ skillNames.set(nativeName, sourcePath);
623
+ commands.push({
624
+ name: sourcePath,
625
+ nativeName,
626
+ description,
627
+ topLevel: !sourcePath.includes("/"),
628
+ });
629
+ }
630
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
631
+ }
632
+
633
+ async function migrateLegacyAdapter({ configDir, home, forgeHome }) {
634
+ const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
635
+ const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
636
+ const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
637
+ const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
638
+ let legacyInstructionsOwned = false;
639
+ if (safeLegacyInstructions) {
640
+ try {
641
+ const content = await readRegularFile(safeLegacyInstructions.path);
642
+ if (content?.split(/\r?\n/).some((line) => line.trim() === LEGACY_SENTINEL)) {
643
+ legacyInstructionsOwned = true;
644
+ }
645
+ } catch (error) {
646
+ if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
647
+ }
648
+ }
649
+
650
+ const configPaths = new Set([
651
+ join(resolvedHome, ".config", "opencode", "opencode.json"),
652
+ join(configDir, "opencode.json"),
653
+ ]);
654
+ const legacyCommands = new Set(["work-on", "review-pr", "quality-gate", "orchestrate"]);
655
+ const staged = [];
656
+ let parseFailed = false;
657
+ for (const configPath of configPaths) {
658
+ const safeConfig = await tryResolveSafePath(dirname(configPath), configPath);
659
+ if (!safeConfig || !existsSync(safeConfig.path)) continue;
660
+ const path = safeConfig.path;
661
+ let config;
662
+ try {
663
+ config = JSON.parse(stripJsonc(await readFile(path, "utf8")));
664
+ } catch (error) {
665
+ result.warnings.push(`Could not migrate legacy entries in ${path}: ${error.message}`);
666
+ parseFailed = true;
667
+ continue;
668
+ }
669
+
670
+ let changed = false;
671
+ if (legacyInstructionsOwned && Array.isArray(config.instructions)) {
672
+ const before = config.instructions.length;
673
+ config.instructions = config.instructions.filter((item) => {
674
+ if (typeof item !== "string") return true;
675
+ return portablePath(resolve(item)) !== portablePath(resolve(legacyInstructions));
676
+ });
677
+ const removed = before - config.instructions.length;
678
+ result.removedConfigEntries += removed;
679
+ changed ||= removed > 0;
680
+ if (config.instructions.length === 0) delete config.instructions;
681
+ }
682
+ if (
683
+ legacyInstructionsOwned &&
684
+ config.command &&
685
+ typeof config.command === "object" &&
686
+ !Array.isArray(config.command)
687
+ ) {
688
+ for (const name of legacyCommands) {
689
+ const definition = config.command[name];
690
+ if (isLegacyCommandDefinition(name, definition, forgeHome)) {
691
+ delete config.command[name];
692
+ result.removedConfigEntries++;
693
+ changed = true;
694
+ }
695
+ }
696
+ if (Object.keys(config.command).length === 0) delete config.command;
697
+ }
698
+ if (changed) {
699
+ staged.push({
700
+ path,
701
+ original: await readFile(path, "utf8"),
702
+ content: `${JSON.stringify(config, null, 2)}\n`,
703
+ });
704
+ }
705
+ }
706
+ if (parseFailed) return result;
707
+ const written = [];
708
+ try {
709
+ for (const item of staged) {
710
+ await atomicWrite(item.path, item.content, dirname(item.path));
711
+ written.push(item);
712
+ }
713
+ } catch (error) {
714
+ for (const item of written) {
715
+ await atomicWrite(item.path, item.original, dirname(item.path)).catch(() => {});
716
+ }
717
+ result.warnings.push(`Could not write legacy migration: ${error.message}`);
718
+ return result;
719
+ }
720
+ if (legacyInstructionsOwned && safeLegacyInstructions) {
721
+ try {
722
+ await unlink(safeLegacyInstructions.path);
723
+ result.removedInstructionsFile = true;
724
+ } catch (error) {
725
+ if (error.code !== "ENOENT") {
726
+ result.warnings.push(`Could not remove legacy instructions file ${legacyInstructions}: ${error.message}`);
727
+ }
728
+ }
729
+ }
730
+ return result;
731
+ }
732
+
733
+ async function snapshotAdapterFiles(configDir, files) {
734
+ const snapshots = new Map();
735
+ for (const rel of files) {
736
+ const path = join(configDir, rel);
737
+ const safe = await tryResolveSafePath(configDir, path);
738
+ const content = safe ? await readRegularFile(safe.path) : null;
739
+ snapshots.set(rel, content !== null && hasManagedSentinel(content) ? content : null);
740
+ }
741
+ return snapshots;
742
+ }
743
+
744
+ async function restoreAdapterState({ configDir, manifestPath, files, manifestContent }) {
745
+ for (const [rel, content] of files) {
746
+ const path = join(configDir, rel);
747
+ if (content !== null) {
748
+ await atomicWrite(path, content, configDir).catch(() => {});
749
+ continue;
750
+ }
751
+ const safe = await tryResolveSafePath(configDir, path);
752
+ if (!safe || !(await isManagedFile(safe.path))) continue;
753
+ await unlink(safe.path).catch(() => {});
754
+ await removeEmptyParentDirs(safe.root, safe.path).catch(() => {});
755
+ }
756
+
757
+ if (manifestContent !== null) {
758
+ await atomicWrite(manifestPath, manifestContent, configDir).catch(() => {});
759
+ } else {
760
+ const safeManifest = await tryResolveSafePath(configDir, manifestPath);
761
+ if (safeManifest) await unlink(safeManifest.path).catch(() => {});
762
+ }
763
+ }
764
+
765
+ export async function installOpenCodeAdapter({
766
+ forgeHome,
767
+ home,
768
+ env = process.env,
769
+ includeExtras = false,
770
+ } = {}) {
771
+ if (!forgeHome) throw new Error("forgeHome is required");
772
+ if (!existsSync(join(forgeHome, "commands"))) {
773
+ throw new Error(`ForgeDock commands directory not found: ${join(forgeHome, "commands")}`);
774
+ }
775
+
776
+ const configDir = resolveOpenCodeConfigDir({ home, env });
777
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
778
+ return withAdapterLock(configDir, () => installOpenCodeAdapterLocked({
779
+ forgeHome,
780
+ home,
781
+ includeExtras,
782
+ configDir,
783
+ manifestPath,
784
+ }));
785
+ }
786
+
787
+ async function installOpenCodeAdapterLocked({ forgeHome, home, includeExtras, configDir, manifestPath }) {
788
+ const previous = (await readManifest(manifestPath, configDir)) || { files: [] };
789
+ const workflows = await discoverEntrypoints(forgeHome, includeExtras);
790
+ const commands = workflows.filter((workflow) => workflow.topLevel);
791
+ const rendered = [];
792
+
793
+ for (const command of commands) {
794
+ const rel = portablePath(join("commands", "forge", `${command.name}.md`));
795
+ const content = renderOpenCodeCommand({
796
+ command: command.name,
797
+ description: command.description,
798
+ forgeHome,
799
+ });
800
+ rendered.push({ rel, content });
801
+ }
802
+
803
+ for (const workflow of workflows) {
804
+ const rel = portablePath(join("skills", workflow.nativeName, "SKILL.md"));
805
+ const content = renderOpenCodeSkill({
806
+ command: workflow.name,
807
+ description: workflow.description,
808
+ forgeHome,
809
+ });
810
+ rendered.push({ rel, content });
811
+ }
812
+
813
+ const pluginRel = portablePath(join("plugins", "forgedock.js"));
814
+ const pluginContent = renderOpenCodePlugin(forgeHome);
815
+ rendered.push({ rel: pluginRel, content: pluginContent });
816
+
817
+ await assertSafePath(configDir, manifestPath);
818
+ // Preflight every collision before the first write so a rejected install
819
+ // cannot leave unmanifested command files behind.
820
+ for (const item of rendered) {
821
+ const path = join(configDir, item.rel);
822
+ await assertSafePath(configDir, path);
823
+ const safe = await tryResolveSafePath(configDir, path);
824
+ if (safe && existsSync(safe.path) && !(await isManagedFile(safe.path))) {
825
+ throw new Error(`Refusing to overwrite user-owned OpenCode file: ${safe.path}`);
826
+ }
827
+ }
828
+ const nextFiles = rendered.map((item) => item.rel).sort();
829
+ const trackedFiles = [...new Set([...previous.files, ...nextFiles])];
830
+ const snapshots = await snapshotAdapterFiles(configDir, trackedFiles);
831
+ const safeManifest = await tryResolveSafePath(configDir, manifestPath);
832
+ const previousManifestContent = safeManifest ? await readRegularFile(safeManifest.path) : null;
833
+ let removed;
834
+ let digest;
835
+ let migration;
836
+ try {
837
+ for (const item of rendered) {
838
+ await atomicWrite(join(configDir, item.rel), item.content, configDir);
839
+ }
840
+
841
+ const stale = previous.files.filter((file) => !nextFiles.includes(file));
842
+ removed = await removeOwnedFiles(configDir, stale);
843
+ digest = digestFiles(rendered);
844
+ const manifest = {
845
+ version: MANIFEST_VERSION,
846
+ forgeHome,
847
+ includeExtras,
848
+ commandCount: commands.length,
849
+ skillCount: workflows.length,
850
+ files: nextFiles,
851
+ digest,
852
+ };
853
+ await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, configDir);
854
+ migration = await migrateLegacyAdapter({ configDir, home, forgeHome });
855
+ } catch (error) {
856
+ await restoreAdapterState({
857
+ configDir,
858
+ manifestPath,
859
+ files: snapshots,
860
+ manifestContent: previousManifestContent,
861
+ });
862
+ throw error;
863
+ }
864
+
865
+ return {
866
+ configDir,
867
+ manifestPath,
868
+ commandCount: commands.length,
869
+ skillCount: workflows.length,
870
+ removed,
871
+ digest,
872
+ migration,
873
+ };
874
+ }
875
+
876
+ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
877
+ const configDir = resolveOpenCodeConfigDir({ home, env });
878
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
879
+ const safeManifest = await tryResolveSafePath(configDir, manifestPath);
880
+ const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
881
+ const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
882
+ const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
883
+ const legacyContent = safeLegacyInstructions
884
+ ? await readRegularFile(safeLegacyInstructions.path)
885
+ : null;
886
+ const legacyInstalled = legacyContent?.split(/\r?\n/).some(
887
+ (line) => line.trim() === LEGACY_SENTINEL,
888
+ ) === true;
889
+
890
+ if (!safeManifest || !existsSync(safeManifest.path)) {
891
+ if (legacyInstalled) {
892
+ return {
893
+ installed: true,
894
+ healthy: false,
895
+ legacy: true,
896
+ configDir,
897
+ missing: [],
898
+ integrity: "legacy-adapter",
899
+ };
900
+ }
901
+ return { installed: false, healthy: false, configDir, missing: [] };
902
+ }
903
+ if (!(await isSafePath(configDir, manifestPath))) {
904
+ return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
905
+ }
906
+ const manifest = await readManifest(manifestPath, configDir);
907
+ if (!manifest) {
908
+ return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
909
+ }
910
+ const missing = [];
911
+ const current = [];
912
+ for (const rel of manifest.files) {
913
+ const path = join(configDir, rel);
914
+ const safe = await tryResolveSafePath(configDir, path);
915
+ const content = safe ? await readRegularFile(safe.path) : null;
916
+ if (!safe || content === null || !hasManagedSentinel(content)) {
917
+ missing.push(rel);
918
+ continue;
919
+ }
920
+ current.push({ rel, content });
921
+ }
922
+ const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
923
+ return {
924
+ installed: true,
925
+ healthy: missing.length === 0 && integrity,
926
+ configDir,
927
+ manifest,
928
+ missing,
929
+ integrity: integrity ? "valid" : "digest-mismatch",
930
+ };
931
+ }
932
+
933
+ export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
934
+ const configDir = resolveOpenCodeConfigDir({ home, env });
935
+ return withAdapterLock(configDir, () => uninstallOpenCodeAdapterLocked({ home, env, configDir }));
936
+ }
937
+
938
+ async function uninstallOpenCodeAdapterLocked({ home, env, configDir }) {
939
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
940
+ const manifest = (await readManifest(manifestPath, configDir)) || { files: [] };
941
+ const removed = await removeOwnedFiles(configDir, manifest.files);
942
+ const safeManifest = await tryResolveSafePath(configDir, manifestPath);
943
+ if (safeManifest) {
944
+ await unlink(safeManifest.path).catch((error) => {
945
+ if (error.code !== "ENOENT") throw error;
946
+ });
947
+ }
948
+ const manifestDir = join(configDir, "forgedock");
949
+ const safeManifestDir = await tryResolveSafePath(configDir, manifestDir);
950
+ if (safeManifestDir) {
951
+ await rmdir(safeManifestDir.path).catch((error) => {
952
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
953
+ });
954
+ }
955
+ const migration = await migrateLegacyAdapter({
956
+ configDir,
957
+ home,
958
+ forgeHome: typeof manifest.forgeHome === "string" ? manifest.forgeHome : undefined,
959
+ });
960
+ return { configDir, removed, migration };
961
+ }