diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 4b58e83..8b15068 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -93,7 +93,11 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) { mode: options.agentMode, } - if (agent.model && agent.model !== "inherit") { + // Only write model for primary agents. Subagents inherit from the parent + // session, making them provider-agnostic. Writing an explicit model like + // "anthropic/claude-haiku-4-5" on a subagent causes ProviderModelNotFoundError + // when the user's OpenCode env uses a different provider. See #477. + if (agent.model && agent.model !== "inherit" && options.agentMode === "primary") { frontmatter.model = normalizeModelWithProvider(agent.model) } @@ -261,6 +265,30 @@ function rewriteClaudePaths(body: string): string { .replace(/\.claude\//g, ".opencode/") } +/** + * Transform skill/agent content for OpenCode compatibility. + * Composes path rewriting with fully-qualified agent name flattening. + * + * OpenCode resolves agents by flat filename, so 3-segment FQ references + * like `compound-engineering:document-review:coherence-reviewer` must be + * rewritten to just `coherence-reviewer`. 2-segment skill references + * (e.g. `compound-engineering:document-review`) are left unchanged. + * See #477. + */ +export function transformSkillContentForOpenCode(body: string): string { + let result = rewriteClaudePaths(body) + // Rewrite 3-segment FQ agent refs: plugin:category:agent-name -> agent-name. + // Boundary assertions prevent partial matching on 4+ segment names + // (e.g. `a:b:c:d` would otherwise produce `c:d` or `a:d`). + // The `/` in the lookbehind prevents rewriting slash commands like + // `/team:ops:deploy` — agent names are never preceded by `/`. + result = result.replace( + /(? 0) { const skillsRoot = openCodePaths.skillsDir for (const skill of bundle.skillDirs) { - await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name))) + await copySkillDir( + skill.sourceDir, + path.join(skillsRoot, sanitizePathName(skill.name)), + transformSkillContentForOpenCode, + true, // transform all .md files — FQ agent names appear in references too + ) } } } diff --git a/src/utils/files.ts b/src/utils/files.ts index 4bed7fe..ad35c99 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -116,14 +116,20 @@ export async function copyDir(sourceDir: string, targetDir: string): Promise string, + transformAllMarkdown?: boolean, ): Promise { await ensureDir(targetDir) const entries = await fs.readdir(sourceDir, { withFileTypes: true }) @@ -133,9 +139,12 @@ export async function copySkillDir( const targetPath = path.join(targetDir, entry.name) if (entry.isDirectory()) { - await copySkillDir(sourcePath, targetPath, transformSkillContent) + await copySkillDir(sourcePath, targetPath, transformSkillContent, transformAllMarkdown) } else if (entry.isFile()) { - if (entry.name === "SKILL.md" && transformSkillContent) { + const shouldTransform = transformSkillContent && ( + entry.name === "SKILL.md" || (transformAllMarkdown && entry.name.endsWith(".md")) + ) + if (shouldTransform) { const content = await readText(sourcePath) await writeText(targetPath, transformSkillContent(content)) } else { diff --git a/tests/converter.test.ts b/tests/converter.test.ts index dfac9ab..5e697dc 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { loadClaudePlugin } from "../src/parsers/claude" -import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode" +import { convertClaudeToOpenCode, transformSkillContentForOpenCode } from "../src/converters/claude-to-opencode" import { parseFrontmatter } from "../src/utils/frontmatter" import type { ClaudePlugin } from "../src/types/claude" @@ -61,7 +61,7 @@ describe("convertClaudeToOpenCode", () => { test("normalizes models and infers temperature", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { - agentMode: "subagent", + agentMode: "primary", inferTemperature: true, permissions: "none", }) @@ -78,7 +78,36 @@ describe("convertClaudeToOpenCode", () => { expect(commandParsed.data.model).toBe("openai/gpt-4o") }) - test("resolves bare Claude model aliases to full IDs", () => { + test("resolves bare Claude model aliases for primary agents", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "cheap-agent", + description: "Agent using bare alias", + body: "Test agent.", + sourcePath: "/tmp/plugin/agents/cheap-agent.md", + model: "haiku", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "primary", + inferTemperature: false, + permissions: "none", + }) + + const agent = bundle.agents.find((a) => a.name === "cheap-agent") + expect(agent).toBeDefined() + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5") + }) + + test("omits model for subagents to allow provider inheritance (#477)", () => { const plugin: ClaudePlugin = { root: "/tmp/plugin", manifest: { name: "fixture", version: "1.0.0" }, @@ -104,7 +133,63 @@ describe("convertClaudeToOpenCode", () => { const agent = bundle.agents.find((a) => a.name === "cheap-agent") expect(agent).toBeDefined() const parsed = parseFrontmatter(agent!.content) - expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5") + expect(parsed.data.model).toBeUndefined() + }) + + test("omits model when agent has no model field regardless of mode", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "no-model-agent", + description: "Agent without model", + body: "Test agent.", + sourcePath: "/tmp/plugin/agents/no-model-agent.md", + }, + ], + commands: [], + skills: [], + } + + for (const mode of ["primary", "subagent"] as const) { + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: mode, + inferTemperature: false, + permissions: "none", + }) + const agent = bundle.agents.find((a) => a.name === "no-model-agent") + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.model).toBeUndefined() + } + }) + + test("omits model: inherit even in primary mode", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "inherit-agent", + description: "Agent with inherit model", + body: "Test agent.", + sourcePath: "/tmp/plugin/agents/inherit-agent.md", + model: "inherit", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "primary", + inferTemperature: false, + permissions: "none", + }) + + const agent = bundle.agents.find((a) => a.name === "inherit-agent") + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.model).toBeUndefined() }) test("converts hooks into plugin file", async () => { @@ -319,3 +404,91 @@ Run \`/compound-engineering-setup\` to create a settings file.`, expect(parsed.body).toContain("Do the thing") }) }) + +describe("transformSkillContentForOpenCode", () => { + test("rewrites 3-segment FQ agent names to flat names", () => { + const input = "- `compound-engineering:document-review:coherence-reviewer`" + expect(transformSkillContentForOpenCode(input)).toBe("- `coherence-reviewer`") + }) + + test("rewrites multiple FQ agent refs in one block", () => { + const input = [ + "- `compound-engineering:document-review:coherence-reviewer`", + "- `compound-engineering:document-review:feasibility-reviewer`", + "- `compound-engineering:review:security-sentinel`", + ].join("\n") + const result = transformSkillContentForOpenCode(input) + expect(result).toContain("- `coherence-reviewer`") + expect(result).toContain("- `feasibility-reviewer`") + expect(result).toContain("- `security-sentinel`") + expect(result).not.toContain("compound-engineering:") + }) + + test("preserves 2-segment skill references", () => { + const input = 'load the `compound-engineering:document-review` skill' + // 2-segment refs are skill names, not agent names — left unchanged + expect(transformSkillContentForOpenCode(input)).toBe(input) + }) + + test("rewrites .claude/ paths to .opencode/", () => { + const input = "Read `.claude/config.json`" + expect(transformSkillContentForOpenCode(input)).toBe("Read `.opencode/config.json`") + }) + + test("rewrites ~/. claude/ paths to ~/.config/opencode/", () => { + const input = "Look in `~/.claude/plugins/`" + expect(transformSkillContentForOpenCode(input)).toBe("Look in `~/.config/opencode/plugins/`") + }) + + test("handles FQ names in JSON-like contexts", () => { + const input = ' subagent_type: "compound-engineering:review:security-sentinel",' + expect(transformSkillContentForOpenCode(input)).toBe( + ' subagent_type: "security-sentinel",' + ) + }) + + test("does not match URLs or non-agent colon patterns", () => { + const cases = [ + "Visit https://example.com/path", + "Use http://localhost:8080/api", + "Set font-size: 12px; color: red;", + "Time is 10:30:45 UTC", + 'key: "value"', + ] + for (const input of cases) { + expect(transformSkillContentForOpenCode(input)).toBe(input) + } + }) + + test("rewrites FQ names from any plugin namespace", () => { + const input = "- `other-plugin:category:my-agent`" + expect(transformSkillContentForOpenCode(input)).toBe("- `my-agent`") + }) + + test("preserves bare agent names (no namespace)", () => { + const input = "Use `coherence-reviewer` for review." + expect(transformSkillContentForOpenCode(input)).toBe(input) + }) + + test("preserves 2-segment plugin:agent names (no category)", () => { + const input = "Spawn `compound-engineering:coherence-reviewer` as subagent." + // 2-segment names could be skill refs or flat agent refs — not rewritten + expect(transformSkillContentForOpenCode(input)).toBe(input) + }) + + test("does not partially rewrite 4-segment colon patterns", () => { + const input = "`a:b:c:d`" + // Without the lookahead, this would become `c:d` — a broken partial rewrite + expect(transformSkillContentForOpenCode(input)).toBe(input) + }) + + test("preserves 3-segment slash commands", () => { + const cases = [ + "Run `/team:ops:deploy` to deploy.", + "Use /compound-engineering:review:check after changes.", + ] + for (const input of cases) { + expect(transformSkillContentForOpenCode(input)).toBe(input) + } + }) +}) diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index 33b5b4c..aba0cea 100644 --- a/tests/opencode-writer.test.ts +++ b/tests/opencode-writer.test.ts @@ -223,6 +223,77 @@ describe("writeOpenCodeBundle", () => { expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n") }) + test("rewrites FQ agent names in copied skill markdown (#477)", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-skill-transform-")) + const skillSrcDir = path.join(tempRoot, "src-skill") + const refsDir = path.join(skillSrcDir, "references") + await fs.mkdir(refsDir, { recursive: true }) + await fs.writeFile( + path.join(skillSrcDir, "SKILL.md"), + "---\nname: test-skill\n---\n\n- `compound-engineering:review:coherence-reviewer`\n" + ) + await fs.writeFile( + path.join(refsDir, "agents.md"), + "Use `compound-engineering:research:repo-research-analyst` for codebase analysis.\n" + ) + + const outputRoot = path.join(tempRoot, ".opencode") + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [], + plugins: [], + commandFiles: [], + skillDirs: [{ name: "test-skill", sourceDir: skillSrcDir }], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + const skillContent = await fs.readFile( + path.join(outputRoot, "skills", "test-skill", "SKILL.md"), + "utf8" + ) + expect(skillContent).toContain("`coherence-reviewer`") + expect(skillContent).not.toContain("compound-engineering:review:coherence-reviewer") + + const refContent = await fs.readFile( + path.join(outputRoot, "skills", "test-skill", "references", "agents.md"), + "utf8" + ) + expect(refContent).toContain("`repo-research-analyst`") + expect(refContent).not.toContain("compound-engineering:research:repo-research-analyst") + }) + + test("does not transform non-markdown files in skill directories", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-skill-nonmd-")) + const skillSrcDir = path.join(tempRoot, "src-skill") + const scriptsDir = path.join(skillSrcDir, "scripts") + await fs.mkdir(scriptsDir, { recursive: true }) + await fs.writeFile( + path.join(skillSrcDir, "SKILL.md"), + "---\nname: test-skill\n---\n\nSkill body.\n" + ) + const scriptContent = "#!/bin/bash\n# compound-engineering:review:security-sentinel\necho done\n" + await fs.writeFile(path.join(scriptsDir, "run.sh"), scriptContent) + + const outputRoot = path.join(tempRoot, ".opencode") + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [], + plugins: [], + commandFiles: [], + skillDirs: [{ name: "test-skill", sourceDir: skillSrcDir }], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + const copiedScript = await fs.readFile( + path.join(outputRoot, "skills", "test-skill", "scripts", "run.sh"), + "utf8" + ) + // Non-markdown files should be copied verbatim — no FQ rewriting + expect(copiedScript).toBe(scriptContent) + }) + test("backs up existing command .md file before overwriting", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-")) const outputRoot = path.join(tempRoot, ".opencode")