forgedock 1.7.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.
@@ -1,22 +1,77 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import { existsSync } from "node:fs";
6
- import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
6
+ import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
- import { parseInstallTier } from "./journey.mjs";
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";
10
20
 
11
21
  const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
22
+ const SKILL_SENTINEL = "<!-- forgedock:managed-opencode-skill -->";
12
23
  const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
13
- const LEGACY_SENTINEL = "<!-- ForgeDock managed";
24
+ const LEGACY_SENTINEL = "<!-- ForgeDock managed — do not remove this line -->";
14
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
+ };
15
47
 
16
48
  function portablePath(path) {
17
49
  return path.replaceAll("\\", "/");
18
50
  }
19
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
+
20
75
  function stripJsonc(raw) {
21
76
  let result = "";
22
77
  let i = 0;
@@ -85,17 +140,51 @@ function parseDescription(content) {
85
140
  return "";
86
141
  }
87
142
 
88
- function isLegacyCommandDefinition(name, definition) {
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) {
89
165
  if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
90
166
  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
- );
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;
99
188
  }
100
189
 
101
190
  export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
@@ -105,25 +194,71 @@ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
105
194
  return join(resolvedHome, ".config", "opencode");
106
195
  }
107
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
+
108
207
  export function renderOpenCodeCommand({ description, forgeHome, command }) {
109
208
  const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
110
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);
111
239
  return `---
240
+ name: ${name}
112
241
  description: ${yamlString(`ForgeDock: ${description}`)}
113
- agent: build
242
+ compatibility: opencode
243
+ metadata:
244
+ forgedock: "managed"
245
+ source: ${yamlString(command)}
114
246
  ---
115
- ${COMMAND_SENTINEL}
247
+ ${SKILL_SENTINEL}
116
248
 
117
- Run the authoritative ForgeDock workflow at \`${specPath}\` with these exact arguments:
249
+ Load and execute the authoritative ForgeDock workflow at \`${specPath}\` in the current context.
118
250
 
119
- $ARGUMENTS
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.
120
252
 
121
- Use \`read\` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.
253
+ ${openCodeReviewDispatchContract()}
122
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.
123
258
  OpenCode runtime mapping:
124
259
 
125
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.
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.
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.
127
262
  - Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
128
263
  - OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
129
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.
@@ -132,17 +267,85 @@ OpenCode runtime mapping:
132
267
 
133
268
  export function renderOpenCodePlugin(forgeHome) {
134
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
+ `;
135
321
  return `${PLUGIN_SENTINEL}
136
322
  import { existsSync } from "node:fs"
137
323
  import { join } from "node:path"
138
324
 
139
325
  const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
140
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
+
141
333
  let shellForgeHome = NATIVE_FORGE_HOME
334
+ ${runtimeGuard}
142
335
 
143
336
  export const ForgeDockPlugin = async () => ({
144
337
  config: async (config) => {
338
+ // Two nested subagent levels cover orchestrate -> work-on -> review/gate
339
+ // without enabling unnecessary recursive fan-out.
145
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
+ }
146
349
  if (process.platform === "win32" && !config.shell) {
147
350
  const candidates = [
148
351
  process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
@@ -155,29 +358,182 @@ export const ForgeDockPlugin = async () => ({
155
358
  shellForgeHome = GIT_BASH_FORGE_HOME
156
359
  }
157
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
+ },
158
370
  "shell.env": async (_input, output) => {
159
371
  output.env.FORGE_HOME = shellForgeHome
160
372
  output.env.FORGE_RUNTIME = "opencode"
373
+ output.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS =
374
+ process.env[BACKGROUND_FLAG] ?? "true"
161
375
  },
162
376
  })
163
377
  `;
164
378
  }
165
379
 
166
- async function atomicWrite(path, content) {
167
- await mkdir(dirname(path), { recursive: true });
168
- const tmp = `${path}.forgedock.tmp-${process.pid}`;
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()}`;
169
513
  try {
170
514
  await writeFile(tmp, content, "utf8");
171
- await rename(tmp, path);
515
+ await rename(tmp, target);
172
516
  } catch (error) {
173
517
  await rm(tmp, { force: true }).catch(() => {});
174
518
  throw error;
175
519
  }
176
520
  }
177
521
 
178
- async function readManifest(path) {
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
+ }
179
533
  try {
180
- const value = JSON.parse(await readFile(path, "utf8"));
534
+ const raw = await readRegularFile(target);
535
+ if (raw === null) return null;
536
+ const value = JSON.parse(raw);
181
537
  if (
182
538
  value?.version === MANIFEST_VERSION &&
183
539
  Array.isArray(value.files) &&
@@ -201,17 +557,33 @@ function digestFiles(files) {
201
557
  .digest("hex");
202
558
  }
203
559
 
204
- function pathInside(root, candidate) {
205
- const rel = relative(resolve(root), resolve(candidate));
206
- return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
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);
207
572
  }
208
573
 
209
574
  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;
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);
215
587
  }
216
588
  }
217
589
 
@@ -219,10 +591,12 @@ async function removeOwnedFiles(configDir, files) {
219
591
  let removed = 0;
220
592
  for (const rel of files) {
221
593
  const path = join(configDir, rel);
222
- if (!pathInside(configDir, path) || !(await isManagedFile(path))) continue;
223
- await unlink(path).catch((error) => {
594
+ const safe = await tryResolveSafePath(configDir, path);
595
+ if (!safe || !(await isManagedFile(safe.path))) continue;
596
+ await unlink(safe.path).catch((error) => {
224
597
  if (error.code !== "ENOENT") throw error;
225
598
  });
599
+ await removeEmptyParentDirs(safe.root, safe.path);
226
600
  removed++;
227
601
  }
228
602
  return removed;
@@ -230,37 +604,47 @@ async function removeOwnedFiles(configDir, files) {
230
604
 
231
605
  async function discoverEntrypoints(forgeHome, includeExtras) {
232
606
  const commandsDir = join(forgeHome, "commands");
233
- const entries = await readdir(commandsDir, { withFileTypes: true });
607
+ const sources = await findMarkdownFiles(commandsDir, { includeExtras });
234
608
  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;
609
+ const skillNames = new Map();
610
+ for (const source of sources) {
611
+ const sourcePath = portablePath(relative(commandsDir, source)).replace(/\.md$/i, "");
240
612
  const content = await readFile(source, "utf8");
241
613
  const description = parseDescription(content);
242
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);
243
623
  commands.push({
244
- name: entry.name.slice(0, -3),
624
+ name: sourcePath,
625
+ nativeName,
245
626
  description,
246
- tier,
627
+ topLevel: !sourcePath.includes("/"),
247
628
  });
248
629
  }
249
630
  return commands.sort((a, b) => a.name.localeCompare(b.name));
250
631
  }
251
632
 
252
- async function migrateLegacyAdapter({ configDir, home }) {
633
+ async function migrateLegacyAdapter({ configDir, home, forgeHome }) {
253
634
  const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
254
635
  const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
255
636
  const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
637
+ const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
256
638
  let legacyInstructionsOwned = false;
257
- try {
258
- const content = await readFile(legacyInstructions, "utf8");
259
- if (content.includes(LEGACY_SENTINEL)) {
260
- legacyInstructionsOwned = true;
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}`);
261
647
  }
262
- } catch (error) {
263
- if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
264
648
  }
265
649
 
266
650
  const configPaths = new Set([
@@ -271,12 +655,14 @@ async function migrateLegacyAdapter({ configDir, home }) {
271
655
  const staged = [];
272
656
  let parseFailed = false;
273
657
  for (const configPath of configPaths) {
274
- if (!existsSync(configPath)) continue;
658
+ const safeConfig = await tryResolveSafePath(dirname(configPath), configPath);
659
+ if (!safeConfig || !existsSync(safeConfig.path)) continue;
660
+ const path = safeConfig.path;
275
661
  let config;
276
662
  try {
277
- config = JSON.parse(stripJsonc(await readFile(configPath, "utf8")));
663
+ config = JSON.parse(stripJsonc(await readFile(path, "utf8")));
278
664
  } catch (error) {
279
- result.warnings.push(`Could not migrate legacy entries in ${configPath}: ${error.message}`);
665
+ result.warnings.push(`Could not migrate legacy entries in ${path}: ${error.message}`);
280
666
  parseFailed = true;
281
667
  continue;
282
668
  }
@@ -293,10 +679,15 @@ async function migrateLegacyAdapter({ configDir, home }) {
293
679
  changed ||= removed > 0;
294
680
  if (config.instructions.length === 0) delete config.instructions;
295
681
  }
296
- if (config.command && typeof config.command === "object" && !Array.isArray(config.command)) {
682
+ if (
683
+ legacyInstructionsOwned &&
684
+ config.command &&
685
+ typeof config.command === "object" &&
686
+ !Array.isArray(config.command)
687
+ ) {
297
688
  for (const name of legacyCommands) {
298
689
  const definition = config.command[name];
299
- if (isLegacyCommandDefinition(name, definition)) {
690
+ if (isLegacyCommandDefinition(name, definition, forgeHome)) {
300
691
  delete config.command[name];
301
692
  result.removedConfigEntries++;
302
693
  changed = true;
@@ -306,8 +697,8 @@ async function migrateLegacyAdapter({ configDir, home }) {
306
697
  }
307
698
  if (changed) {
308
699
  staged.push({
309
- path: configPath,
310
- original: await readFile(configPath, "utf8"),
700
+ path,
701
+ original: await readFile(path, "utf8"),
311
702
  content: `${JSON.stringify(config, null, 2)}\n`,
312
703
  });
313
704
  }
@@ -316,21 +707,61 @@ async function migrateLegacyAdapter({ configDir, home }) {
316
707
  const written = [];
317
708
  try {
318
709
  for (const item of staged) {
319
- await atomicWrite(item.path, item.content);
710
+ await atomicWrite(item.path, item.content, dirname(item.path));
320
711
  written.push(item);
321
712
  }
322
713
  } catch (error) {
323
- for (const item of written) await atomicWrite(item.path, item.original).catch(() => {});
714
+ for (const item of written) {
715
+ await atomicWrite(item.path, item.original, dirname(item.path)).catch(() => {});
716
+ }
324
717
  result.warnings.push(`Could not write legacy migration: ${error.message}`);
325
718
  return result;
326
719
  }
327
- if (legacyInstructionsOwned) {
328
- await unlink(legacyInstructions);
329
- result.removedInstructionsFile = true;
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
+ }
330
729
  }
331
730
  return result;
332
731
  }
333
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
+
334
765
  export async function installOpenCodeAdapter({
335
766
  forgeHome,
336
767
  home,
@@ -344,8 +775,19 @@ export async function installOpenCodeAdapter({
344
775
 
345
776
  const configDir = resolveOpenCodeConfigDir({ home, env });
346
777
  const manifestPath = join(configDir, "forgedock", "manifest.json");
347
- const previous = (await readManifest(manifestPath)) || { files: [] };
348
- const commands = await discoverEntrypoints(forgeHome, includeExtras);
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);
349
791
  const rendered = [];
350
792
 
351
793
  for (const command of commands) {
@@ -358,43 +800,110 @@ export async function installOpenCodeAdapter({
358
800
  rendered.push({ rel, content });
359
801
  }
360
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
+
361
813
  const pluginRel = portablePath(join("plugins", "forgedock.js"));
362
814
  const pluginContent = renderOpenCodePlugin(forgeHome);
363
815
  rendered.push({ rel: pluginRel, content: pluginContent });
364
816
 
817
+ await assertSafePath(configDir, manifestPath);
365
818
  // Preflight every collision before the first write so a rejected install
366
819
  // cannot leave unmanifested command files behind.
367
820
  for (const item of rendered) {
368
821
  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}`);
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}`);
371
826
  }
372
827
  }
373
- for (const item of rendered) await atomicWrite(join(configDir, item.rel), item.content);
374
-
375
828
  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,
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,
383
868
  commandCount: commands.length,
384
- files: nextFiles,
869
+ skillCount: workflows.length,
870
+ removed,
385
871
  digest,
872
+ migration,
386
873
  };
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
874
  }
392
875
 
393
876
  export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
394
877
  const configDir = resolveOpenCodeConfigDir({ home, env });
395
878
  const manifestPath = join(configDir, "forgedock", "manifest.json");
396
- if (!existsSync(manifestPath)) return { installed: false, healthy: false, configDir, missing: [] };
397
- const manifest = await readManifest(manifestPath);
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);
398
907
  if (!manifest) {
399
908
  return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
400
909
  }
@@ -402,11 +911,13 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
402
911
  const current = [];
403
912
  for (const rel of manifest.files) {
404
913
  const path = join(configDir, rel);
405
- if (!existsSync(path) || !(await isManagedFile(path))) {
914
+ const safe = await tryResolveSafePath(configDir, path);
915
+ const content = safe ? await readRegularFile(safe.path) : null;
916
+ if (!safe || content === null || !hasManagedSentinel(content)) {
406
917
  missing.push(rel);
407
918
  continue;
408
919
  }
409
- current.push({ rel, content: await readFile(path, "utf8") });
920
+ current.push({ rel, content });
410
921
  }
411
922
  const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
412
923
  return {
@@ -421,15 +932,30 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
421
932
 
422
933
  export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
423
934
  const configDir = resolveOpenCodeConfigDir({ home, env });
935
+ return withAdapterLock(configDir, () => uninstallOpenCodeAdapterLocked({ home, env, configDir }));
936
+ }
937
+
938
+ async function uninstallOpenCodeAdapterLocked({ home, env, configDir }) {
424
939
  const manifestPath = join(configDir, "forgedock", "manifest.json");
425
- const manifest = (await readManifest(manifestPath)) || { files: [] };
940
+ const manifest = (await readManifest(manifestPath, configDir)) || { files: [] };
426
941
  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;
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,
432
959
  });
433
- const migration = await migrateLegacyAdapter({ configDir, home });
434
960
  return { configDir, removed, migration };
435
961
  }