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:
@@ -93,7 +93,11 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
|
|||||||
mode: options.agentMode,
|
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)
|
frontmatter.model = normalizeModelWithProvider(agent.model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +265,30 @@ function rewriteClaudePaths(body: string): string {
|
|||||||
.replace(/\.claude\//g, ".opencode/")
|
.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 {
|
function inferTemperature(agent: ClaudeAgent): number | undefined {
|
||||||
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
|
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
|
||||||
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
|
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
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"
|
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
|
||||||
|
|
||||||
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
|
// 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) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
const skillsRoot = openCodePaths.skillsDir
|
const skillsRoot = openCodePaths.skillsDir
|
||||||
for (const skill of bundle.skillDirs) {
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,14 +116,20 @@ export async function copyDir(sourceDir: string, targetDir: string): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy a skill directory, optionally transforming SKILL.md content.
|
* Copy a skill directory, optionally transforming markdown content.
|
||||||
* All other files are copied verbatim. Used by target writers to apply
|
* Non-markdown files are copied verbatim. Used by target writers to apply
|
||||||
* platform-specific content transforms to pass-through skills.
|
* 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(
|
export async function copySkillDir(
|
||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
transformSkillContent?: (content: string) => string,
|
transformSkillContent?: (content: string) => string,
|
||||||
|
transformAllMarkdown?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await ensureDir(targetDir)
|
await ensureDir(targetDir)
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
|
||||||
@@ -133,9 +139,12 @@ export async function copySkillDir(
|
|||||||
const targetPath = path.join(targetDir, entry.name)
|
const targetPath = path.join(targetDir, entry.name)
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await copySkillDir(sourcePath, targetPath, transformSkillContent)
|
await copySkillDir(sourcePath, targetPath, transformSkillContent, transformAllMarkdown)
|
||||||
} else if (entry.isFile()) {
|
} 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)
|
const content = await readText(sourcePath)
|
||||||
await writeText(targetPath, transformSkillContent(content))
|
await writeText(targetPath, transformSkillContent(content))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { loadClaudePlugin } from "../src/parsers/claude"
|
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 { parseFrontmatter } from "../src/utils/frontmatter"
|
||||||
import type { ClaudePlugin } from "../src/types/claude"
|
import type { ClaudePlugin } from "../src/types/claude"
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
test("normalizes models and infers temperature", async () => {
|
test("normalizes models and infers temperature", async () => {
|
||||||
const plugin = await loadClaudePlugin(fixtureRoot)
|
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||||
const bundle = convertClaudeToOpenCode(plugin, {
|
const bundle = convertClaudeToOpenCode(plugin, {
|
||||||
agentMode: "subagent",
|
agentMode: "primary",
|
||||||
inferTemperature: true,
|
inferTemperature: true,
|
||||||
permissions: "none",
|
permissions: "none",
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,36 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
expect(commandParsed.data.model).toBe("openai/gpt-4o")
|
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 = {
|
const plugin: ClaudePlugin = {
|
||||||
root: "/tmp/plugin",
|
root: "/tmp/plugin",
|
||||||
manifest: { name: "fixture", version: "1.0.0" },
|
manifest: { name: "fixture", version: "1.0.0" },
|
||||||
@@ -104,7 +133,63 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
const agent = bundle.agents.find((a) => a.name === "cheap-agent")
|
const agent = bundle.agents.find((a) => a.name === "cheap-agent")
|
||||||
expect(agent).toBeDefined()
|
expect(agent).toBeDefined()
|
||||||
const parsed = parseFrontmatter(agent!.content)
|
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 () => {
|
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")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -223,6 +223,77 @@ describe("writeOpenCodeBundle", () => {
|
|||||||
expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
|
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 () => {
|
test("backs up existing command .md file before overwriting", async () => {
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
|
||||||
const outputRoot = path.join(tempRoot, ".opencode")
|
const outputRoot = path.join(tempRoot, ".opencode")
|
||||||
|
|||||||
Reference in New Issue
Block a user