fix(converters): OpenCode subagent model and FQ agent name resolution (#483)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-01 16:45:07 -07:00
committed by GitHub
parent 428f4fd548
commit 577db53a2d
5 changed files with 298 additions and 11 deletions

View File

@@ -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(
/(?<![a-z0-9:/-])[a-z][a-z0-9-]*:[a-z][a-z0-9-]*:([a-z][a-z0-9-]*)(?![a-z0-9:-])/g,
"$1",
)
return result
}
function inferTemperature(agent: ClaudeAgent): number | undefined {
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {

View File

@@ -1,5 +1,6 @@
import path from "path"
import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformSkillContentForOpenCode } from "../converters/claude-to-opencode"
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
// 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
if (bundle.skillDirs.length > 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
)
}
}
}

View File

@@ -116,14 +116,20 @@ export async function copyDir(sourceDir: string, targetDir: string): Promise<voi
}
/**
* Copy a skill directory, optionally transforming SKILL.md content.
* All other files are copied verbatim. Used by target writers to apply
* Copy a skill directory, optionally transforming markdown content.
* Non-markdown files are copied verbatim. Used by target writers to apply
* platform-specific content transforms to pass-through skills.
*
* By default only SKILL.md is transformed (safe for slash-command rewrites
* that shouldn't touch reference files). Set `transformAllMarkdown` to also
* transform reference .md files — needed when the transform rewrites content
* that appears in reference files (e.g. fully-qualified agent names).
*/
export async function copySkillDir(
sourceDir: string,
targetDir: string,
transformSkillContent?: (content: string) => string,
transformAllMarkdown?: boolean,
): Promise<void> {
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 {