Skip to content

Commit 577db53

Browse files
tmchowclaude
andauthored
fix(converters): OpenCode subagent model and FQ agent name resolution (EveryInc#483)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 428f4fd commit 577db53

File tree

5 files changed

+298
-11
lines changed

5 files changed

+298
-11
lines changed

src/converters/claude-to-opencode.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
9393
mode: options.agentMode,
9494
}
9595

96-
if (agent.model && agent.model !== "inherit") {
96+
// Only write model for primary agents. Subagents inherit from the parent
97+
// session, making them provider-agnostic. Writing an explicit model like
98+
// "anthropic/claude-haiku-4-5" on a subagent causes ProviderModelNotFoundError
99+
// when the user's OpenCode env uses a different provider. See #477.
100+
if (agent.model && agent.model !== "inherit" && options.agentMode === "primary") {
97101
frontmatter.model = normalizeModelWithProvider(agent.model)
98102
}
99103

@@ -261,6 +265,30 @@ function rewriteClaudePaths(body: string): string {
261265
.replace(/\.claude\//g, ".opencode/")
262266
}
263267

268+
/**
269+
* Transform skill/agent content for OpenCode compatibility.
270+
* Composes path rewriting with fully-qualified agent name flattening.
271+
*
272+
* OpenCode resolves agents by flat filename, so 3-segment FQ references
273+
* like `compound-engineering:document-review:coherence-reviewer` must be
274+
* rewritten to just `coherence-reviewer`. 2-segment skill references
275+
* (e.g. `compound-engineering:document-review`) are left unchanged.
276+
* See #477.
277+
*/
278+
export function transformSkillContentForOpenCode(body: string): string {
279+
let result = rewriteClaudePaths(body)
280+
// Rewrite 3-segment FQ agent refs: plugin:category:agent-name -> agent-name.
281+
// Boundary assertions prevent partial matching on 4+ segment names
282+
// (e.g. `a:b:c:d` would otherwise produce `c:d` or `a:d`).
283+
// The `/` in the lookbehind prevents rewriting slash commands like
284+
// `/team:ops:deploy` — agent names are never preceded by `/`.
285+
result = result.replace(
286+
/(?<![a-z0-9:/-])[a-z][a-z0-9-]*:[a-z][a-z0-9-]*:([a-z][a-z0-9-]*)(?![a-z0-9:-])/g,
287+
"$1",
288+
)
289+
return result
290+
}
291+
264292
function inferTemperature(agent: ClaudeAgent): number | undefined {
265293
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
266294
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {

src/targets/opencode.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "path"
2-
import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
2+
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
3+
import { transformSkillContentForOpenCode } from "../converters/claude-to-opencode"
34
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
45

56
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
@@ -100,7 +101,12 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
100101
if (bundle.skillDirs.length > 0) {
101102
const skillsRoot = openCodePaths.skillsDir
102103
for (const skill of bundle.skillDirs) {
103-
await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name)))
104+
await copySkillDir(
105+
skill.sourceDir,
106+
path.join(skillsRoot, sanitizePathName(skill.name)),
107+
transformSkillContentForOpenCode,
108+
true, // transform all .md files — FQ agent names appear in references too
109+
)
104110
}
105111
}
106112
}

src/utils/files.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,20 @@ export async function copyDir(sourceDir: string, targetDir: string): Promise<voi
116116
}
117117

118118
/**
119-
* Copy a skill directory, optionally transforming SKILL.md content.
120-
* All other files are copied verbatim. Used by target writers to apply
119+
* Copy a skill directory, optionally transforming markdown content.
120+
* Non-markdown files are copied verbatim. Used by target writers to apply
121121
* platform-specific content transforms to pass-through skills.
122+
*
123+
* By default only SKILL.md is transformed (safe for slash-command rewrites
124+
* that shouldn't touch reference files). Set `transformAllMarkdown` to also
125+
* transform reference .md files — needed when the transform rewrites content
126+
* that appears in reference files (e.g. fully-qualified agent names).
122127
*/
123128
export async function copySkillDir(
124129
sourceDir: string,
125130
targetDir: string,
126131
transformSkillContent?: (content: string) => string,
132+
transformAllMarkdown?: boolean,
127133
): Promise<void> {
128134
await ensureDir(targetDir)
129135
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
@@ -133,9 +139,12 @@ export async function copySkillDir(
133139
const targetPath = path.join(targetDir, entry.name)
134140

135141
if (entry.isDirectory()) {
136-
await copySkillDir(sourcePath, targetPath, transformSkillContent)
142+
await copySkillDir(sourcePath, targetPath, transformSkillContent, transformAllMarkdown)
137143
} else if (entry.isFile()) {
138-
if (entry.name === "SKILL.md" && transformSkillContent) {
144+
const shouldTransform = transformSkillContent && (
145+
entry.name === "SKILL.md" || (transformAllMarkdown && entry.name.endsWith(".md"))
146+
)
147+
if (shouldTransform) {
139148
const content = await readText(sourcePath)
140149
await writeText(targetPath, transformSkillContent(content))
141150
} else {

tests/converter.test.ts

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import path from "path"
33
import { loadClaudePlugin } from "../src/parsers/claude"
4-
import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode"
4+
import { convertClaudeToOpenCode, transformSkillContentForOpenCode } from "../src/converters/claude-to-opencode"
55
import { parseFrontmatter } from "../src/utils/frontmatter"
66
import type { ClaudePlugin } from "../src/types/claude"
77

@@ -61,7 +61,7 @@ describe("convertClaudeToOpenCode", () => {
6161
test("normalizes models and infers temperature", async () => {
6262
const plugin = await loadClaudePlugin(fixtureRoot)
6363
const bundle = convertClaudeToOpenCode(plugin, {
64-
agentMode: "subagent",
64+
agentMode: "primary",
6565
inferTemperature: true,
6666
permissions: "none",
6767
})
@@ -78,7 +78,7 @@ describe("convertClaudeToOpenCode", () => {
7878
expect(commandParsed.data.model).toBe("openai/gpt-4o")
7979
})
8080

81-
test("resolves bare Claude model aliases to full IDs", () => {
81+
test("resolves bare Claude model aliases for primary agents", () => {
8282
const plugin: ClaudePlugin = {
8383
root: "/tmp/plugin",
8484
manifest: { name: "fixture", version: "1.0.0" },
@@ -96,7 +96,7 @@ describe("convertClaudeToOpenCode", () => {
9696
}
9797

9898
const bundle = convertClaudeToOpenCode(plugin, {
99-
agentMode: "subagent",
99+
agentMode: "primary",
100100
inferTemperature: false,
101101
permissions: "none",
102102
})
@@ -107,6 +107,91 @@ describe("convertClaudeToOpenCode", () => {
107107
expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5")
108108
})
109109

110+
test("omits model for subagents to allow provider inheritance (#477)", () => {
111+
const plugin: ClaudePlugin = {
112+
root: "/tmp/plugin",
113+
manifest: { name: "fixture", version: "1.0.0" },
114+
agents: [
115+
{
116+
name: "cheap-agent",
117+
description: "Agent using bare alias",
118+
body: "Test agent.",
119+
sourcePath: "/tmp/plugin/agents/cheap-agent.md",
120+
model: "haiku",
121+
},
122+
],
123+
commands: [],
124+
skills: [],
125+
}
126+
127+
const bundle = convertClaudeToOpenCode(plugin, {
128+
agentMode: "subagent",
129+
inferTemperature: false,
130+
permissions: "none",
131+
})
132+
133+
const agent = bundle.agents.find((a) => a.name === "cheap-agent")
134+
expect(agent).toBeDefined()
135+
const parsed = parseFrontmatter(agent!.content)
136+
expect(parsed.data.model).toBeUndefined()
137+
})
138+
139+
test("omits model when agent has no model field regardless of mode", () => {
140+
const plugin: ClaudePlugin = {
141+
root: "/tmp/plugin",
142+
manifest: { name: "fixture", version: "1.0.0" },
143+
agents: [
144+
{
145+
name: "no-model-agent",
146+
description: "Agent without model",
147+
body: "Test agent.",
148+
sourcePath: "/tmp/plugin/agents/no-model-agent.md",
149+
},
150+
],
151+
commands: [],
152+
skills: [],
153+
}
154+
155+
for (const mode of ["primary", "subagent"] as const) {
156+
const bundle = convertClaudeToOpenCode(plugin, {
157+
agentMode: mode,
158+
inferTemperature: false,
159+
permissions: "none",
160+
})
161+
const agent = bundle.agents.find((a) => a.name === "no-model-agent")
162+
const parsed = parseFrontmatter(agent!.content)
163+
expect(parsed.data.model).toBeUndefined()
164+
}
165+
})
166+
167+
test("omits model: inherit even in primary mode", () => {
168+
const plugin: ClaudePlugin = {
169+
root: "/tmp/plugin",
170+
manifest: { name: "fixture", version: "1.0.0" },
171+
agents: [
172+
{
173+
name: "inherit-agent",
174+
description: "Agent with inherit model",
175+
body: "Test agent.",
176+
sourcePath: "/tmp/plugin/agents/inherit-agent.md",
177+
model: "inherit",
178+
},
179+
],
180+
commands: [],
181+
skills: [],
182+
}
183+
184+
const bundle = convertClaudeToOpenCode(plugin, {
185+
agentMode: "primary",
186+
inferTemperature: false,
187+
permissions: "none",
188+
})
189+
190+
const agent = bundle.agents.find((a) => a.name === "inherit-agent")
191+
const parsed = parseFrontmatter(agent!.content)
192+
expect(parsed.data.model).toBeUndefined()
193+
})
194+
110195
test("converts hooks into plugin file", async () => {
111196
const plugin = await loadClaudePlugin(fixtureRoot)
112197
const bundle = convertClaudeToOpenCode(plugin, {
@@ -319,3 +404,91 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
319404
expect(parsed.body).toContain("Do the thing")
320405
})
321406
})
407+
408+
describe("transformSkillContentForOpenCode", () => {
409+
test("rewrites 3-segment FQ agent names to flat names", () => {
410+
const input = "- `compound-engineering:document-review:coherence-reviewer`"
411+
expect(transformSkillContentForOpenCode(input)).toBe("- `coherence-reviewer`")
412+
})
413+
414+
test("rewrites multiple FQ agent refs in one block", () => {
415+
const input = [
416+
"- `compound-engineering:document-review:coherence-reviewer`",
417+
"- `compound-engineering:document-review:feasibility-reviewer`",
418+
"- `compound-engineering:review:security-sentinel`",
419+
].join("\n")
420+
const result = transformSkillContentForOpenCode(input)
421+
expect(result).toContain("- `coherence-reviewer`")
422+
expect(result).toContain("- `feasibility-reviewer`")
423+
expect(result).toContain("- `security-sentinel`")
424+
expect(result).not.toContain("compound-engineering:")
425+
})
426+
427+
test("preserves 2-segment skill references", () => {
428+
const input = 'load the `compound-engineering:document-review` skill'
429+
// 2-segment refs are skill names, not agent names — left unchanged
430+
expect(transformSkillContentForOpenCode(input)).toBe(input)
431+
})
432+
433+
test("rewrites .claude/ paths to .opencode/", () => {
434+
const input = "Read `.claude/config.json`"
435+
expect(transformSkillContentForOpenCode(input)).toBe("Read `.opencode/config.json`")
436+
})
437+
438+
test("rewrites ~/. claude/ paths to ~/.config/opencode/", () => {
439+
const input = "Look in `~/.claude/plugins/`"
440+
expect(transformSkillContentForOpenCode(input)).toBe("Look in `~/.config/opencode/plugins/`")
441+
})
442+
443+
test("handles FQ names in JSON-like contexts", () => {
444+
const input = ' subagent_type: "compound-engineering:review:security-sentinel",'
445+
expect(transformSkillContentForOpenCode(input)).toBe(
446+
' subagent_type: "security-sentinel",'
447+
)
448+
})
449+
450+
test("does not match URLs or non-agent colon patterns", () => {
451+
const cases = [
452+
"Visit https://example.com/path",
453+
"Use http://localhost:8080/api",
454+
"Set font-size: 12px; color: red;",
455+
"Time is 10:30:45 UTC",
456+
'key: "value"',
457+
]
458+
for (const input of cases) {
459+
expect(transformSkillContentForOpenCode(input)).toBe(input)
460+
}
461+
})
462+
463+
test("rewrites FQ names from any plugin namespace", () => {
464+
const input = "- `other-plugin:category:my-agent`"
465+
expect(transformSkillContentForOpenCode(input)).toBe("- `my-agent`")
466+
})
467+
468+
test("preserves bare agent names (no namespace)", () => {
469+
const input = "Use `coherence-reviewer` for review."
470+
expect(transformSkillContentForOpenCode(input)).toBe(input)
471+
})
472+
473+
test("preserves 2-segment plugin:agent names (no category)", () => {
474+
const input = "Spawn `compound-engineering:coherence-reviewer` as subagent."
475+
// 2-segment names could be skill refs or flat agent refs — not rewritten
476+
expect(transformSkillContentForOpenCode(input)).toBe(input)
477+
})
478+
479+
test("does not partially rewrite 4-segment colon patterns", () => {
480+
const input = "`a:b:c:d`"
481+
// Without the lookahead, this would become `c:d` — a broken partial rewrite
482+
expect(transformSkillContentForOpenCode(input)).toBe(input)
483+
})
484+
485+
test("preserves 3-segment slash commands", () => {
486+
const cases = [
487+
"Run `/team:ops:deploy` to deploy.",
488+
"Use /compound-engineering:review:check after changes.",
489+
]
490+
for (const input of cases) {
491+
expect(transformSkillContentForOpenCode(input)).toBe(input)
492+
}
493+
})
494+
})

0 commit comments

Comments
 (0)