|
|
@@ -0,0 +1,539 @@
|
|
|
+/**
|
|
|
+ * OpenCodeAdapter — emits `.opencode/agent/**` from the canonical `content/agents/**` tree.
|
|
|
+ *
|
|
|
+ * ## What this adapter does
|
|
|
+ *
|
|
|
+ * Exactly one thing: it removes the `oac:` block from a canonical agent file and leaves every
|
|
|
+ * other byte alone. That is the whole transform. The canonical file is *defined* as
|
|
|
+ * "OpenCode-legal frontmatter PLUS an `oac:` block", so the OpenCode emission is the canonical
|
|
|
+ * file minus that block — nothing to re-derive, nothing to re-format.
|
|
|
+ *
|
|
|
+ * ## Why it strips text instead of re-serializing YAML
|
|
|
+ *
|
|
|
+ * The obvious implementation — parse the frontmatter, delete `oac`, `yaml.dump()` the rest —
|
|
|
+ * cannot work here, and the reason is worth recording because it will look like an omission
|
|
|
+ * otherwise.
|
|
|
+ *
|
|
|
+ * The 33 shipped agents are hand-authored, and their YAML carries author decisions no dumper
|
|
|
+ * reproduces: `"bash .opencode/skills/task-management/router.sh complete*": "allow"` is quoted
|
|
|
+ * while the sibling `contextscout: "allow"` is not; actions are quoted (`"deny"`) where YAML
|
|
|
+ * does not require it. A round-trip through `yaml.dump()` renormalizes all of it and every one
|
|
|
+ * of the 33 files comes back byte-different. Since `oac build && git diff --exit-code` is the
|
|
|
+ * gate this refactor rests on (07 Stage 3), a renormalizing emitter would either fail the gate
|
|
|
+ * forever or force a 33-file reformatting commit that buries any real drift in noise.
|
|
|
+ *
|
|
|
+ * Textual excision makes byte-identity the *default* rather than something chased with
|
|
|
+ * special cases, and it keeps comments and blank lines — which a dumper silently discards.
|
|
|
+ *
|
|
|
+ * The parse still happens: the file is validated against {@link CanonicalAgentSchema} and the
|
|
|
+ * emitted result is re-parsed and checked (see {@link OpenCodeAdapter.fromCanonical}). Parsing
|
|
|
+ * decides *whether* to emit; it just does not decide the bytes.
|
|
|
+ *
|
|
|
+ * ## What OpenCode actually accepts — verified against source, not docs
|
|
|
+ *
|
|
|
+ * Checked against the OpenCode checkout at `/Users/darrenhinde/Documents/GitHub/opencode`
|
|
|
+ * (`packages/opencode/src/config/config.ts`, v1.1.48+118 / commit `b9aad20be`) and confirmed
|
|
|
+ * live against the installed 1.18.1 binary:
|
|
|
+ *
|
|
|
+ * - **`name:` is legal.** It is listed in the Agent schema's own `knownKeys` (config.ts:636),
|
|
|
+ * and the markdown agent loader builds `{ name: <path-derived>, ...md.data }` (config.ts:336-338)
|
|
|
+ * — the frontmatter spread comes *second*, so an authored `name:` OVERRIDES the path-derived
|
|
|
+ * one and becomes the key the agent registers under (config.ts:343). Emitting `name:` is
|
|
|
+ * therefore not merely allowed, it is REQUIRED to preserve identity: drop it and `CoderAgent`
|
|
|
+ * silently becomes `coder-agent`, breaking every `task:` rule that names it.
|
|
|
+ *
|
|
|
+ * - **Unknown keys are NOT rejected.** The Agent schema ends in `.catchall(z.any())`
|
|
|
+ * (config.ts:633) and sweeps anything outside `knownKeys` into `options` (config.ts:654-658).
|
|
|
+ * So `oac:` would not actually break OpenCode. Stripping it is hygiene — keeping build-time
|
|
|
+ * metadata out of an agent's runtime `options` — not a hard requirement. See the report in
|
|
|
+ * the subtask notes: the premise that justified `agent-metadata.json` does not hold.
|
|
|
+ *
|
|
|
+ * - **The permission vocabulary is OPEN.** `Permission` is a `z.object({...}).catchall(
|
|
|
+ * PermissionRule).or(PermissionAction)` (config.ts:553-580), and `skill` (config.ts:575) and
|
|
|
+ * `question` (config.ts:569) are known keys. Capability names are therefore treated here as
|
|
|
+ * opaque strings — never validated against a closed enum.
|
|
|
+ *
|
|
|
+ * - **Authored order is load-bearing.** `permissionTransform` (config.ts:541) rebuilds the
|
|
|
+ * permission map from `__originalKeys` precisely to preserve the order the author wrote,
|
|
|
+ * because resolution is last-match-wins. Textual passthrough preserves it exactly.
|
|
|
+ */
|
|
|
+
|
|
|
+import * as yaml from "js-yaml";
|
|
|
+import { BaseAdapter } from "./BaseAdapter.js";
|
|
|
+import {
|
|
|
+ CanonicalAgentSchema,
|
|
|
+ desugarPermission,
|
|
|
+ type ConversionResult,
|
|
|
+ type GranularPermission,
|
|
|
+ type OpenAgent,
|
|
|
+ type ToolCapabilities,
|
|
|
+} from "../types.js";
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Constants
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** Where OpenCode discovers agent files. Mirrors `AGENT_GLOB` (config.ts:311). */
|
|
|
+export const OPENCODE_AGENT_ROOT = ".opencode/agent";
|
|
|
+
|
|
|
+/**
|
|
|
+ * The top-level frontmatter keys OpenCode recognises, copied verbatim from the Agent schema's
|
|
|
+ * own `knownKeys` set (config.ts:635-652).
|
|
|
+ *
|
|
|
+ * Used only to WARN. OpenCode's `.catchall(z.any())` (config.ts:633) means an unknown key is
|
|
|
+ * swept into `options` rather than rejected, so refusing to emit one would be this build
|
|
|
+ * inventing a restriction the target does not have.
|
|
|
+ */
|
|
|
+const OPENCODE_KNOWN_KEYS: ReadonlySet<string> = new Set([
|
|
|
+ "name",
|
|
|
+ "model",
|
|
|
+ "variant",
|
|
|
+ "prompt",
|
|
|
+ "description",
|
|
|
+ "temperature",
|
|
|
+ "top_p",
|
|
|
+ "mode",
|
|
|
+ "hidden",
|
|
|
+ "color",
|
|
|
+ "steps",
|
|
|
+ "maxSteps",
|
|
|
+ "options",
|
|
|
+ "permission",
|
|
|
+ "disable",
|
|
|
+ "tools",
|
|
|
+]);
|
|
|
+
|
|
|
+/** The `oac:` key itself — stripped, so it is never reported as an unknown key. */
|
|
|
+const OAC_KEY = "oac";
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Types
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** The result of emitting one canonical agent file. */
|
|
|
+export interface CanonicalEmitResult {
|
|
|
+ /** The emitted file content, ready to write verbatim. */
|
|
|
+ content: string;
|
|
|
+ /** Semantics lost or worth flagging. Empty means a provably faithful emission. */
|
|
|
+ warnings: string[];
|
|
|
+}
|
|
|
+
|
|
|
+/** Options for {@link OpenCodeAdapter.fromCanonical}. */
|
|
|
+export interface FromCanonicalOptions {
|
|
|
+ /** Source path, used only to make error messages name the offending file. */
|
|
|
+ filePath?: string;
|
|
|
+}
|
|
|
+
|
|
|
+/** Raised when a canonical source cannot be emitted. Never thrown for a recoverable loss. */
|
|
|
+export class OpenCodeEmitError extends Error {
|
|
|
+ constructor(message: string, filePath?: string) {
|
|
|
+ super(filePath ? `${filePath}: ${message}` : message);
|
|
|
+ this.name = "OpenCodeEmitError";
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Frontmatter text surgery
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** A canonical file split into the pieces the emitter manipulates, byte-preserving. */
|
|
|
+interface SplitFile {
|
|
|
+ /** Frontmatter lines, excluding both `---` delimiters. */
|
|
|
+ frontmatter: string[];
|
|
|
+ /** Everything after the closing `---` line, verbatim (including its leading newline). */
|
|
|
+ rest: string;
|
|
|
+}
|
|
|
+
|
|
|
+const BLANK = /^\s*$/;
|
|
|
+const INDENTED = /^[ \t]/;
|
|
|
+
|
|
|
+/** True for a top-level `key:` line — the only thing that can end a nested block. */
|
|
|
+function isTopLevelKey(line: string, key: string): boolean {
|
|
|
+ return new RegExp(`^${key}\\s*:`).test(line);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Split a canonical file at its FIRST closing `---`.
|
|
|
+ *
|
|
|
+ * The first delimiter matters: agent bodies routinely contain `---` horizontal rules
|
|
|
+ * (`coder-agent.md` has five), and a greedy match would swallow half the prompt into the
|
|
|
+ * frontmatter.
|
|
|
+ */
|
|
|
+function splitFrontmatter(source: string, filePath?: string): SplitFile {
|
|
|
+ const lines = source.split("\n");
|
|
|
+
|
|
|
+ if (lines[0] !== "---" && lines[0]?.trimEnd() !== "---") {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ "no YAML frontmatter: a canonical agent file must open with `---`.",
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const close = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
|
|
|
+ if (close === -1) {
|
|
|
+ throw new OpenCodeEmitError("unterminated YAML frontmatter: no closing `---`.", filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ frontmatter: lines.slice(1, close),
|
|
|
+ // Rebuilt from the closing delimiter onward so no byte after it is ever touched.
|
|
|
+ rest: lines.slice(close).join("\n"),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Remove the top-level `oac:` block from frontmatter lines.
|
|
|
+ *
|
|
|
+ * Consumes the `oac:` line plus its indented continuation. Blank lines are consumed only when
|
|
|
+ * more of the block follows them, so a blank SEPARATOR after the block survives — otherwise an
|
|
|
+ * `oac:` block sitting mid-frontmatter would silently eat the spacing around the next key.
|
|
|
+ * Flow style (`oac: {id: x}`) has no continuation and drops as a single line.
|
|
|
+ */
|
|
|
+function stripOacBlock(frontmatter: string[]): string[] {
|
|
|
+ const out: string[] = [];
|
|
|
+ let i = 0;
|
|
|
+
|
|
|
+ while (i < frontmatter.length) {
|
|
|
+ const line = frontmatter[i]!;
|
|
|
+
|
|
|
+ if (!isTopLevelKey(line, OAC_KEY)) {
|
|
|
+ out.push(line);
|
|
|
+ i += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ i += 1; // the `oac:` line itself
|
|
|
+ let end = i;
|
|
|
+ while (end < frontmatter.length) {
|
|
|
+ const next = frontmatter[end]!;
|
|
|
+ if (INDENTED.test(next) || BLANK.test(next)) {
|
|
|
+ end += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ // Give back trailing blanks: they separate the next key, they are not part of the block.
|
|
|
+ while (end > i && BLANK.test(frontmatter[end - 1]!)) end -= 1;
|
|
|
+ i = end;
|
|
|
+ }
|
|
|
+
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+/** Reassemble a file from stripped frontmatter and an untouched remainder. */
|
|
|
+function rebuild(frontmatter: string[], rest: string): string {
|
|
|
+ return ["---", ...frontmatter].join("\n") + "\n" + rest;
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Parsing helpers
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** Parse a frontmatter block into a plain object, or fail with a located message. */
|
|
|
+function parseFrontmatter(lines: string[], filePath?: string): Record<string, unknown> {
|
|
|
+ let parsed: unknown;
|
|
|
+ try {
|
|
|
+ parsed = yaml.load(lines.join("\n"));
|
|
|
+ } catch (cause) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ `frontmatter is not valid YAML: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
|
+ throw new OpenCodeEmitError("frontmatter must be a YAML mapping.", filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ return parsed as Record<string, unknown>;
|
|
|
+}
|
|
|
+
|
|
|
+/** Desugar a `permission:` value into ordered rules, or `undefined` when absent. */
|
|
|
+function orderedPermissions(value: unknown): GranularPermission | undefined {
|
|
|
+ return value === undefined ? undefined : desugarPermission(value);
|
|
|
+}
|
|
|
+
|
|
|
+/** Compare two ordered permission specs for exact equality, order included. */
|
|
|
+function samePermissions(a?: GranularPermission, b?: GranularPermission): boolean {
|
|
|
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Adapter
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/**
|
|
|
+ * Emits OpenCode agent files from canonical sources.
|
|
|
+ *
|
|
|
+ * @example
|
|
|
+ * ```ts
|
|
|
+ * const adapter = new OpenCodeAdapter();
|
|
|
+ * const { content, warnings } = await adapter.fromCanonical(
|
|
|
+ * readFileSync("content/agents/subagents/code/coder-agent.md", "utf-8")
|
|
|
+ * );
|
|
|
+ * // -> the same file with its `oac:` block removed, byte-for-byte otherwise
|
|
|
+ * ```
|
|
|
+ */
|
|
|
+export class OpenCodeAdapter extends BaseAdapter {
|
|
|
+ readonly name = "opencode";
|
|
|
+ readonly displayName = "OpenCode";
|
|
|
+
|
|
|
+ constructor() {
|
|
|
+ super();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==========================================================================
|
|
|
+ // Canonical emission — the build path
|
|
|
+ // ==========================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Emit the OpenCode agent file for one canonical source.
|
|
|
+ *
|
|
|
+ * Deterministic by construction: the output is a subsequence of the input's bytes. There is
|
|
|
+ * no clock, no host path, no directory read and no map iteration anywhere on this path, so
|
|
|
+ * two runs cannot differ.
|
|
|
+ *
|
|
|
+ * `async` deliberately: every failure below must surface as a REJECTED promise, not a
|
|
|
+ * synchronous throw. A method typed `Promise<T>` that throws synchronously breaks
|
|
|
+ * `fromCanonical(x).catch(...)` — the caller's handler is never installed and the error
|
|
|
+ * escapes as an uncaught exception, which for a build tool means a stack trace instead of
|
|
|
+ * "this file is missing its oac: block".
|
|
|
+ *
|
|
|
+ * @param source - a canonical agent file: OpenCode frontmatter + an `oac:` block.
|
|
|
+ * @returns the emitted content plus any warnings.
|
|
|
+ * @throws {OpenCodeEmitError} when the source is malformed, carries no `oac:` block, or when
|
|
|
+ * the emitted result would not round-trip its permissions.
|
|
|
+ */
|
|
|
+ async fromCanonical(
|
|
|
+ source: string,
|
|
|
+ options: FromCanonicalOptions = {}
|
|
|
+ ): Promise<CanonicalEmitResult> {
|
|
|
+ const { filePath } = options;
|
|
|
+ const { frontmatter, rest } = splitFrontmatter(source, filePath);
|
|
|
+ const parsed = parseFrontmatter(frontmatter, filePath);
|
|
|
+
|
|
|
+ if (!(OAC_KEY in parsed)) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ "no `oac:` block. A canonical agent file must carry one; without it this file is " +
|
|
|
+ "already an OpenCode agent and does not belong in the canonical tree.",
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate the whole canonical shape before emitting. This is what rejects a bad `oac:`
|
|
|
+ // block, an integer-like permission scope (which ECMAScript would reorder, silently
|
|
|
+ // changing last-match-wins precedence) and a malformed permission map.
|
|
|
+ const validated = CanonicalAgentSchema.safeParse(parsed);
|
|
|
+ if (!validated.success) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ `canonical validation failed:\n${validated.error.errors
|
|
|
+ .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
|
+ .join("\n")}`,
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const stripped = stripOacBlock(frontmatter);
|
|
|
+ const content = rebuild(stripped, rest);
|
|
|
+
|
|
|
+ return {
|
|
|
+ content,
|
|
|
+ warnings: this.verifyEmission(content, parsed, filePath),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Re-parse the emitted file and prove the strip did what it claims.
|
|
|
+ *
|
|
|
+ * Text surgery earns this check. A parse-and-dump emitter is wrong in obvious, visible ways;
|
|
|
+ * a line-range strip is wrong in quiet ones — an off-by-one that clips the last permission
|
|
|
+ * rule produces a file that still parses, still loads, and silently grants less (or more)
|
|
|
+ * than the author wrote. So the postcondition is asserted rather than assumed.
|
|
|
+ *
|
|
|
+ * @returns warnings for recoverable observations; throws when the emission is not faithful.
|
|
|
+ */
|
|
|
+ private verifyEmission(
|
|
|
+ content: string,
|
|
|
+ source: Record<string, unknown>,
|
|
|
+ filePath?: string
|
|
|
+ ): string[] {
|
|
|
+ const emitted = parseFrontmatter(splitFrontmatter(content, filePath).frontmatter, filePath);
|
|
|
+
|
|
|
+ // 1. The oac block is gone — proven by parsing, not by grepping for "oac:".
|
|
|
+ if (OAC_KEY in emitted) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ "internal error: emitted frontmatter still carries an `oac:` key.",
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Every other key survived, with an identical value. Catches both a clipped block and
|
|
|
+ // an over-eager strip that swallowed the key after `oac:`.
|
|
|
+ const expected = Object.keys(source).filter((key) => key !== OAC_KEY);
|
|
|
+ const actual = Object.keys(emitted);
|
|
|
+
|
|
|
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ `internal error: stripping \`oac:\` changed the frontmatter keys.\n` +
|
|
|
+ ` expected: ${expected.join(", ") || "(none)"}\n` +
|
|
|
+ ` emitted: ${actual.join(", ") || "(none)"}`,
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const key of expected) {
|
|
|
+ if (JSON.stringify(emitted[key]) !== JSON.stringify(source[key])) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ `internal error: stripping \`oac:\` altered the value of \`${key}\`.`,
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. Permissions desugar to the same ORDERED rules. The deep-equal above already implies
|
|
|
+ // this, but it is asserted in the canonical representation as well: order is the whole
|
|
|
+ // security property here, and `JSON.stringify` comparing two objects is only
|
|
|
+ // order-sensitive by accident of insertion order.
|
|
|
+ if (
|
|
|
+ !samePermissions(
|
|
|
+ orderedPermissions(source["permission"]),
|
|
|
+ orderedPermissions(emitted["permission"])
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ throw new OpenCodeEmitError(
|
|
|
+ "internal error: emitted permissions do not match the source in order or content.",
|
|
|
+ filePath
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. Report anything OpenCode would sweep into `options` rather than act on.
|
|
|
+ return actual
|
|
|
+ .filter((key) => !OPENCODE_KNOWN_KEYS.has(key))
|
|
|
+ .map(
|
|
|
+ (key) =>
|
|
|
+ `⚠️ Frontmatter key '${key}' is not part of OpenCode's agent schema ` +
|
|
|
+ `(config.ts:635-652). OpenCode will collect it into the agent's \`options\` rather ` +
|
|
|
+ `than act on it.`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The path an emitted agent is written to, relative to the project root.
|
|
|
+ *
|
|
|
+ * Derived from the source's path under the content root, NOT from `oac.category` + `oac.id`.
|
|
|
+ * That distinction is load-bearing: `content/agents/subagents/code/test-engineer.md` declares
|
|
|
+ * `id: tester`, so composing the path from the id would emit `subagents/code/tester.md` and
|
|
|
+ * orphan the `test-engineer.md` that OpenCode is already loading. The filesystem is the
|
|
|
+ * authority on layout; `oac.category` merely describes it.
|
|
|
+ *
|
|
|
+ * @param relativePath - POSIX path relative to the content root, e.g.
|
|
|
+ * `"subagents/code/coder-agent.md"`.
|
|
|
+ */
|
|
|
+ outputPath(relativePath: string): string {
|
|
|
+ return `${OPENCODE_AGENT_ROOT}/${relativePath}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==========================================================================
|
|
|
+ // BaseAdapter surface
|
|
|
+ // ==========================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * OpenCode agent files ARE the canonical format minus `oac:`, so importing one is a matter
|
|
|
+ * of reading its frontmatter. Identity that only the `oac:` block carries (id, category,
|
|
|
+ * version, …) cannot be recovered from an OpenCode file and is left to the caller.
|
|
|
+ */
|
|
|
+ toOAC(source: string): Promise<OpenAgent> {
|
|
|
+ const { frontmatter, rest } = splitFrontmatter(source);
|
|
|
+ const parsed = parseFrontmatter(frontmatter);
|
|
|
+ const body = rest.replace(/^---\r?\n?/, "");
|
|
|
+
|
|
|
+ const name = typeof parsed["name"] === "string" ? parsed["name"] : "opencode-agent";
|
|
|
+
|
|
|
+ return Promise.resolve({
|
|
|
+ frontmatter: {
|
|
|
+ name,
|
|
|
+ description:
|
|
|
+ typeof parsed["description"] === "string"
|
|
|
+ ? parsed["description"]
|
|
|
+ : "Agent imported from OpenCode",
|
|
|
+ mode:
|
|
|
+ parsed["mode"] === "primary" || parsed["mode"] === "all" ? parsed["mode"] : "subagent",
|
|
|
+ ...(typeof parsed["temperature"] === "number"
|
|
|
+ ? { temperature: parsed["temperature"] }
|
|
|
+ : {}),
|
|
|
+ ...(typeof parsed["model"] === "string" ? { model: parsed["model"] } : {}),
|
|
|
+ },
|
|
|
+ metadata: { name, tags: [], dependencies: [] },
|
|
|
+ systemPrompt: body.trim(),
|
|
|
+ contexts: [],
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Legacy `OpenAgent` -> OpenCode conversion.
|
|
|
+ *
|
|
|
+ * Deliberately unimplemented. The canonical build uses {@link fromCanonical}, whose input is
|
|
|
+ * the authored file itself; `OpenAgent` is the *lossy* legacy shape, whose `permission` field
|
|
|
+ * is an unordered `Record`. Emitting OpenCode from it would mean re-serializing a map whose
|
|
|
+ * key order is an ECMAScript accident — exactly the last-match-wins corruption this refactor
|
|
|
+ * exists to remove. Failing loudly beats shipping a plausible reordering.
|
|
|
+ */
|
|
|
+ fromOAC(_agent: OpenAgent): Promise<ConversionResult> {
|
|
|
+ return Promise.resolve(
|
|
|
+ this.createErrorResult([
|
|
|
+ "OpenCodeAdapter does not implement fromOAC(). Use fromCanonical(source) instead: " +
|
|
|
+ "OpenAgent cannot carry ordered permission rules, so emitting OpenCode from it " +
|
|
|
+ "would risk silently reordering a last-match-wins permission block.",
|
|
|
+ ])
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Agents live under `.opencode/agent/`; the per-agent path comes from {@link outputPath}. */
|
|
|
+ getConfigPath(): string {
|
|
|
+ return `${OPENCODE_AGENT_ROOT}/`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * OpenCode is the canonical target: it is the only one that carries ordered, scoped
|
|
|
+ * permissions without degradation, which is why this adapter has no projection logic.
|
|
|
+ */
|
|
|
+ getCapabilities(): ToolCapabilities {
|
|
|
+ return {
|
|
|
+ name: this.name,
|
|
|
+ displayName: this.displayName,
|
|
|
+ supportsMultipleAgents: true,
|
|
|
+ supportsSkills: true,
|
|
|
+ supportsHooks: false,
|
|
|
+ supportsGranularPermissions: true,
|
|
|
+ supportsContexts: true,
|
|
|
+ supportsCustomModels: true,
|
|
|
+ supportsTemperature: true,
|
|
|
+ supportsMaxSteps: true,
|
|
|
+ configFormat: "markdown",
|
|
|
+ outputStructure: "directory",
|
|
|
+ notes: [
|
|
|
+ "Agent files live at .opencode/agent/<category>/<name>.md, mirroring content/agents/.",
|
|
|
+ "Emission strips the `oac:` block and preserves every other byte, including comments.",
|
|
|
+ "Permission rules keep their authored order; OpenCode resolves them last-match-wins.",
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Nothing is lost emitting a canonical agent to OpenCode — the frontmatter is already
|
|
|
+ * OpenCode's own — so this reports only what OpenCode would not act on.
|
|
|
+ */
|
|
|
+ validateConversion(agent: OpenAgent): string[] {
|
|
|
+ const warnings: string[] = [];
|
|
|
+
|
|
|
+ // `hooks` is an OAC concept with no counterpart in OpenCode's Agent schema
|
|
|
+ // (config.ts:635-652), so it would be swept into `options` and never fire.
|
|
|
+ if (agent.frontmatter.hooks?.length) {
|
|
|
+ warnings.push(
|
|
|
+ this.unsupportedFeatureWarning("hooks", `${agent.frontmatter.hooks.length} hook(s)`)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return warnings;
|
|
|
+ }
|
|
|
+}
|