refactor(install): prefer native plugin install across targets (#609)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

Co-authored-by: John Cavanaugh <cavanaug@users.noreply.github.com>
This commit is contained in:
Trevin Chow
2026-04-20 18:47:07 -07:00
committed by GitHub
parent 9497a00d90
commit c2d60b47be
104 changed files with 7073 additions and 7068 deletions

View File

@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { loadClaudeHome } from "../src/parsers/claude-home"
describe("loadClaudeHome", () => {
test("loads personal skills, commands, and MCP servers", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-"))
const skillDir = path.join(tempHome, "skills", "reviewer")
const commandsDir = path.join(tempHome, "commands")
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: reviewer\n---\nReview things.\n")
await fs.mkdir(path.join(commandsDir, "workflows"), { recursive: true })
await fs.writeFile(
path.join(commandsDir, "workflows", "plan.md"),
"---\ndescription: Planning command\nargument-hint: \"[feature]\"\n---\nPlan the work.\n",
)
await fs.writeFile(
path.join(commandsDir, "custom.md"),
"---\nname: custom-command\ndescription: Custom command\nallowed-tools: Bash, Read\n---\nDo custom work.\n",
)
await fs.writeFile(
path.join(tempHome, "settings.json"),
JSON.stringify({
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}),
)
const config = await loadClaudeHome(tempHome)
expect(config.skills.map((skill) => skill.name)).toEqual(["reviewer"])
expect(config.commands?.map((command) => command.name)).toEqual([
"custom-command",
"workflows:plan",
])
expect(config.commands?.find((command) => command.name === "workflows:plan")?.argumentHint).toBe("[feature]")
expect(config.commands?.find((command) => command.name === "custom-command")?.allowedTools).toEqual(["Bash", "Read"])
expect(config.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
})
test("keeps personal skill directory names stable even when frontmatter name differs", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-skill-name-"))
const skillDir = path.join(tempHome, "skills", "reviewer")
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
"---\nname: ce-plan\ndescription: Reviewer skill\nargument-hint: \"[topic]\"\n---\nReview things.\n",
)
const config = await loadClaudeHome(tempHome)
expect(config.skills).toHaveLength(1)
expect(config.skills[0]?.name).toBe("reviewer")
expect(config.skills[0]?.description).toBe("Reviewer skill")
expect(config.skills[0]?.argumentHint).toBe("[topic]")
})
test("keeps personal skills when frontmatter is malformed", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-skill-yaml-"))
const skillDir = path.join(tempHome, "skills", "reviewer")
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
"---\nname: ce-plan\nfoo: [unterminated\n---\nReview things.\n",
)
const config = await loadClaudeHome(tempHome)
expect(config.skills).toHaveLength(1)
expect(config.skills[0]?.name).toBe("reviewer")
expect(config.skills[0]?.description).toBeUndefined()
expect(config.skills[0]?.argumentHint).toBeUndefined()
})
})

View File

@@ -6,6 +6,7 @@ import { loadClaudePlugin } from "../src/parsers/claude"
import { filterSkillsByPlatform } from "../src/types/claude"
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const compoundPluginRoot = path.join(import.meta.dir, "..", "plugins", "compound-engineering")
const mcpFixtureRoot = path.join(import.meta.dir, "fixtures", "mcp-file")
const customPathsRoot = path.join(import.meta.dir, "fixtures", "custom-paths")
const invalidCommandPathRoot = path.join(import.meta.dir, "fixtures", "invalid-command-path")
@@ -32,6 +33,14 @@ async function makeMinimalPluginRoot(): Promise<string> {
}
describe("loadClaudePlugin", () => {
test("current compound-engineering plugin ships skills and agents but no source commands", async () => {
const plugin = await loadClaudePlugin(compoundPluginRoot)
expect(plugin.commands).toHaveLength(0)
expect(plugin.skills.length).toBeGreaterThan(0)
expect(plugin.agents.length).toBeGreaterThan(0)
})
test("loads manifest, agents, commands, skills, hooks", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@ const fixturePlugin: ClaudePlugin = {
}
describe("convertClaudeToCodex", () => {
test("converts commands to prompts and agents to skills", () => {
test("converts commands to prompts and agents to custom agents", () => {
const bundle = convertClaudeToCodex(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
@@ -64,7 +64,8 @@ describe("convertClaudeToCodex", () => {
expect(parsedPrompt.body).toContain("Plan the work.")
expect(bundle.skillDirs[0]?.name).toBe("existing-skill")
expect(bundle.generatedSkills).toHaveLength(2)
expect(bundle.generatedSkills).toHaveLength(1)
expect(bundle.agents).toHaveLength(1)
const commandSkill = bundle.generatedSkills.find((skill) => skill.name === "workflows-plan")
expect(commandSkill).toBeDefined()
@@ -73,16 +74,14 @@ describe("convertClaudeToCodex", () => {
expect(parsedCommandSkill.data.description).toBe("Planning command")
expect(parsedCommandSkill.body).toContain("Allowed tools")
const agentSkill = bundle.generatedSkills.find((skill) => skill.name === "security-reviewer")
expect(agentSkill).toBeDefined()
const parsedSkill = parseFrontmatter(agentSkill!.content)
expect(parsedSkill.data.name).toBe("security-reviewer")
expect(parsedSkill.data.description).toBe("Security-focused agent")
expect(parsedSkill.body).toContain("Capabilities")
expect(parsedSkill.body).toContain("Threat modeling")
const agent = bundle.agents.find((item) => item.name === "security-reviewer")
expect(agent).toBeDefined()
expect(agent!.description).toBe("Security-focused agent")
expect(agent!.instructions).toContain("Capabilities")
expect(agent!.instructions).toContain("Threat modeling")
})
test("drops model field (Codex skill frontmatter does not support model)", () => {
test("drops model field from Codex custom agents", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
@@ -104,8 +103,9 @@ describe("convertClaudeToCodex", () => {
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "fast-agent")
expect(parseFrontmatter(skill!.content).data.model).toBeUndefined()
const agent = bundle.agents.find((s) => s.name === "fast-agent")
expect(agent).toBeDefined()
expect("model" in agent!).toBe(false)
})
test("copies workflow skills as regular skills and omits workflows aliases", () => {
@@ -190,7 +190,7 @@ describe("convertClaudeToCodex", () => {
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
})
test("transforms Task agent calls to skill references", () => {
test("transforms known Task agent calls to custom agent spawns", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
@@ -208,7 +208,26 @@ Task best-practices-researcher(topic)`,
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
agents: [
{
name: "repo-research-analyst",
description: "Repo research",
body: "Research repositories.",
sourcePath: "/tmp/plugin/agents/repo-research-analyst.md",
},
{
name: "learnings-researcher",
description: "Learning research",
body: "Search learnings.",
sourcePath: "/tmp/plugin/agents/learnings-researcher.md",
},
{
name: "best-practices-researcher",
description: "Best practices",
body: "Search best practices.",
sourcePath: "/tmp/plugin/agents/best-practices-researcher.md",
},
],
skills: [],
}
@@ -222,17 +241,16 @@ Task best-practices-researcher(topic)`,
expect(commandSkill).toBeDefined()
const parsed = parseFrontmatter(commandSkill!.content)
// Task calls should be transformed to skill references
expect(parsed.body).toContain("Use the $repo-research-analyst skill to: feature_description")
expect(parsed.body).toContain("Use the $learnings-researcher skill to: feature_description")
expect(parsed.body).toContain("Use the $best-practices-researcher skill to: topic")
expect(parsed.body).toContain("Spawn the custom agent `repo-research-analyst` with task: feature_description")
expect(parsed.body).toContain("Spawn the custom agent `learnings-researcher` with task: feature_description")
expect(parsed.body).toContain("Spawn the custom agent `best-practices-researcher` with task: topic")
// Original Task syntax should not remain
expect(parsed.body).not.toContain("Task repo-research-analyst")
expect(parsed.body).not.toContain("Task learnings-researcher")
})
test("transforms namespaced Task agent calls to skill references using final segment", () => {
test("transforms namespaced Task agent calls to category-qualified custom agents", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
@@ -241,16 +259,35 @@ Task best-practices-researcher(topic)`,
description: "Planning with namespaced agents",
body: `Run these agents in parallel:
- Task compound-engineering:research:repo-research-analyst(feature_description)
- Task compound-engineering:research:learnings-researcher(feature_description)
- Task compound-engineering:research:ce-repo-research-analyst(feature_description)
- Task compound-engineering:research:ce-learnings-researcher(feature_description)
Then consolidate findings.
Task compound-engineering:review:security-reviewer(code_diff)`,
Task compound-engineering:review:ce-security-reviewer(code_diff)`,
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
agents: [
{
name: "ce-repo-research-analyst",
description: "Repo research",
body: "Research repositories.",
sourcePath: "/tmp/plugin/agents/research/ce-repo-research-analyst.agent.md",
},
{
name: "ce-learnings-researcher",
description: "Learning research",
body: "Search learnings.",
sourcePath: "/tmp/plugin/agents/research/ce-learnings-researcher.agent.md",
},
{
name: "ce-security-reviewer",
description: "Security review",
body: "Review security.",
sourcePath: "/tmp/plugin/agents/review/ce-security-reviewer.agent.md",
},
],
skills: [],
}
@@ -264,10 +301,9 @@ Task compound-engineering:review:security-reviewer(code_diff)`,
expect(commandSkill).toBeDefined()
const parsed = parseFrontmatter(commandSkill!.content)
// Namespaced Task calls should use only the final segment as the skill name
expect(parsed.body).toContain("Use the $repo-research-analyst skill to: feature_description")
expect(parsed.body).toContain("Use the $learnings-researcher skill to: feature_description")
expect(parsed.body).toContain("Use the $security-reviewer skill to: code_diff")
expect(parsed.body).toContain("Spawn the custom agent `research-ce-repo-research-analyst` with task: feature_description")
expect(parsed.body).toContain("Spawn the custom agent `research-ce-learnings-researcher` with task: feature_description")
expect(parsed.body).toContain("Spawn the custom agent `review-ce-security-reviewer` with task: code_diff")
// Original namespaced Task syntax should not remain
expect(parsed.body).not.toContain("Task compound-engineering:")
@@ -284,7 +320,14 @@ Task compound-engineering:review:security-reviewer(code_diff)`,
sourcePath: "/tmp/plugin/commands/review.md",
},
],
agents: [],
agents: [
{
name: "ce-code-simplicity-reviewer",
description: "Simplicity review",
body: "Review simplicity.",
sourcePath: "/tmp/plugin/agents/review/ce-code-simplicity-reviewer.agent.md",
},
],
skills: [],
}
@@ -297,7 +340,7 @@ Task compound-engineering:review:security-reviewer(code_diff)`,
const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
expect(commandSkill).toBeDefined()
const parsed = parseFrontmatter(commandSkill!.content)
expect(parsed.body).toContain("Use the $code-simplicity-reviewer skill")
expect(parsed.body).toContain("Spawn the custom agent `review-ce-code-simplicity-reviewer`")
expect(parsed.body).not.toContain("compound-engineering:")
expect(parsed.body).not.toContain("skill to:")
})
@@ -372,15 +415,14 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`,
permissions: "none",
})
const agentSkill = bundle.generatedSkills.find((s) => s.name === "session-historian")
expect(agentSkill).toBeDefined()
expect(agentSkill!.sidecarDirs).toEqual([
const agent = bundle.agents.find((s) => s.name === "research-session-historian")
expect(agent).toBeDefined()
expect(agent!.sidecarDirs).toEqual([
{ sourceDir: scriptDir, targetName: "session-history-scripts" },
])
const parsed = parseFrontmatter(agentSkill!.content)
expect(parsed.body).toContain("<script-dir>/discover-sessions.sh")
expect(parsed.body).not.toContain("<script-dir>/prompts:discover-sessions.sh")
expect(agent!.instructions).toContain("<script-dir>/discover-sessions.sh")
expect(agent!.instructions).not.toContain("<script-dir>/prompts:discover-sessions.sh")
})
test("transforms workflow skill slash commands to Codex skill references", () => {
@@ -509,7 +551,7 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
expect(parsed.body).toContain("compound-engineering.local.md")
})
test("rewrites .claude/ paths in agent skill bodies", () => {
test("preserves tool-agnostic paths in Codex custom agent instructions", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [],
@@ -530,15 +572,12 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
permissions: "none",
})
const agentSkill = bundle.generatedSkills.find((s) => s.name === "config-reader")
expect(agentSkill).toBeDefined()
const parsed = parseFrontmatter(agentSkill!.content)
// Tool-agnostic path in project root — no rewriting needed
expect(parsed.body).toContain("compound-engineering.local.md")
const agent = bundle.agents.find((s) => s.name === "config-reader")
expect(agent).toBeDefined()
expect(agent!.instructions).toContain("compound-engineering.local.md")
})
test("truncates generated skill descriptions to Codex limits and single line", () => {
test("truncates custom agent descriptions to Codex limits and single line", () => {
const longDescription = `Line one\nLine two ${"a".repeat(2000)}`
const plugin: ClaudePlugin = {
...fixturePlugin,
@@ -560,9 +599,7 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
permissions: "none",
})
const generated = bundle.generatedSkills[0]
const parsed = parseFrontmatter(generated.content)
const description = String(parsed.data.description ?? "")
const description = bundle.agents[0].description
expect(description.length).toBeLessThanOrEqual(1024)
expect(description).not.toContain("\n")
expect(description.endsWith("...")).toBe(true)

View File

@@ -4,6 +4,8 @@ import path from "path"
import os from "os"
import { mergeCodexConfig, renderCodexConfig, writeCodexBundle } from "../src/targets/codex"
import type { CodexBundle } from "../src/types/codex"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToCodex } from "../src/converters/claude-to-codex"
async function exists(filePath: string): Promise<boolean> {
try {
@@ -14,6 +16,15 @@ async function exists(filePath: string): Promise<boolean> {
}
}
async function entryExists(filePath: string): Promise<boolean> {
try {
await fs.lstat(filePath)
return true
} catch {
return false
}
}
describe("writeCodexBundle", () => {
test("writes prompts, skills, and config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-"))
@@ -26,6 +37,13 @@ describe("writeCodexBundle", () => {
},
],
generatedSkills: [{ name: "agent-skill", content: "Skill content" }],
agents: [
{
name: "research-ce-repo-research-analyst",
description: "Repo research",
instructions: "Research the repository.",
},
],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
remote: {
@@ -40,6 +58,11 @@ describe("writeCodexBundle", () => {
expect(await exists(path.join(tempRoot, ".codex", "prompts", "command-one.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".codex", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".codex", "skills", "agent-skill", "SKILL.md"))).toBe(true)
const agentPath = path.join(tempRoot, ".codex", "agents", "research-ce-repo-research-analyst.toml")
expect(await exists(agentPath)).toBe(true)
const agentToml = await fs.readFile(agentPath, "utf8")
expect(agentToml).toContain('name = "research-ce-repo-research-analyst"')
expect(agentToml).toContain('developer_instructions = "Research the repository."')
const configPath = path.join(tempRoot, ".codex", "config.toml")
expect(await exists(configPath)).toBe(true)
@@ -56,6 +79,38 @@ describe("writeCodexBundle", () => {
expect(config).toContain("http_headers")
})
test("throws when two agents sanitize to the same Codex filename", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agent-collision-"))
const bundle: CodexBundle = {
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [
{
name: "research:ce-learnings-researcher",
description: "First",
instructions: "First agent body.",
},
{
name: "research-ce-learnings-researcher",
description: "Second",
instructions: "Second agent body.",
},
],
}
await expect(writeCodexBundle(tempRoot, bundle)).rejects.toThrow(
/Codex agent filename collision/,
)
// Verify neither agent was silently dropped: the first agent should not have
// been written before the collision was detected (guard runs before writes).
const agentsRoot = path.join(tempRoot, ".codex", "agents")
expect(
await exists(path.join(agentsRoot, "research-ce-learnings-researcher.toml")),
).toBe(false)
})
test("writes directly into a .codex output root", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-home-"))
const codexRoot = path.join(tempRoot, ".codex")
@@ -123,6 +178,182 @@ describe("writeCodexBundle", () => {
expect(await exists(path.join(promptsDir, "ce-plan.md"))).toBe(true)
})
test("writes plugin skills under a namespaced Codex skills root without .agents symlinks", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-managed-plugin-"))
const codexRoot = path.join(tempRoot, ".codex")
const bundle: CodexBundle = {
pluginName: "compound-engineering",
prompts: [{ name: "old-prompt", content: "Prompt content" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "old-command", content: "Old command" }],
agents: [{ name: "old-agent", description: "Old agent", instructions: "Old agent body" }],
}
await writeCodexBundle(codexRoot, bundle)
const managedSkillsRoot = path.join(codexRoot, "skills", "compound-engineering")
const managedAgentsRoot = path.join(codexRoot, "agents", "compound-engineering")
expect(await exists(path.join(managedSkillsRoot, "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(managedSkillsRoot, "old-command", "SKILL.md"))).toBe(true)
expect(await exists(path.join(managedAgentsRoot, "old-agent.toml"))).toBe(true)
expect(await exists(path.join(tempRoot, ".agents", "skills", "skill-one"))).toBe(false)
expect(await exists(path.join(tempRoot, ".agents", "skills", "old-agent"))).toBe(false)
expect(await exists(path.join(codexRoot, "compound-engineering", "install-manifest.json"))).toBe(true)
await writeCodexBundle(codexRoot, {
pluginName: "compound-engineering",
prompts: [{ name: "new-prompt", content: "Prompt content" }],
skillDirs: [],
generatedSkills: [{ name: "new-command", content: "New command" }],
agents: [{ name: "new-agent", description: "New agent", instructions: "New agent body" }],
})
expect(await exists(path.join(managedSkillsRoot, "skill-one", "SKILL.md"))).toBe(false)
expect(await exists(path.join(managedSkillsRoot, "old-command", "SKILL.md"))).toBe(false)
expect(await exists(path.join(managedSkillsRoot, "new-command", "SKILL.md"))).toBe(true)
expect(await exists(path.join(managedAgentsRoot, "old-agent.toml"))).toBe(false)
expect(await exists(path.join(managedAgentsRoot, "new-agent.toml"))).toBe(true)
expect(await exists(path.join(tempRoot, ".agents", "skills", "new-agent"))).toBe(false)
expect(await exists(path.join(codexRoot, "prompts", "old-prompt.md"))).toBe(false)
expect(await exists(path.join(codexRoot, "prompts", "new-prompt.md"))).toBe(true)
})
test("removes legacy .agents symlinks that point to managed Codex skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-flat-symlink-"))
const codexRoot = path.join(tempRoot, ".codex")
const previousManagedSkillsRoot = path.join(codexRoot, "compound-engineering", "skills")
const agentsSkillsDir = path.join(tempRoot, ".agents", "skills")
await fs.mkdir(path.join(previousManagedSkillsRoot, "old-agent"), { recursive: true })
await fs.mkdir(path.join(previousManagedSkillsRoot, "reproduce-bug"), { recursive: true })
await fs.writeFile(
path.join(codexRoot, "compound-engineering", "install-manifest.json"),
JSON.stringify({ version: 1, pluginName: "compound-engineering", skills: ["old-agent"], prompts: [] }),
)
await fs.mkdir(agentsSkillsDir, { recursive: true })
await fs.symlink(previousManagedSkillsRoot, path.join(agentsSkillsDir, "compound-engineering"))
await fs.symlink(
path.join(previousManagedSkillsRoot, "old-agent"),
path.join(agentsSkillsDir, "old-agent"),
)
await fs.symlink(
path.join(previousManagedSkillsRoot, "reproduce-bug"),
path.join(agentsSkillsDir, "reproduce-bug"),
)
const unrelatedRoot = path.join(tempRoot, "other-skills", "skill-one")
await fs.mkdir(unrelatedRoot, { recursive: true })
await fs.symlink(unrelatedRoot, path.join(agentsSkillsDir, "skill-one"))
await writeCodexBundle(codexRoot, {
pluginName: "compound-engineering",
prompts: [],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [],
})
expect(await entryExists(path.join(agentsSkillsDir, "compound-engineering"))).toBe(false)
expect(await entryExists(path.join(agentsSkillsDir, "old-agent"))).toBe(false)
expect(await entryExists(path.join(agentsSkillsDir, "reproduce-bug"))).toBe(false)
expect(await fs.realpath(path.join(agentsSkillsDir, "skill-one"))).toBe(await fs.realpath(unrelatedRoot))
expect(await exists(previousManagedSkillsRoot)).toBe(false)
})
test("moves legacy flat Codex CE artifacts to a namespaced backup", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-legacy-skill-"))
const codexRoot = path.join(tempRoot, ".codex")
await fs.mkdir(path.join(codexRoot, "skills", "ce-plan"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "ce-plan", "SKILL.md"), "legacy current workflow skill")
await fs.mkdir(path.join(codexRoot, "skills", "ce:plan"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "ce:plan", "SKILL.md"), "legacy raw colon workflow skill")
await fs.mkdir(path.join(codexRoot, "skills", "ce:plan-beta"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "ce:plan-beta", "SKILL.md"), "legacy raw colon beta workflow skill")
await fs.mkdir(path.join(codexRoot, "skills", "repo-research-analyst"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "repo-research-analyst", "SKILL.md"), "legacy current agent skill")
await fs.mkdir(path.join(codexRoot, "skills", "reproduce-bug"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "reproduce-bug", "SKILL.md"), "legacy removed skill")
await fs.mkdir(path.join(codexRoot, "skills", "bug-reproduction-validator"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "skills", "bug-reproduction-validator", "SKILL.md"), "legacy removed agent skill")
await fs.mkdir(path.join(codexRoot, "prompts"), { recursive: true })
await fs.writeFile(path.join(codexRoot, "prompts", "reproduce-bug.md"), "legacy removed prompt")
await fs.writeFile(path.join(codexRoot, "prompts", "report-bug.md"), "legacy deleted command prompt")
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
await writeCodexBundle(codexRoot, bundle)
expect(await exists(path.join(codexRoot, "skills", "ce-plan"))).toBe(false)
expect(await exists(path.join(codexRoot, "skills", "ce:plan"))).toBe(false)
expect(await exists(path.join(codexRoot, "skills", "ce:plan-beta"))).toBe(false)
expect(await exists(path.join(codexRoot, "skills", "repo-research-analyst"))).toBe(false)
expect(await exists(path.join(codexRoot, "skills", "reproduce-bug"))).toBe(false)
expect(await exists(path.join(codexRoot, "skills", "bug-reproduction-validator"))).toBe(false)
expect(await exists(path.join(codexRoot, "prompts", "reproduce-bug.md"))).toBe(false)
expect(await exists(path.join(codexRoot, "prompts", "report-bug.md"))).toBe(false)
expect(await exists(path.join(codexRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
test("preserves unrelated user skills at flat ~/.codex/skills/<name>/ that share a name with a current CE skill", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-user-skill-collide-"))
const codexRoot = path.join(tempRoot, ".codex")
// ce-demo-reel is the name of a current CE skill, but it has never been
// shipped as a flat ~/.codex/skills/ce-demo-reel/ install (the historical
// flat name was "demo-reel"). A user could plausibly have authored their
// own ce-demo-reel skill at the flat path. The first install of CE must
// not move it to backup.
const userSkillDir = path.join(codexRoot, "skills", "ce-demo-reel")
await fs.mkdir(userSkillDir, { recursive: true })
const userSkillContent = "# user-authored skill, not from CE"
await fs.writeFile(path.join(userSkillDir, "SKILL.md"), userSkillContent)
// Same for ce-debug — current CE skill name, never in the historical
// flat-path allow-list, so a same-named user skill must be preserved.
const userDebugDir = path.join(codexRoot, "skills", "ce-debug")
await fs.mkdir(userDebugDir, { recursive: true })
await fs.writeFile(path.join(userDebugDir, "SKILL.md"), "# user debug skill")
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
await writeCodexBundle(codexRoot, bundle)
// The user skills survive the install — same path, same content.
expect(await exists(path.join(userSkillDir, "SKILL.md"))).toBe(true)
expect(await fs.readFile(path.join(userSkillDir, "SKILL.md"), "utf8")).toBe(userSkillContent)
expect(await exists(path.join(userDebugDir, "SKILL.md"))).toBe(true)
// And they are not silently relocated to the legacy backup.
const backupRoot = path.join(codexRoot, "compound-engineering", "legacy-backup")
if (await exists(backupRoot)) {
const timestamps = await fs.readdir(backupRoot)
for (const ts of timestamps) {
const skillsBackup = path.join(backupRoot, ts, "skills")
if (!(await exists(skillsBackup))) continue
const backed = await fs.readdir(skillsBackup)
expect(backed).not.toContain("ce-demo-reel")
expect(backed).not.toContain("ce-debug")
}
}
})
test("preserves existing user config when writing MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-backup-"))
const codexRoot = path.join(tempRoot, ".codex")

View File

@@ -15,6 +15,24 @@ const compoundEngineeringRoot = path.join(
)
describe("convertClaudeToOpenCode", () => {
test("current compound-engineering output is skills and subagents, not commands", async () => {
const plugin = await loadClaudePlugin(compoundEngineeringRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
expect(bundle.agents.length).toBeGreaterThan(0)
expect(bundle.skillDirs.length).toBeGreaterThan(0)
expect(bundle.commandFiles).toHaveLength(0)
expect(bundle.plugins).toHaveLength(0)
expect(bundle.config.tools).toBeUndefined()
const parsedAgents = bundle.agents.map((agent) => parseFrontmatter(agent.content))
expect(parsedAgents.every((agent) => agent.data.mode === "subagent")).toBe(true)
})
test("from-command mode: map allowedTools to global permission block", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
@@ -24,6 +42,7 @@ describe("convertClaudeToOpenCode", () => {
})
expect(bundle.config.command).toBeUndefined()
expect(bundle.config.tools).toBeUndefined()
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
@@ -275,6 +294,7 @@ describe("convertClaudeToOpenCode", () => {
inferTemperature: false,
permissions: "broad",
})
expect(broadBundle.config.tools).toBeUndefined()
expect(broadBundle.config.permission).toEqual({
read: "allow",
write: "allow",

View File

@@ -1,395 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeCopilotBundle } from "../src/targets/copilot"
import type { CopilotBundle } from "../src/types/copilot"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeCopilotBundle", () => {
test("writes agents, generated skills, copied skills, and MCP config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-test-"))
const bundle: CopilotBundle = {
agents: [
{
name: "security-reviewer",
content: "---\ndescription: Security\nuser-invocable: true\n---\n\nReview code.",
},
],
generatedSkills: [
{
name: "plan",
content: "---\nname: plan\ndescription: Planning\n---\n\nPlan the work.",
},
],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
mcpConfig: {
playwright: {
type: "local",
command: "npx",
args: ["-y", "@anthropic/mcp-playwright"],
tools: ["*"],
},
},
}
await writeCopilotBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".github", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".github", "copilot-mcp-config.json"))).toBe(true)
const agentContent = await fs.readFile(
path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"),
"utf8",
)
expect(agentContent).toContain("Review code.")
const skillContent = await fs.readFile(
path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"),
"utf8",
)
expect(skillContent).toContain("Plan the work.")
const mcpContent = JSON.parse(
await fs.readFile(path.join(tempRoot, ".github", "copilot-mcp-config.json"), "utf8"),
)
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
})
test("agents use .agent.md file extension", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-ext-"))
const bundle: CopilotBundle = {
agents: [{ name: "test-agent", content: "Agent content" }],
generatedSkills: [],
skillDirs: [],
}
await writeCopilotBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.agent.md"))).toBe(true)
// Should NOT create a plain .md file
expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.md"))).toBe(false)
})
test("writes directly into .github output root without double-nesting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-home-"))
const githubRoot = path.join(tempRoot, ".github")
const bundle: CopilotBundle = {
agents: [{ name: "reviewer", content: "Reviewer agent content" }],
generatedSkills: [{ name: "plan", content: "Plan content" }],
skillDirs: [],
}
await writeCopilotBundle(githubRoot, bundle)
expect(await exists(path.join(githubRoot, "agents", "reviewer.agent.md"))).toBe(true)
expect(await exists(path.join(githubRoot, "skills", "plan", "SKILL.md"))).toBe(true)
// Should NOT double-nest under .github/.github
expect(await exists(path.join(githubRoot, ".github"))).toBe(false)
})
test("handles empty bundles gracefully", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-empty-"))
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
}
await writeCopilotBundle(tempRoot, bundle)
expect(await exists(tempRoot)).toBe(true)
})
test("writes multiple agents as separate .agent.md files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-multi-"))
const githubRoot = path.join(tempRoot, ".github")
const bundle: CopilotBundle = {
agents: [
{ name: "security-sentinel", content: "Security rules" },
{ name: "performance-oracle", content: "Performance rules" },
{ name: "code-simplicity-reviewer", content: "Simplicity rules" },
],
generatedSkills: [],
skillDirs: [],
}
await writeCopilotBundle(githubRoot, bundle)
expect(await exists(path.join(githubRoot, "agents", "security-sentinel.agent.md"))).toBe(true)
expect(await exists(path.join(githubRoot, "agents", "performance-oracle.agent.md"))).toBe(true)
expect(await exists(path.join(githubRoot, "agents", "code-simplicity-reviewer.agent.md"))).toBe(true)
})
test("backs up existing copilot-mcp-config.json before overwriting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-backup-"))
const githubRoot = path.join(tempRoot, ".github")
await fs.mkdir(githubRoot, { recursive: true })
// Write an existing config
const mcpPath = path.join(githubRoot, "copilot-mcp-config.json")
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { type: "local", command: "old-cmd", tools: ["*"] } } }))
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: {
newServer: { type: "local", command: "new-cmd", tools: ["*"] },
},
}
await writeCopilotBundle(githubRoot, bundle)
// New config should have the new content
const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
// A backup file should exist
const files = await fs.readdir(githubRoot)
const backupFiles = files.filter((f) => f.startsWith("copilot-mcp-config.json.bak."))
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
})
test("transforms Task calls in copied SKILL.md files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-skill-transform-"))
const sourceSkillDir = path.join(tempRoot, "source-skill")
await fs.mkdir(sourceSkillDir, { recursive: true })
await fs.writeFile(
path.join(sourceSkillDir, "SKILL.md"),
`---
name: ce-plan
description: Planning workflow
---
Run these research agents:
- Task compound-engineering:research:repo-research-analyst(feature_description)
- Task compound-engineering:research:learnings-researcher(feature_description)
- Task compound-engineering:review:code-simplicity-reviewer()
`,
)
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [{ name: "ce-plan", sourceDir: sourceSkillDir }],
}
await writeCopilotBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".github", "skills", "ce-plan", "SKILL.md"),
"utf8",
)
expect(installedSkill).toContain("Use the repo-research-analyst skill to: feature_description")
expect(installedSkill).toContain("Use the learnings-researcher skill to: feature_description")
expect(installedSkill).toContain("Use the code-simplicity-reviewer skill")
expect(installedSkill).not.toContain("Task compound-engineering:")
})
test("removes stale plugin MCP servers on re-install", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-converge-"))
const githubRoot = path.join(tempRoot, ".github")
const bundle1: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: { old: { type: "local", command: "old-server", tools: ["*"] } },
}
const bundle2: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: { fresh: { type: "local", command: "new-server", tools: ["*"] } },
}
await writeCopilotBundle(tempRoot, bundle1)
await writeCopilotBundle(tempRoot, bundle2)
const result = JSON.parse(await fs.readFile(path.join(githubRoot, "copilot-mcp-config.json"), "utf8"))
expect(result.mcpServers.fresh).toBeDefined()
expect(result.mcpServers.old).toBeUndefined()
})
test("cleans up all plugin MCP servers when bundle has none", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-zero-"))
const githubRoot = path.join(tempRoot, ".github")
const bundle1: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: { old: { type: "local", command: "old-server", tools: ["*"] } },
}
const bundle2: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
// No mcpConfig
}
await writeCopilotBundle(tempRoot, bundle1)
await writeCopilotBundle(tempRoot, bundle2)
const result = JSON.parse(await fs.readFile(path.join(githubRoot, "copilot-mcp-config.json"), "utf8"))
expect(result.mcpServers.old).toBeUndefined()
expect(result._compound_managed_mcp).toEqual([])
})
test("does not prune untracked user config when plugin has zero MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-untracked-"))
const githubRoot = path.join(tempRoot, ".github")
await fs.mkdir(githubRoot, { recursive: true })
// Pre-existing user config with no tracking key (never had the plugin before)
await fs.writeFile(
path.join(githubRoot, "copilot-mcp-config.json"),
JSON.stringify({
mcpServers: { "user-tool": { type: "local", command: "my-tool", tools: ["*"] } },
}),
)
// Plugin installs with zero MCP servers
await writeCopilotBundle(githubRoot, {
agents: [],
generatedSkills: [],
skillDirs: [],
})
const result = JSON.parse(await fs.readFile(path.join(githubRoot, "copilot-mcp-config.json"), "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result._compound_managed_mcp).toEqual([])
})
test("preserves user servers across zero-MCP-then-MCP round trip", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-roundtrip-"))
const githubRoot = path.join(tempRoot, ".github")
const mcpPath = path.join(githubRoot, "copilot-mcp-config.json")
// 1. Install with plugin MCP
await writeCopilotBundle(tempRoot, {
agents: [], generatedSkills: [], skillDirs: [],
mcpConfig: { plugin: { type: "local", command: "plugin-server", tools: ["*"] } },
})
// 2. User adds their own server
const afterInstall = JSON.parse(await fs.readFile(mcpPath, "utf8"))
afterInstall.mcpServers["user-tool"] = { type: "local", command: "my-tool", tools: ["*"] }
await fs.writeFile(mcpPath, JSON.stringify(afterInstall))
// 3. Install with zero plugin MCP
await writeCopilotBundle(tempRoot, {
agents: [], generatedSkills: [], skillDirs: [],
})
// 4. Install with plugin MCP again
await writeCopilotBundle(tempRoot, {
agents: [], generatedSkills: [], skillDirs: [],
mcpConfig: { new_plugin: { type: "local", command: "new-plugin", tools: ["*"] } },
})
const result = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result.mcpServers.new_plugin).toBeDefined()
expect(result.mcpServers.plugin).toBeUndefined()
})
test("preserves user-added MCP servers across re-installs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-user-mcp-"))
const githubRoot = path.join(tempRoot, ".github")
await fs.mkdir(githubRoot, { recursive: true })
// User has their own MCP server alongside plugin-managed ones (tracking key present)
await fs.writeFile(
path.join(githubRoot, "copilot-mcp-config.json"),
JSON.stringify({
mcpServers: { "user-tool": { type: "local", command: "my-tool", tools: ["*"] } },
_compound_managed_mcp: [],
}),
)
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: { plugin: { type: "local", command: "plugin-server", tools: ["*"] } },
}
await writeCopilotBundle(githubRoot, bundle)
const result = JSON.parse(await fs.readFile(path.join(githubRoot, "copilot-mcp-config.json"), "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result.mcpServers.plugin).toBeDefined()
})
test("prunes stale servers from legacy config without tracking key", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-legacy-"))
const githubRoot = path.join(tempRoot, ".github")
await fs.mkdir(githubRoot, { recursive: true })
// Simulate old writer output: has mcpServers but no _compound_managed_mcp
await fs.writeFile(
path.join(githubRoot, "copilot-mcp-config.json"),
JSON.stringify({
mcpServers: {
old: { type: "local", command: "old-server", tools: ["*"] },
renamed: { type: "local", command: "renamed-server", tools: ["*"] },
},
}),
)
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [],
skillDirs: [],
mcpConfig: { fresh: { type: "local", command: "new-server", tools: ["*"] } },
}
await writeCopilotBundle(githubRoot, bundle)
const result = JSON.parse(await fs.readFile(path.join(githubRoot, "copilot-mcp-config.json"), "utf8"))
expect(result.mcpServers.fresh).toBeDefined()
expect(result.mcpServers.old).toBeUndefined()
expect(result.mcpServers.renamed).toBeUndefined()
expect(result._compound_managed_mcp).toEqual(["fresh"])
})
test("creates skill directories with SKILL.md", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-genskill-"))
const bundle: CopilotBundle = {
agents: [],
generatedSkills: [
{
name: "deploy",
content: "---\nname: deploy\ndescription: Deploy skill\n---\n\nDeploy steps.",
},
],
skillDirs: [],
}
await writeCopilotBundle(tempRoot, bundle)
const skillPath = path.join(tempRoot, ".github", "skills", "deploy", "SKILL.md")
expect(await exists(skillPath)).toBe(true)
const content = await fs.readFile(skillPath, "utf8")
expect(content).toContain("Deploy steps.")
})
})

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
@@ -11,7 +11,6 @@ describe("detectInstalledTools", () => {
// Create directories for some tools
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
@@ -21,10 +20,6 @@ describe("detectInstalledTools", () => {
expect(codex?.detected).toBe(true)
expect(codex?.reason).toContain(".codex")
const windsurf = results.find((t) => t.name === "windsurf")
expect(windsurf?.detected).toBe(true)
expect(windsurf?.reason).toContain(".codeium/windsurf")
const gemini = results.find((t) => t.name === "gemini")
expect(gemini?.detected).toBe(true)
expect(gemini?.reason).toContain(".gemini")
@@ -50,7 +45,7 @@ describe("detectInstalledTools", () => {
const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.length).toBe(10)
expect(results.length).toBe(8)
for (const tool of results) {
expect(tool.detected).toBe(false)
expect(tool.reason).toBe("not found")
@@ -64,14 +59,49 @@ describe("detectInstalledTools", () => {
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "opencode")?.detected).toBe(true)
expect(results.find((t) => t.name === "droid")?.detected).toBe(true)
expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
expect(results.find((t) => t.name === "openclaw")?.detected).toBe(true)
})
describe("opencode OPENCODE_CONFIG_DIR", () => {
const originalEnv = process.env.OPENCODE_CONFIG_DIR
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalEnv
}
})
test("detects opencode at OPENCODE_CONFIG_DIR when set, even if ~/.config/opencode is absent", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-cwd-"))
const customRoot = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-root-"))
// Ensure no ~/.config/opencode exists under the sandbox home.
process.env.OPENCODE_CONFIG_DIR = customRoot
const results = await detectInstalledTools(tempHome, tempCwd)
const opencode = results.find((t) => t.name === "opencode")
expect(opencode?.detected).toBe(true)
expect(opencode?.reason).toContain(customRoot)
})
test("opencode is not detected when OPENCODE_CONFIG_DIR points at a missing directory", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-missing-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-missing-cwd-"))
const missingRoot = path.join(os.tmpdir(), `detect-opencode-missing-${Date.now()}-${Math.random()}`)
process.env.OPENCODE_CONFIG_DIR = missingRoot
const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "opencode")?.detected).toBe(false)
})
})
test("detects copilot from project-specific skills without generic .github false positives", async () => {

View File

@@ -1,138 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeDroidBundle } from "../src/targets/droid"
import type { DroidBundle } from "../src/types/droid"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeDroidBundle", () => {
test("writes commands, droids, and skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-test-"))
const bundle: DroidBundle = {
commands: [{ name: "plan", content: "Plan command content" }],
droids: [{ name: "security-reviewer", content: "Droid content" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
}
await writeDroidBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".factory", "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".factory", "droids", "security-reviewer.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".factory", "skills", "skill-one", "SKILL.md"))).toBe(true)
const commandContent = await fs.readFile(
path.join(tempRoot, ".factory", "commands", "plan.md"),
"utf8",
)
expect(commandContent).toContain("Plan command content")
const droidContent = await fs.readFile(
path.join(tempRoot, ".factory", "droids", "security-reviewer.md"),
"utf8",
)
expect(droidContent).toContain("Droid content")
})
test("transforms Task calls in copied SKILL.md files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-skill-transform-"))
const sourceSkillDir = path.join(tempRoot, "source-skill")
await fs.mkdir(sourceSkillDir, { recursive: true })
await fs.writeFile(
path.join(sourceSkillDir, "SKILL.md"),
`---
name: ce-plan
description: Planning workflow
---
Run these research agents:
- Task compound-engineering:research:repo-research-analyst(feature_description)
- Task compound-engineering:research:learnings-researcher(feature_description)
- Task compound-engineering:review:code-simplicity-reviewer()
`,
)
const bundle: DroidBundle = {
commands: [],
droids: [],
skillDirs: [{ name: "ce-plan", sourceDir: sourceSkillDir }],
}
await writeDroidBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".factory", "skills", "ce-plan", "SKILL.md"),
"utf8",
)
expect(installedSkill).toContain("Task repo-research-analyst: feature_description")
expect(installedSkill).toContain("Task learnings-researcher: feature_description")
expect(installedSkill).toContain("Task code-simplicity-reviewer")
expect(installedSkill).not.toContain("Task compound-engineering:")
})
test("writes directly into a .factory output root", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-home-"))
const factoryRoot = path.join(tempRoot, ".factory")
const bundle: DroidBundle = {
commands: [{ name: "plan", content: "Plan content" }],
droids: [{ name: "reviewer", content: "Reviewer content" }],
skillDirs: [],
}
await writeDroidBundle(factoryRoot, bundle)
expect(await exists(path.join(factoryRoot, "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(factoryRoot, "droids", "reviewer.md"))).toBe(true)
// Should not double-nest under .factory/.factory
expect(await exists(path.join(factoryRoot, ".factory"))).toBe(false)
})
test("handles empty bundles gracefully", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-empty-"))
const bundle: DroidBundle = {
commands: [],
droids: [],
skillDirs: [],
}
await writeDroidBundle(tempRoot, bundle)
// Root should exist but no subdirectories created
expect(await exists(tempRoot)).toBe(true)
})
test("writes multiple commands as separate files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-multi-"))
const factoryRoot = path.join(tempRoot, ".factory")
const bundle: DroidBundle = {
commands: [
{ name: "plan", content: "Plan content" },
{ name: "work", content: "Work content" },
{ name: "brainstorm", content: "Brainstorm content" },
],
droids: [],
skillDirs: [],
}
await writeDroidBundle(factoryRoot, bundle)
expect(await exists(path.join(factoryRoot, "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(factoryRoot, "commands", "work.md"))).toBe(true)
expect(await exists(path.join(factoryRoot, "commands", "brainstorm.md"))).toBe(true)
})
})

View File

@@ -42,18 +42,19 @@ const fixturePlugin: ClaudePlugin = {
}
describe("convertClaudeToGemini", () => {
test("converts agents to skills with SKILL.md frontmatter", () => {
test("converts agents to Gemini subagent Markdown", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
const parsed = parseFrontmatter(skill!.content)
const agent = bundle.agents?.find((a) => a.name === "security-reviewer")
expect(agent).toBeDefined()
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.name).toBe("security-reviewer")
expect(parsed.data.description).toBe("Security-focused agent")
expect(parsed.data.kind).toBe("local")
expect(parsed.body).toContain("Focus on vulnerabilities.")
})
@@ -64,9 +65,9 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
const parsed = parseFrontmatter(skill!.content)
const agent = bundle.agents?.find((a) => a.name === "security-reviewer")
expect(agent).toBeDefined()
const parsed = parseFrontmatter(agent!.content)
expect(parsed.body).toContain("## Capabilities")
expect(parsed.body).toContain("- Threat modeling")
expect(parsed.body).toContain("- OWASP")
@@ -92,8 +93,8 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
expect(parsed.data.description).toBe("Use this skill for my-agent tasks")
const parsed = parseFrontmatter(bundle.agents![0].content)
expect(parsed.data.description).toBe("Use this agent for my-agent tasks")
})
test("agent model field silently dropped", () => {
@@ -103,8 +104,8 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
const parsed = parseFrontmatter(skill!.content)
const agent = bundle.agents?.find((a) => a.name === "security-reviewer")
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBeUndefined()
})
@@ -129,7 +130,7 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
const parsed = parseFrontmatter(bundle.agents![0].content)
expect(parsed.body).toContain("Instructions converted from the Empty Agent agent.")
})
@@ -232,7 +233,7 @@ describe("convertClaudeToGemini", () => {
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
})
test("plugin with zero agents produces empty generatedSkills", () => {
test("plugin with zero agents produces empty agents", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
@@ -246,7 +247,7 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
expect(bundle.generatedSkills).toHaveLength(0)
expect(bundle.agents).toHaveLength(0)
})
test("plugin with only skills works correctly", () => {
@@ -262,12 +263,12 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
expect(bundle.generatedSkills).toHaveLength(0)
expect(bundle.agents).toHaveLength(0)
expect(bundle.skillDirs).toHaveLength(1)
expect(bundle.commands).toHaveLength(0)
})
test("agent name colliding with skill name gets deduplicated", () => {
test("agent name can match a skill name because Gemini agents and skills are separate roots", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
skills: [{ name: "security-reviewer", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }],
@@ -281,8 +282,7 @@ describe("convertClaudeToGemini", () => {
permissions: "none",
})
// Agent should be deduplicated since skill already has "security-reviewer"
expect(bundle.generatedSkills[0].name).toBe("security-reviewer-2")
expect(bundle.agents![0].name).toBe("security-reviewer")
expect(bundle.skillDirs[0].name).toBe("security-reviewer")
})
@@ -323,7 +323,7 @@ describe("transformContentForGemini", () => {
expect(result).not.toContain("~/.claude/")
})
test("transforms Task agent(args) to natural language skill reference", () => {
test("transforms Task agent(args) to Gemini subagent reference", () => {
const input = `Run these:
- Task repo-research-analyst(feature_description)
@@ -332,9 +332,9 @@ describe("transformContentForGemini", () => {
Task best-practices-researcher(topic)`
const result = transformContentForGemini(input)
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
expect(result).toContain("Use the learnings-researcher skill to: feature_description")
expect(result).toContain("Use the best-practices-researcher skill to: topic")
expect(result).toContain("Use the @repo-research-analyst subagent to: feature_description")
expect(result).toContain("Use the @learnings-researcher subagent to: feature_description")
expect(result).toContain("Use the @best-practices-researcher subagent to: topic")
expect(result).not.toContain("Task repo-research-analyst")
})
@@ -345,8 +345,8 @@ Task best-practices-researcher(topic)`
- Task compound-engineering:review:security-reviewer(code_diff)`
const result = transformContentForGemini(input)
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
expect(result).toContain("Use the security-reviewer skill to: code_diff")
expect(result).toContain("Use the @repo-research-analyst subagent to: feature_description")
expect(result).toContain("Use the @security-reviewer subagent to: code_diff")
expect(result).not.toContain("compound-engineering:")
})
@@ -354,15 +354,14 @@ Task best-practices-researcher(topic)`
const input = `- Task compound-engineering:review:code-simplicity-reviewer()`
const result = transformContentForGemini(input)
expect(result).toContain("Use the code-simplicity-reviewer skill")
expect(result).toContain("Use the @code-simplicity-reviewer subagent")
expect(result).not.toContain("compound-engineering:")
expect(result).not.toContain("skill to:")
expect(result).not.toContain("subagent to:")
})
test("transforms @agent references to skill references", () => {
test("transforms @agent references to subagent references", () => {
const result = transformContentForGemini("Ask @security-sentinel for a review.")
expect(result).toContain("the security-sentinel skill")
expect(result).not.toContain("@security-sentinel")
expect(result).toContain("@security-sentinel subagent")
})
})

View File

@@ -4,6 +4,8 @@ import path from "path"
import os from "os"
import { writeGeminiBundle } from "../src/targets/gemini"
import type { GeminiBundle } from "../src/types/gemini"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToGemini } from "../src/converters/claude-to-gemini"
async function exists(filePath: string): Promise<boolean> {
try {
@@ -41,10 +43,12 @@ describe("writeGeminiBundle", () => {
expect(rewritten).toContain("Fresh generated skill.")
})
test("writes skills, commands, and settings.json", async () => {
test("writes agents, skills, commands, and settings.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"))
const bundle: GeminiBundle = {
generatedSkills: [
pluginName: "compound-engineering",
generatedSkills: [],
agents: [
{
name: "security-reviewer",
content: "---\nname: security-reviewer\ndescription: Security\n---\n\nReview code.",
@@ -69,16 +73,17 @@ describe("writeGeminiBundle", () => {
await writeGeminiBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "agents", "security-reviewer.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "commands", "plan.toml"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "settings.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "compound-engineering", "install-manifest.json"))).toBe(true)
const skillContent = await fs.readFile(
path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"),
const agentContent = await fs.readFile(
path.join(tempRoot, ".gemini", "agents", "security-reviewer.md"),
"utf8",
)
expect(skillContent).toContain("Review code.")
expect(agentContent).toContain("Review code.")
const commandContent = await fs.readFile(
path.join(tempRoot, ".gemini", "commands", "plan.toml"),
@@ -124,9 +129,9 @@ Run these research agents:
"utf8",
)
expect(installedSkill).toContain("Use the repo-research-analyst skill to: feature_description")
expect(installedSkill).toContain("Use the learnings-researcher skill to: feature_description")
expect(installedSkill).toContain("Use the code-simplicity-reviewer skill")
expect(installedSkill).toContain("Use the @repo-research-analyst subagent to: feature_description")
expect(installedSkill).toContain("Use the @learnings-researcher subagent to: feature_description")
expect(installedSkill).toContain("Use the @code-simplicity-reviewer subagent")
expect(installedSkill).not.toContain("Task compound-engineering:")
})
@@ -152,9 +157,8 @@ Run these research agents:
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-home-"))
const geminiRoot = path.join(tempRoot, ".gemini")
const bundle: GeminiBundle = {
generatedSkills: [
{ name: "reviewer", content: "Reviewer skill content" },
],
generatedSkills: [],
agents: [{ name: "reviewer", content: "Reviewer agent content" }],
skillDirs: [],
commands: [
{ name: "plan", content: "Plan content" },
@@ -163,7 +167,7 @@ Run these research agents:
await writeGeminiBundle(geminiRoot, bundle)
expect(await exists(path.join(geminiRoot, "skills", "reviewer", "SKILL.md"))).toBe(true)
expect(await exists(path.join(geminiRoot, "agents", "reviewer.md"))).toBe(true)
expect(await exists(path.join(geminiRoot, "commands", "plan.toml"))).toBe(true)
// Should NOT double-nest under .gemini/.gemini
expect(await exists(path.join(geminiRoot, ".gemini"))).toBe(false)
@@ -242,4 +246,119 @@ Run these research agents:
// Should add new MCP server
expect(content.mcpServers.newServer.command).toBe("new-cmd")
})
test("removes previously managed Gemini artifacts that disappear on reinstall", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-managed-cleanup-"))
await writeGeminiBundle(tempRoot, {
pluginName: "compound-engineering",
generatedSkills: [],
agents: [{ name: "old-agent", content: "---\nname: old-agent\n---\n\nBody" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
commands: [{ name: "old/cmd", content: 'description = "Old"\nprompt = """\nold\n"""' }],
})
await writeGeminiBundle(tempRoot, {
pluginName: "compound-engineering",
generatedSkills: [],
agents: [{ name: "new-agent", content: "---\nname: new-agent\n---\n\nBody" }],
skillDirs: [],
commands: [{ name: "new/cmd", content: 'description = "New"\nprompt = """\nnew\n"""' }],
})
expect(await exists(path.join(tempRoot, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(false)
expect(await exists(path.join(tempRoot, ".gemini", "agents", "old-agent.md"))).toBe(false)
expect(await exists(path.join(tempRoot, ".gemini", "agents", "new-agent.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "commands", "old", "cmd.toml"))).toBe(false)
expect(await exists(path.join(tempRoot, ".gemini", "commands", "new", "cmd.toml"))).toBe(true)
})
test("namespaces managed install manifests per plugin so installs do not collide", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-multi-plugin-"))
// Install plugin A first, with a skill and an agent
await writeGeminiBundle(tempRoot, {
pluginName: "compound-engineering",
generatedSkills: [],
agents: [{ name: "ce-agent", content: "---\nname: ce-agent\n---\n\nBody" }],
skillDirs: [
{
name: "ce-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
commands: [],
})
// Install plugin B into the same Gemini root
await writeGeminiBundle(tempRoot, {
pluginName: "coding-tutor",
generatedSkills: [],
agents: [{ name: "tutor-agent", content: "---\nname: tutor-agent\n---\n\nBody" }],
skillDirs: [
{
name: "tutor-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
commands: [],
})
// Both plugins must keep their own namespaced manifest
expect(await exists(path.join(tempRoot, ".gemini", "compound-engineering", "install-manifest.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "coding-tutor", "install-manifest.json"))).toBe(true)
// Reinstall plugin A with no agents/skills — it must clean up only its own
// managed artifacts, leaving plugin B's intact (the bug the namespacing fix
// addresses: a shared manifest path would have lost B's manifest after A
// was installed, and a later A reinstall would skip B's stale-file cleanup).
await writeGeminiBundle(tempRoot, {
pluginName: "compound-engineering",
generatedSkills: [],
agents: [],
skillDirs: [],
commands: [],
})
expect(await exists(path.join(tempRoot, ".gemini", "agents", "ce-agent.md"))).toBe(false)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "ce-skill"))).toBe(false)
expect(await exists(path.join(tempRoot, ".gemini", "agents", "tutor-agent.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "tutor-skill"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "coding-tutor", "install-manifest.json"))).toBe(true)
})
test("moves legacy Gemini CE artifacts to a namespaced backup", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-legacy-artifacts-"))
const geminiRoot = path.join(tempRoot, ".gemini")
await fs.mkdir(path.join(geminiRoot, "skills", "reproduce-bug"), { recursive: true })
await fs.writeFile(path.join(geminiRoot, "skills", "reproduce-bug", "SKILL.md"), "legacy removed skill")
await fs.mkdir(path.join(geminiRoot, "skills", "bug-reproduction-validator"), { recursive: true })
await fs.writeFile(path.join(geminiRoot, "skills", "bug-reproduction-validator", "SKILL.md"), "legacy removed agent skill")
await fs.mkdir(path.join(geminiRoot, "agents"), { recursive: true })
await fs.writeFile(path.join(geminiRoot, "agents", "bug-reproduction-validator.md"), "legacy removed agent")
await fs.mkdir(path.join(geminiRoot, "commands"), { recursive: true })
await fs.writeFile(path.join(geminiRoot, "commands", "reproduce-bug.toml"), "legacy removed command")
await fs.writeFile(path.join(geminiRoot, "commands", "report-bug.toml"), "legacy deleted command")
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
await writeGeminiBundle(geminiRoot, bundle)
expect(await exists(path.join(geminiRoot, "skills", "reproduce-bug"))).toBe(false)
expect(await exists(path.join(geminiRoot, "skills", "bug-reproduction-validator"))).toBe(false)
expect(await exists(path.join(geminiRoot, "agents", "bug-reproduction-validator.md"))).toBe(false)
expect(await exists(path.join(geminiRoot, "commands", "reproduce-bug.toml"))).toBe(false)
expect(await exists(path.join(geminiRoot, "commands", "report-bug.toml"))).toBe(false)
expect(await exists(path.join(geminiRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
})

View File

@@ -64,6 +64,34 @@ describe("writeKiroBundle", () => {
expect(await exists(path.join(kiroRoot, "agents", "prompts", "session-historian.md"))).toBe(false)
})
test("moves historical CE Kiro artifacts to backup during install", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-legacy-artifacts-"))
const kiroRoot = path.join(tempRoot, ".kiro")
const sourceSkillDir = path.join(tempRoot, "source-skill")
await fs.mkdir(sourceSkillDir, { recursive: true })
await fs.writeFile(
path.join(sourceSkillDir, "SKILL.md"),
"---\nname: ce-plan\ndescription: Plan\n---\n\nPlan.",
)
await fs.mkdir(path.join(kiroRoot, "skills", "reproduce-bug"), { recursive: true })
await fs.writeFile(path.join(kiroRoot, "skills", "reproduce-bug", "SKILL.md"), "legacy skill")
await fs.mkdir(path.join(kiroRoot, "agents", "prompts"), { recursive: true })
await fs.writeFile(path.join(kiroRoot, "agents", "repo-research-analyst.json"), "{}")
await fs.writeFile(path.join(kiroRoot, "agents", "prompts", "repo-research-analyst.md"), "legacy prompt")
await writeKiroBundle(kiroRoot, {
...emptyBundle,
pluginName: "compound-engineering",
skillDirs: [{ name: "ce-plan", sourceDir: sourceSkillDir }],
})
expect(await exists(path.join(kiroRoot, "skills", "reproduce-bug"))).toBe(false)
expect(await exists(path.join(kiroRoot, "agents", "repo-research-analyst.json"))).toBe(false)
expect(await exists(path.join(kiroRoot, "agents", "prompts", "repo-research-analyst.md"))).toBe(false)
expect(await exists(path.join(kiroRoot, "skills", "ce-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(kiroRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
test("writes agents, skills, steering, and mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-test-"))
const bundle: KiroBundle = {

View File

@@ -135,6 +135,38 @@ describe("cleanupStaleSkillDirs", () => {
expect(await exists(path.join(root, "ce-document-review"))).toBe(false)
})
test("removes raw colon workflow skill directories", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "cleanup-colon-workflows-"))
await createDir(
path.join(root, "ce:plan"),
skillContent(
"ce:plan",
await pluginDescription("plugins/compound-engineering/skills/ce-plan/SKILL.md"),
),
)
await createDir(
path.join(root, "workflows:review"),
skillContent(
"workflows:review",
await pluginDescription("plugins/compound-engineering/skills/ce-code-review/SKILL.md"),
),
)
await createDir(
path.join(root, "ce:plan-beta"),
skillContent(
"ce:plan-beta",
"[BETA] Transform feature descriptions or requirements into structured implementation plans grounded in repo patterns and research. Use when the user says 'plan this', 'create a plan', 'write a tech plan', 'plan the implementation', 'how should we build', 'what's the approach for', 'break this down', or when a brainstorm/requirements document is ready for technical planning. Best when requirements are at least roughly defined; for exploratory or ambiguous requests, prefer ce:brainstorm first.",
),
)
const removed = await cleanupStaleSkillDirs(root)
expect(removed).toBe(3)
expect(await exists(path.join(root, "ce:plan"))).toBe(false)
expect(await exists(path.join(root, "workflows:review"))).toBe(false)
expect(await exists(path.join(root, "ce:plan-beta"))).toBe(false)
})
test("returns 0 when directory does not exist", async () => {
const removed = await cleanupStaleSkillDirs("/tmp/nonexistent-cleanup-dir-12345")
expect(removed).toBe(0)

View File

@@ -0,0 +1,423 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { isSafeManagedPath } from "../src/utils/files"
import {
readManagedInstallManifest,
writeManagedInstallManifest,
cleanupRemovedManagedDirectories,
cleanupRemovedManagedFiles,
} from "../src/targets/managed-artifacts"
import { readCodexInstallManifest } from "../src/targets/codex"
import {
cleanupRemovedPiExtensions,
cleanupRemovedPiPrompts,
cleanupRemovedPiSkills,
readPiInstallManifest,
} from "../src/targets/pi"
describe("isSafeManagedPath", () => {
const root = "/tmp/managed-root"
test("accepts simple relative names", () => {
expect(isSafeManagedPath(root, "skill-name")).toBe(true)
expect(isSafeManagedPath(root, "foo.md")).toBe(true)
expect(isSafeManagedPath(root, "foo/bar")).toBe(true)
expect(isSafeManagedPath(root, "foo/bar/baz.toml")).toBe(true)
})
test("rejects non-string values", () => {
expect(isSafeManagedPath(root, undefined as unknown)).toBe(false)
expect(isSafeManagedPath(root, null as unknown)).toBe(false)
expect(isSafeManagedPath(root, 42 as unknown)).toBe(false)
expect(isSafeManagedPath(root, {} as unknown)).toBe(false)
})
test("rejects empty strings", () => {
expect(isSafeManagedPath(root, "")).toBe(false)
})
test("rejects absolute POSIX paths", () => {
expect(isSafeManagedPath(root, "/etc/passwd")).toBe(false)
expect(isSafeManagedPath(root, "/tmp/anything")).toBe(false)
})
test("rejects path traversal segments", () => {
expect(isSafeManagedPath(root, "..")).toBe(false)
expect(isSafeManagedPath(root, "../escape")).toBe(false)
expect(isSafeManagedPath(root, "../../../etc/passwd")).toBe(false)
expect(isSafeManagedPath(root, "foo/../bar")).toBe(false)
expect(isSafeManagedPath(root, "foo/../../escape")).toBe(false)
})
test("rejects windows-style absolute paths", () => {
// path.isAbsolute recognizes drive letters on win32 only; on posix
// the backslash form is treated as a literal filename, but the
// traversal split catches mixed separators.
expect(isSafeManagedPath(root, "..\\escape")).toBe(false)
expect(isSafeManagedPath(root, "foo\\..\\..\\escape")).toBe(false)
})
test("rejects entries that resolve outside root", () => {
// Even without `..` segments, the final containment check catches
// anything that would resolve outside the root.
expect(isSafeManagedPath(root, "..")).toBe(false)
})
})
describe("readManagedInstallManifest filters unsafe entries", () => {
let tempRoot: string
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "managed-manifest-"))
})
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
})
test("drops traversal and absolute entries, keeps safe ones", async () => {
const managedDir = path.join(tempRoot, "managed")
await fs.mkdir(managedDir, { recursive: true })
const manifest = {
version: 1,
pluginName: "compound-engineering",
groups: {
skills: [
"safe-skill",
"../../../etc/passwd",
"/etc/passwd",
"foo/../bar",
"foo/../../escape",
"another-safe",
],
commands: ["ok.md"],
},
}
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readManagedInstallManifest(managedDir, "compound-engineering")
expect(result).not.toBeNull()
expect(result!.groups.skills).toEqual(["safe-skill", "another-safe"])
expect(result!.groups.commands).toEqual(["ok.md"])
})
test("returns null for wrong pluginName", async () => {
const managedDir = path.join(tempRoot, "managed")
await fs.mkdir(managedDir, { recursive: true })
const manifest = {
version: 1,
pluginName: "other-plugin",
groups: { skills: ["safe"] },
}
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readManagedInstallManifest(managedDir, "compound-engineering")
expect(result).toBeNull()
})
})
describe("cleanupRemovedManagedFiles does not escape root (defense in depth)", () => {
let tempRoot: string
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "managed-cleanup-"))
})
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
})
test("skips unsafe entries even when fed directly (bypass read-time filter)", async () => {
const rootDir = path.join(tempRoot, "root")
await fs.mkdir(rootDir, { recursive: true })
const outsideFile = path.join(tempRoot, "outside.txt")
await fs.writeFile(outsideFile, "keep me")
// Simulate a manifest object assembled without going through
// readManagedInstallManifest's filter.
const hostileManifest = {
version: 1 as const,
pluginName: "compound-engineering",
groups: {
prompts: ["../outside.txt", "/etc/passwd"],
},
}
await cleanupRemovedManagedFiles(rootDir, hostileManifest, "prompts", [])
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
})
test("skips unsafe directory entries", async () => {
const rootDir = path.join(tempRoot, "root")
await fs.mkdir(rootDir, { recursive: true })
const outsideDir = path.join(tempRoot, "outside")
await fs.mkdir(outsideDir)
await fs.writeFile(path.join(outsideDir, "file.txt"), "keep me")
const hostileManifest = {
version: 1 as const,
pluginName: "compound-engineering",
groups: {
skills: ["../outside"],
},
}
await cleanupRemovedManagedDirectories(rootDir, hostileManifest, "skills", [])
expect(await fs.readFile(path.join(outsideDir, "file.txt"), "utf8")).toBe("keep me")
})
test("still cleans up safe entries correctly", async () => {
const rootDir = path.join(tempRoot, "root")
await fs.mkdir(rootDir, { recursive: true })
const safeFile = path.join(rootDir, "safe-prompt.md")
await fs.writeFile(safeFile, "remove me")
await writeManagedInstallManifest(rootDir, {
version: 1,
pluginName: "compound-engineering",
groups: { prompts: ["safe-prompt.md"] },
})
const manifest = await readManagedInstallManifest(rootDir, "compound-engineering")
expect(manifest).not.toBeNull()
// Simulate a follow-up install where "safe-prompt.md" is no longer
// in the current bundle — cleanup should remove it.
await cleanupRemovedManagedFiles(rootDir, manifest, "prompts", [])
let exists = true
try {
await fs.stat(safeFile)
} catch {
exists = false
}
expect(exists).toBe(false)
})
})
describe("readCodexInstallManifest filters unsafe entries", () => {
let tempRoot: string
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-manifest-"))
})
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
})
test("drops traversal/absolute entries from skills, prompts, agents", async () => {
const codexRoot = path.join(tempRoot, ".codex")
const pluginDir = path.join(codexRoot, "compound-engineering")
await fs.mkdir(pluginDir, { recursive: true })
const manifest = {
version: 1,
pluginName: "compound-engineering",
skills: ["safe-skill", "../../../etc/passwd", "/etc/passwd"],
prompts: ["ok.md", "../../evil.md", "foo/../../escape.md"],
agents: ["safe-agent.toml", "/tmp/abs.toml", "../escape.toml"],
}
await fs.writeFile(path.join(pluginDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readCodexInstallManifest(codexRoot, "compound-engineering")
expect(result).not.toBeNull()
expect(result!.skills).toEqual(["safe-skill"])
expect(result!.prompts).toEqual(["ok.md"])
expect(result!.agents).toEqual(["safe-agent.toml"])
})
test("keeps all entries when all are safe", async () => {
const codexRoot = path.join(tempRoot, ".codex")
const pluginDir = path.join(codexRoot, "compound-engineering")
await fs.mkdir(pluginDir, { recursive: true })
const manifest = {
version: 1,
pluginName: "compound-engineering",
skills: ["a", "b", "c"],
prompts: ["p.md"],
agents: ["agent.toml"],
}
await fs.writeFile(path.join(pluginDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readCodexInstallManifest(codexRoot, "compound-engineering")
expect(result).not.toBeNull()
expect(result!.skills).toEqual(["a", "b", "c"])
expect(result!.prompts).toEqual(["p.md"])
expect(result!.agents).toEqual(["agent.toml"])
})
})
describe("readPiInstallManifest filters unsafe entries", () => {
let tempRoot: string
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-manifest-"))
})
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
})
test("drops traversal/absolute entries from skills, prompts, extensions", async () => {
const piRoot = path.join(tempRoot, ".pi")
const managedDir = path.join(piRoot, "compound-engineering")
await fs.mkdir(managedDir, { recursive: true })
const paths = {
managedDir,
skillsDir: path.join(piRoot, "skills"),
promptsDir: path.join(piRoot, "prompts"),
extensionsDir: path.join(piRoot, "extensions"),
mcporterConfigPath: path.join(managedDir, "mcporter.json"),
agentsPath: path.join(piRoot, "AGENTS.md"),
}
const manifest = {
version: 1,
pluginName: "compound-engineering",
skills: ["safe-skill", "../../../etc/passwd", "/etc/passwd", "foo/../../escape"],
prompts: ["ok.md", "../../evil.md", "foo/../bar.md"],
extensions: ["safe.ext", "/tmp/abs.ext", "..\\escape.ext"],
}
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readPiInstallManifest(managedDir, "compound-engineering", paths)
expect(result).not.toBeNull()
expect(result!.skills).toEqual(["safe-skill"])
expect(result!.prompts).toEqual(["ok.md"])
expect(result!.extensions).toEqual(["safe.ext"])
})
test("keeps all entries when all are safe", async () => {
const piRoot = path.join(tempRoot, ".pi")
const managedDir = path.join(piRoot, "compound-engineering")
await fs.mkdir(managedDir, { recursive: true })
const paths = {
managedDir,
skillsDir: path.join(piRoot, "skills"),
promptsDir: path.join(piRoot, "prompts"),
extensionsDir: path.join(piRoot, "extensions"),
mcporterConfigPath: path.join(managedDir, "mcporter.json"),
agentsPath: path.join(piRoot, "AGENTS.md"),
}
const manifest = {
version: 1,
pluginName: "compound-engineering",
skills: ["a", "b", "c"],
prompts: ["p.md"],
extensions: ["ext.js"],
}
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
const result = await readPiInstallManifest(managedDir, "compound-engineering", paths)
expect(result).not.toBeNull()
expect(result!.skills).toEqual(["a", "b", "c"])
expect(result!.prompts).toEqual(["p.md"])
expect(result!.extensions).toEqual(["ext.js"])
})
})
describe("Pi cleanup helpers do not escape root (defense in depth)", () => {
let tempRoot: string
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-cleanup-"))
})
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
})
test("cleanupRemovedPiSkills skips unsafe entries fed directly", async () => {
const skillsDir = path.join(tempRoot, "skills")
await fs.mkdir(skillsDir, { recursive: true })
const outsideDir = path.join(tempRoot, "outside-skill")
await fs.mkdir(outsideDir)
await fs.writeFile(path.join(outsideDir, "file.txt"), "keep me")
const hostileManifest = {
version: 1 as const,
pluginName: "compound-engineering",
skills: ["../outside-skill", "/etc/passwd"],
prompts: [],
extensions: [],
}
await cleanupRemovedPiSkills(skillsDir, hostileManifest, [])
expect(await fs.readFile(path.join(outsideDir, "file.txt"), "utf8")).toBe("keep me")
})
test("cleanupRemovedPiPrompts skips unsafe entries fed directly", async () => {
const promptsDir = path.join(tempRoot, "prompts")
await fs.mkdir(promptsDir, { recursive: true })
const outsideFile = path.join(tempRoot, "outside.txt")
await fs.writeFile(outsideFile, "keep me")
const hostileManifest = {
version: 1 as const,
pluginName: "compound-engineering",
skills: [],
prompts: ["../outside.txt", "/etc/passwd"],
extensions: [],
}
await cleanupRemovedPiPrompts(promptsDir, hostileManifest, [])
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
})
test("cleanupRemovedPiExtensions skips unsafe entries fed directly", async () => {
const extensionsDir = path.join(tempRoot, "extensions")
await fs.mkdir(extensionsDir, { recursive: true })
const outsideFile = path.join(tempRoot, "outside-ext")
await fs.writeFile(outsideFile, "keep me")
const hostileManifest = {
version: 1 as const,
pluginName: "compound-engineering",
skills: [],
prompts: [],
extensions: ["../outside-ext", "/etc/passwd", "foo/../../escape"],
}
await cleanupRemovedPiExtensions(extensionsDir, hostileManifest, [])
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
})
test("still cleans up safe entries correctly", async () => {
const skillsDir = path.join(tempRoot, "skills")
const promptsDir = path.join(tempRoot, "prompts")
const extensionsDir = path.join(tempRoot, "extensions")
await fs.mkdir(skillsDir, { recursive: true })
await fs.mkdir(promptsDir, { recursive: true })
await fs.mkdir(extensionsDir, { recursive: true })
const staleSkillDir = path.join(skillsDir, "stale-skill")
await fs.mkdir(staleSkillDir)
await fs.writeFile(path.join(staleSkillDir, "SKILL.md"), "old")
const stalePrompt = path.join(promptsDir, "stale.md")
await fs.writeFile(stalePrompt, "old")
const staleExt = path.join(extensionsDir, "stale.ext")
await fs.writeFile(staleExt, "old")
const manifest = {
version: 1 as const,
pluginName: "compound-engineering",
skills: ["stale-skill"],
prompts: ["stale.md"],
extensions: ["stale.ext"],
}
await cleanupRemovedPiSkills(skillsDir, manifest, [])
await cleanupRemovedPiPrompts(promptsDir, manifest, [])
await cleanupRemovedPiExtensions(extensionsDir, manifest, [])
for (const p of [staleSkillDir, stalePrompt, staleExt]) {
let exists = true
try {
await fs.stat(p)
} catch {
exists = false
}
expect(exists).toBe(false)
}
})
})

View File

@@ -1,269 +0,0 @@
import { describe, expect, test } from "bun:test"
import { convertClaudeToOpenClaw } from "../src/converters/claude-to-openclaw"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "compound-engineering", version: "1.0.0", description: "A plugin" },
agents: [
{
name: "security-reviewer",
description: "Security-focused agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities in ~/.claude/settings.",
sourcePath: "/tmp/plugin/agents/security-reviewer.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work. See ~/.claude/settings for config.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
{
name: "disabled-cmd",
description: "Disabled command",
model: "inherit",
allowedTools: [],
body: "Should be excluded.",
disableModelInvocation: true,
sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: {
local: { command: "npx", args: ["-y", "some-mcp-server"] },
remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
},
}
const defaultOptions = {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
}
describe("convertClaudeToOpenClaw", () => {
test("converts agents to skill files with SKILL.md content", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const skill = bundle.skills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
expect(skill!.dir).toBe("agent-security-reviewer")
const parsed = parseFrontmatter(skill!.content)
expect(parsed.data.name).toBe("security-reviewer")
expect(parsed.data.description).toBe("Security-focused agent")
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
expect(parsed.body).toContain("Focus on vulnerabilities")
})
test("resolves bare model aliases to provider-prefixed IDs", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "fast-agent",
description: "Fast agent",
model: "sonnet",
body: "Do things quickly.",
sourcePath: "/tmp/plugin/agents/fast.md",
},
],
}
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
const skill = bundle.skills.find((s) => s.name === "fast-agent")
const parsed = parseFrontmatter(skill!.content)
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-6")
})
test("prefixes minimax models with minimax/ provider", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "minimax-agent",
description: "MiniMax agent",
model: "minimax-m2.7",
body: "Use MiniMax model.",
sourcePath: "/tmp/plugin/agents/minimax.md",
},
],
}
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
const skill = bundle.skills.find((s) => s.name === "minimax-agent")
const parsed = parseFrontmatter(skill!.content)
expect(parsed.data.model).toBe("minimax/minimax-m2.7")
})
test("converts commands to skill files (excluding disableModelInvocation)", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
expect(cmdSkill).toBeDefined()
expect(cmdSkill!.dir).toBe("cmd-workflows:plan")
const disabledSkill = bundle.skills.find((s) => s.name === "disabled-cmd")
expect(disabledSkill).toBeUndefined()
})
test("commands list excludes disableModelInvocation commands", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
expect(cmd).toBeDefined()
expect(cmd!.description).toBe("Planning command")
expect(cmd!.acceptsArgs).toBe(true)
const disabled = bundle.commands.find((c) => c.name === "disabled-cmd")
expect(disabled).toBeUndefined()
})
test("command colons are replaced with dashes in command registrations", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
expect(cmd).toBeDefined()
expect(cmd!.name).not.toContain(":")
})
test("manifest includes plugin id, display name, and skills list", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
expect(bundle.manifest.id).toBe("compound-engineering")
expect(bundle.manifest.name).toBe("Compound Engineering")
expect(bundle.manifest.kind).toBe("tool")
expect(bundle.manifest.configSchema).toEqual({
type: "object",
properties: {},
})
expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer")
expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan")
expect(bundle.manifest.skills).toContain("skills/existing-skill")
})
test("package.json uses plugin name and version", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
expect(bundle.packageJson.name).toBe("openclaw-compound-engineering")
expect(bundle.packageJson.version).toBe("1.0.0")
expect(bundle.packageJson.type).toBe("module")
})
test("skillDirCopies includes original skill directories", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const copy = bundle.skillDirCopies.find((s) => s.name === "existing-skill")
expect(copy).toBeDefined()
expect(copy!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
})
test("stdio MCP servers included in openclaw config", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
expect(bundle.openclawConfig).toBeDefined()
const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
expect(mcp.local).toBeDefined()
expect((mcp.local as any).type).toBe("stdio")
expect((mcp.local as any).command).toBe("npx")
})
test("HTTP MCP servers included as http type in openclaw config", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
expect(mcp.remote).toBeDefined()
expect((mcp.remote as any).type).toBe("http")
expect((mcp.remote as any).url).toBe("https://mcp.example.com/api")
})
test("paths are rewritten from .claude/ to .openclaw/ in skill content", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
const agentSkill = bundle.skills.find((s) => s.name === "security-reviewer")
expect(agentSkill!.content).toContain("~/.openclaw/settings")
expect(agentSkill!.content).not.toContain("~/.claude/settings")
const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
expect(cmdSkill!.content).toContain("~/.openclaw/settings")
expect(cmdSkill!.content).not.toContain("~/.claude/settings")
})
test("generateEntryPoint uses JSON.stringify for safe string escaping", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "tricky-cmd",
description: 'Has "quotes" and \\backslashes\\ and\nnewlines',
model: "inherit",
allowedTools: [],
body: "body",
sourcePath: "/tmp/cmd.md",
},
],
}
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
// Entry point must be valid JS/TS — JSON.stringify handles all special chars
expect(bundle.entryPoint).toContain('"tricky-cmd"')
expect(bundle.entryPoint).toContain('\\"quotes\\"')
expect(bundle.entryPoint).toContain("\\\\backslashes\\\\")
expect(bundle.entryPoint).toContain("\\n")
// No raw unescaped newline inside a string literal
const lines = bundle.entryPoint.split("\n")
const nameLine = lines.find((l) => l.includes("tricky-cmd") && l.includes("name:"))
expect(nameLine).toBeDefined()
})
test("generateEntryPoint inlines command bodies for sync registration", () => {
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
expect(bundle.entryPoint).not.toContain("const skills: Record<string, string> = {}")
expect(bundle.entryPoint).toContain('text: "Plan the work. See ~/.openclaw/settings for config."')
expect(bundle.entryPoint).toContain("export default function register(api)")
})
test("plugin without MCP servers has no openclawConfig", () => {
const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
expect(bundle.openclawConfig).toBeUndefined()
})
test("manifest skill paths use sanitized names matching filesystem output", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
skills: [
{
name: "ce-plan",
description: "Planning skill",
sourceDir: "/tmp/plugin/skills/ce-plan",
skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md",
},
],
}
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
// Manifest paths must not contain colons
for (const skillPath of bundle.manifest.skills) {
expect(skillPath).not.toContain(":")
}
expect(bundle.manifest.skills).toContain("skills/ce-plan")
expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan")
})
})

View File

@@ -1,104 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { writeOpenClawBundle } from "../src/targets/openclaw"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { OpenClawBundle } from "../src/types/openclaw"
async function exists(targetPath: string): Promise<boolean> {
try {
await fs.stat(targetPath)
return true
} catch {
return false
}
}
async function pluginDescription(relativePath: string): Promise<string> {
const raw = await fs.readFile(path.join(import.meta.dir, "..", relativePath), "utf8")
const { data } = parseFrontmatter(raw, relativePath)
if (typeof data.description !== "string") {
throw new Error(`Missing description in ${relativePath}`)
}
return data.description
}
function legacyAgentSkillContent(name: string, description: string): string {
return `---\nname: ${name}\ndescription: ${JSON.stringify(description)}\n---\n\n# ${name}\n`
}
describe("writeOpenClawBundle", () => {
test("writes openclaw.plugin.json with a configSchema", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-writer-"))
const bundle: OpenClawBundle = {
manifest: {
id: "compound-engineering",
name: "Compound Engineering",
kind: "tool",
configSchema: {
type: "object",
properties: {},
},
skills: [],
},
packageJson: {
name: "openclaw-compound-engineering",
version: "1.0.0",
},
entryPoint: "export default async function register() {}",
skills: [],
skillDirCopies: [],
commands: [],
}
await writeOpenClawBundle(tempRoot, bundle)
const manifest = JSON.parse(
await fs.readFile(path.join(tempRoot, "openclaw.plugin.json"), "utf8"),
)
expect(manifest.configSchema).toEqual({
type: "object",
properties: {},
})
})
test("removes stale legacy OpenClaw agent skill directories before writing", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-writer-cleanup-"))
const staleDir = path.join(tempRoot, "skills", "agent-adversarial-reviewer")
await fs.mkdir(staleDir, { recursive: true })
await fs.writeFile(
path.join(staleDir, "SKILL.md"),
legacyAgentSkillContent(
"adversarial-reviewer",
await pluginDescription("plugins/compound-engineering/agents/review/ce-adversarial-reviewer.agent.md"),
),
)
const bundle: OpenClawBundle = {
manifest: {
id: "compound-engineering",
name: "Compound Engineering",
kind: "tool",
configSchema: {
type: "object",
properties: {},
},
skills: [],
},
packageJson: {
name: "openclaw-compound-engineering",
version: "1.0.0",
},
entryPoint: "export default async function register() {}",
skills: [],
skillDirCopies: [],
commands: [],
}
await writeOpenClawBundle(tempRoot, bundle)
expect(await exists(staleDir)).toBe(false)
})
})

View File

@@ -3,8 +3,10 @@ import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeOpenCodeBundle } from "../src/targets/opencode"
import { mergeJsonConfigAtKey } from "../src/sync/json-config"
import { mergeJsonConfigAtKey } from "../src/utils/json-config"
import type { OpenCodeBundle } from "../src/types/opencode"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode"
async function exists(filePath: string): Promise<boolean> {
try {
@@ -19,6 +21,7 @@ describe("writeOpenCodeBundle", () => {
test("writes config, agents, plugins, and skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-"))
const bundle: OpenCodeBundle = {
pluginName: "compound-engineering",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "agent-one", content: "Agent content" }],
plugins: [{ name: "hook.ts", content: "export {}" }],
@@ -37,6 +40,7 @@ describe("writeOpenCodeBundle", () => {
expect(await exists(path.join(tempRoot, ".opencode", "agents", "agent-one.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "plugins", "hook.ts"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "compound-engineering", "install-manifest.json"))).toBe(true)
})
test("writes directly into a .opencode output root", async () => {
@@ -89,6 +93,32 @@ describe("writeOpenCodeBundle", () => {
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
})
test("scope='global' forces flat layout for OPENCODE_CONFIG_DIR-style roots with non-conventional basenames", async () => {
// Simulates OPENCODE_CONFIG_DIR pointing to a directory whose basename is
// neither "opencode" nor ".opencode" (e.g. NixOS, Docker, custom XDG_CONFIG_HOME).
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-env-dir-"))
const outputRoot = path.join(tempRoot, "custom-opencode-config")
const bundle: OpenCodeBundle = {
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "agent-one", content: "Agent content" }],
plugins: [],
commandFiles: [],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
}
await writeOpenCodeBundle(outputRoot, bundle, "global")
expect(await exists(path.join(outputRoot, "opencode.json"))).toBe(true)
expect(await exists(path.join(outputRoot, "agents", "agent-one.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
})
test("merges plugin config into existing opencode.json without destroying user keys", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-"))
const outputRoot = path.join(tempRoot, ".opencode")
@@ -325,6 +355,246 @@ describe("writeOpenCodeBundle", () => {
const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8")
expect(backupContent).toBe("old content\n")
})
test("removes previously managed OpenCode artifacts that disappear on reinstall", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-managed-cleanup-"))
const outputRoot = path.join(tempRoot, ".opencode")
await writeOpenCodeBundle(outputRoot, {
pluginName: "compound-engineering",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "old-agent", content: "Agent content" }],
plugins: [{ name: "hook.ts", content: "export {}" }],
commandFiles: [{ name: "old:cmd", content: "old" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
})
await writeOpenCodeBundle(outputRoot, {
pluginName: "compound-engineering",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "new-agent", content: "Agent content" }],
plugins: [],
commandFiles: [{ name: "new:cmd", content: "new" }],
skillDirs: [],
})
expect(await exists(path.join(outputRoot, "agents", "old-agent.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "agents", "new-agent.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "plugins", "hook.ts"))).toBe(false)
expect(await exists(path.join(outputRoot, "commands", "old", "cmd.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "commands", "new", "cmd.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(false)
})
test("namespaces managed install manifests per plugin so installs do not collide", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-multi-plugin-"))
const outputRoot = path.join(tempRoot, ".opencode")
// Install plugin A first, with a skill and an agent
await writeOpenCodeBundle(outputRoot, {
pluginName: "compound-engineering",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "ce-agent", content: "ce agent" }],
plugins: [],
commandFiles: [],
skillDirs: [
{
name: "ce-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
})
// Install plugin B into the same OpenCode root
await writeOpenCodeBundle(outputRoot, {
pluginName: "coding-tutor",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "tutor-agent", content: "tutor agent" }],
plugins: [],
commandFiles: [],
skillDirs: [
{
name: "tutor-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
})
// Both plugins must keep their own namespaced manifest
expect(await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json"))).toBe(true)
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
// Reinstall plugin A with no agents/skills — it must clean up only its own
// managed artifacts, leaving plugin B's intact (the bug the namespacing fix
// addresses: a shared manifest path would have lost B's manifest after A was
// installed, and a later A reinstall would skip B's stale-file cleanup).
await writeOpenCodeBundle(outputRoot, {
pluginName: "compound-engineering",
config: { $schema: "https://opencode.ai/config.json" },
agents: [],
plugins: [],
commandFiles: [],
skillDirs: [],
})
expect(await exists(path.join(outputRoot, "agents", "ce-agent.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "ce-skill"))).toBe(false)
expect(await exists(path.join(outputRoot, "agents", "tutor-agent.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "tutor-skill"))).toBe(true)
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
})
test("moves legacy OpenCode CE artifacts to a namespaced backup", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-legacy-artifacts-"))
const outputRoot = path.join(tempRoot, ".opencode")
await fs.mkdir(path.join(outputRoot, "skills", "reproduce-bug"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "skills", "reproduce-bug", "SKILL.md"), "legacy removed skill")
await fs.mkdir(path.join(outputRoot, "agents"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "agents", "bug-reproduction-validator.md"), "legacy removed agent")
await fs.mkdir(path.join(outputRoot, "commands"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "commands", "reproduce-bug.md"), "legacy removed command")
await fs.writeFile(path.join(outputRoot, "commands", "report-bug.md"), "legacy deleted command")
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
await writeOpenCodeBundle(outputRoot, bundle)
expect(await exists(path.join(outputRoot, "skills", "reproduce-bug"))).toBe(false)
expect(await exists(path.join(outputRoot, "agents", "bug-reproduction-validator.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "commands", "reproduce-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "commands", "report-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
test("upgrades from pre-namespacing legacy shared manifest for non-CE plugins", async () => {
// Pre-namespacing, ALL plugins wrote their install manifest to the same
// shared path: `<root>/compound-engineering/install-manifest.json`. After
// the namespacing fix, a plugin like `coding-tutor` reads from its own
// scoped path (`<root>/coding-tutor/install-manifest.json`), which does
// not exist on the first reinstall after upgrade. Without a fallback, the
// manifest resolves to null and the writer skips cleanup, leaving stale
// files from the pre-namespacing install in place. This test exercises
// the fallback read of the legacy shared manifest.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-legacy-manifest-"))
const outputRoot = path.join(tempRoot, ".opencode")
// Seed the legacy shared manifest at the OLD path, recording artifacts
// that the previous coding-tutor install placed in the root.
await fs.mkdir(path.join(outputRoot, "compound-engineering"), { recursive: true })
await fs.writeFile(
path.join(outputRoot, "compound-engineering", "install-manifest.json"),
JSON.stringify({
version: 1,
pluginName: "coding-tutor",
groups: {
agents: ["stale-tutor-agent.md"],
commands: ["stale-tutor-cmd.md"],
plugins: [],
skills: ["stale-tutor-skill"],
},
}),
)
// Seed the stale artifacts on disk as they'd exist from the prior install.
await fs.mkdir(path.join(outputRoot, "agents"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "agents", "stale-tutor-agent.md"), "stale")
await fs.mkdir(path.join(outputRoot, "commands"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "commands", "stale-tutor-cmd.md"), "stale")
await fs.mkdir(path.join(outputRoot, "skills", "stale-tutor-skill"), { recursive: true })
await fs.writeFile(
path.join(outputRoot, "skills", "stale-tutor-skill", "SKILL.md"),
"stale",
)
// Reinstall coding-tutor with a new, non-overlapping set of artifacts.
await writeOpenCodeBundle(outputRoot, {
pluginName: "coding-tutor",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "fresh-tutor-agent", content: "fresh" }],
plugins: [],
commandFiles: [],
skillDirs: [
{
name: "fresh-tutor-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
})
// Stale artifacts from the legacy manifest must be cleaned up.
expect(await exists(path.join(outputRoot, "agents", "stale-tutor-agent.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "commands", "stale-tutor-cmd.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "stale-tutor-skill"))).toBe(false)
// Fresh artifacts must be written under the plugin-scoped manifest path.
expect(await exists(path.join(outputRoot, "agents", "fresh-tutor-agent.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "fresh-tutor-skill", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
// The legacy shared manifest must be archived so it doesn't keep
// misleading a future install (and must no longer exist at the old path).
expect(await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json"))).toBe(false)
expect(await exists(path.join(outputRoot, "coding-tutor", "legacy-backup"))).toBe(true)
})
test("leaves legacy shared manifest alone when it belongs to a different plugin", async () => {
// Reinforces the cross-plugin safety: a legacy manifest owned by plugin
// A must not be consumed or cleaned up by plugin B's first namespaced
// install. Plugin A's own next install is responsible for migrating it.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-legacy-other-plugin-"))
const outputRoot = path.join(tempRoot, ".opencode")
await fs.mkdir(path.join(outputRoot, "compound-engineering"), { recursive: true })
const legacyManifest = {
version: 1,
pluginName: "some-other-plugin",
groups: {
agents: ["other-plugin-agent.md"],
commands: [],
plugins: [],
skills: [],
},
}
await fs.writeFile(
path.join(outputRoot, "compound-engineering", "install-manifest.json"),
JSON.stringify(legacyManifest),
)
await fs.mkdir(path.join(outputRoot, "agents"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "agents", "other-plugin-agent.md"), "other")
await writeOpenCodeBundle(outputRoot, {
pluginName: "coding-tutor",
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "tutor-agent", content: "tutor" }],
plugins: [],
commandFiles: [],
skillDirs: [],
})
// Other plugin's artifact is left alone.
expect(await exists(path.join(outputRoot, "agents", "other-plugin-agent.md"))).toBe(true)
// Other plugin's legacy manifest is left at the legacy path.
expect(
await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json")),
).toBe(true)
const preserved = JSON.parse(
await fs.readFile(
path.join(outputRoot, "compound-engineering", "install-manifest.json"),
"utf8",
),
)
expect(preserved.pluginName).toBe("some-other-plugin")
})
})
describe("mergeJsonConfigAtKey", () => {

View File

@@ -5,6 +5,8 @@ import os from "os"
import { writePiBundle } from "../src/targets/pi"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { PiBundle } from "../src/types/pi"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToPi } from "../src/converters/claude-to-pi"
async function exists(filePath: string): Promise<boolean> {
try {
@@ -59,6 +61,7 @@ describe("writePiBundle", () => {
const outputRoot = path.join(tempRoot, ".pi")
const bundle: PiBundle = {
pluginName: "compound-engineering",
prompts: [{ name: "workflows-plan", content: "Prompt content" }],
skillDirs: [
{
@@ -82,6 +85,7 @@ describe("writePiBundle", () => {
expect(await exists(path.join(outputRoot, "skills", "repo-research-analyst", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
expect(await exists(path.join(outputRoot, "compound-engineering", "mcporter.json"))).toBe(true)
expect(await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json"))).toBe(true)
const agentsPath = path.join(outputRoot, "AGENTS.md")
const agentsContent = await fs.readFile(agentsPath, "utf8")
@@ -175,4 +179,125 @@ Run these research agents:
const currentConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as { mcpServers: Record<string, unknown> }
expect(currentConfig.mcpServers.linear).toBeDefined()
})
test("removes previously managed Pi artifacts that disappear on reinstall", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-managed-cleanup-"))
const outputRoot = path.join(tempRoot, ".pi")
await writePiBundle(outputRoot, {
pluginName: "compound-engineering",
prompts: [{ name: "old-prompt", content: "Prompt content" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "old-agent", content: "---\nname: old-agent\n---\n\nBody" }],
extensions: [{ name: "compound-engineering-compat.ts", content: "export default function first() {}" }],
})
await writePiBundle(outputRoot, {
pluginName: "compound-engineering",
prompts: [{ name: "new-prompt", content: "Prompt content" }],
skillDirs: [],
generatedSkills: [{ name: "new-agent", content: "---\nname: new-agent\n---\n\nBody" }],
extensions: [],
})
expect(await exists(path.join(outputRoot, "prompts", "old-prompt.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "new-prompt.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "old-agent", "SKILL.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "new-agent", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(false)
})
test("namespaces managed install manifests per plugin so installs do not collide", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-multi-plugin-"))
const outputRoot = path.join(tempRoot, ".pi")
// Install plugin A first, with a prompt, skill, generated skill, and extension
await writePiBundle(outputRoot, {
pluginName: "compound-engineering",
prompts: [{ name: "ce-prompt", content: "CE prompt" }],
skillDirs: [
{
name: "ce-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "ce-gen-skill", content: "---\nname: ce-gen-skill\n---\n\nBody" }],
extensions: [{ name: "ce-ext.ts", content: "export default function () {}" }],
})
// Install plugin B into the same Pi root
await writePiBundle(outputRoot, {
pluginName: "coding-tutor",
prompts: [{ name: "tutor-prompt", content: "Tutor prompt" }],
skillDirs: [
{
name: "tutor-skill",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "tutor-gen-skill", content: "---\nname: tutor-gen-skill\n---\n\nBody" }],
extensions: [{ name: "tutor-ext.ts", content: "export default function () {}" }],
})
// Both plugins must keep their own namespaced manifest
expect(await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json"))).toBe(true)
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
// Reinstall plugin A with no artifacts — it must clean up only its own
// managed artifacts, leaving plugin B's intact (the bug the namespacing fix
// addresses: a shared manifest path would have lost B's manifest after A
// was installed, and a later A reinstall would skip B's stale-file cleanup).
await writePiBundle(outputRoot, {
pluginName: "compound-engineering",
prompts: [],
skillDirs: [],
generatedSkills: [],
extensions: [],
})
expect(await exists(path.join(outputRoot, "prompts", "ce-prompt.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "ce-skill"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "ce-gen-skill"))).toBe(false)
expect(await exists(path.join(outputRoot, "extensions", "ce-ext.ts"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "tutor-prompt.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "tutor-skill"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "tutor-gen-skill"))).toBe(true)
expect(await exists(path.join(outputRoot, "extensions", "tutor-ext.ts"))).toBe(true)
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
})
test("moves legacy flat Pi CE artifacts to a namespaced backup", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-legacy-artifacts-"))
const outputRoot = path.join(tempRoot, ".pi")
await fs.mkdir(path.join(outputRoot, "skills", "reproduce-bug"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "skills", "reproduce-bug", "SKILL.md"), "legacy removed skill")
await fs.mkdir(path.join(outputRoot, "skills", "bug-reproduction-validator"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "skills", "bug-reproduction-validator", "SKILL.md"), "legacy removed agent skill")
await fs.mkdir(path.join(outputRoot, "prompts"), { recursive: true })
await fs.writeFile(path.join(outputRoot, "prompts", "reproduce-bug.md"), "legacy removed prompt")
await fs.writeFile(path.join(outputRoot, "prompts", "report-bug.md"), "legacy deleted command prompt")
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToPi(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
await writePiBundle(outputRoot, bundle)
expect(await exists(path.join(outputRoot, "skills", "reproduce-bug"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "bug-reproduction-validator"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "reproduce-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "report-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "ce-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "ce-repo-research-analyst", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
})

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToCodex } from "../src/converters/claude-to-codex"
import { convertClaudeToPi } from "../src/converters/claude-to-pi"
import { convertClaudeToKiro } from "../src/converters/claude-to-kiro"
import { getLegacyCodexArtifacts, getLegacyKiroArtifacts, getLegacyPiArtifacts, getLegacyWindsurfArtifacts } from "../src/data/plugin-legacy-artifacts"
describe("plugin legacy artifacts", () => {
test("Codex legacy detection is restricted to the explicit historical allow-list", async () => {
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyCodexArtifacts(bundle)
// Historical CE skills (renamed/removed since) are detected. These are
// explicitly enumerated in EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN.
expect(artifacts.skills).toContain("ce-plan")
expect(artifacts.skills).toContain("ce:plan")
expect(artifacts.skills).toContain("ce:plan-beta")
expect(artifacts.skills).toContain("ce-review")
expect(artifacts.skills).toContain("ce:review-beta")
expect(artifacts.skills).toContain("ce-document-review")
expect(artifacts.skills).toContain("demo-reel")
expect(artifacts.skills).toContain("ce:polish-beta")
expect(artifacts.skills).toContain("ce:release-notes")
expect(artifacts.skills).toContain("ce-update")
expect(artifacts.skills).toContain("creating-agent-skills")
expect(artifacts.skills).toContain("repo-research-analyst")
expect(artifacts.skills).toContain("bug-reproduction-validator")
expect(artifacts.skills).toContain("report-bug")
expect(artifacts.skills).toContain("reproduce-bug")
expect(artifacts.skills).toContain("resolve_pr_parallel")
// Current CE skill names that were never on the historical allow-list MUST
// NOT be flagged as legacy candidates. Otherwise a first install would
// sweep an unrelated user skill at ~/.codex/skills/<name>/ into backup
// simply because its name collides with a current CE skill.
expect(artifacts.skills).not.toContain("ce-demo-reel")
// Synthesized agent name variants (e.g. ce-<final-segment>) are not on
// the historical allow-list either, so they should not be probed against
// unrelated user skills at flat ~/.codex/skills/<name>/ paths.
expect(artifacts.skills).not.toContain("ce-repo-research-analyst")
expect(artifacts.skills).not.toContain("research-ce-repo-research-analyst")
expect(artifacts.prompts).toContain("codify.md")
expect(artifacts.prompts).toContain("compound-plan.md")
expect(artifacts.prompts).toContain("plan.md")
expect(artifacts.prompts).toContain("report-bug.md")
expect(artifacts.prompts).toContain("workflows-review.md")
expect(artifacts.prompts).toContain("technical_review.md")
})
test("Codex legacy detection ignores current bundle skills/agents not in the historical allow-list", () => {
const artifacts = getLegacyCodexArtifacts({
pluginName: "compound-engineering",
prompts: [],
skillDirs: [
// A current skill name that was NEVER shipped historically. A user
// could plausibly have an unrelated skill at ~/.codex/skills/my-novel-skill/
// and a first install of CE must not touch it.
{ name: "my-novel-skill", sourceDir: "/tmp/unused" },
],
generatedSkills: [
{ name: "another-novel-skill", content: "" },
],
agents: [
{ name: "my-novel-agent", description: "x", instructions: "y" },
],
})
expect(artifacts.skills).not.toContain("my-novel-skill")
expect(artifacts.skills).not.toContain("another-novel-skill")
expect(artifacts.skills).not.toContain("my-novel-agent")
expect(artifacts.skills).not.toContain("ce-my-novel-agent")
})
test("Codex legacy detection returns nothing for plugins without an allow-list", () => {
const artifacts = getLegacyCodexArtifacts({
pluginName: "some-third-party-plugin",
prompts: [{ name: "anything", content: "" }],
skillDirs: [{ name: "shared-name", sourceDir: "/tmp/x" }],
generatedSkills: [],
agents: [{ name: "shared-name", description: "x", instructions: "y" }],
})
expect(artifacts.skills).toEqual([])
expect(artifacts.prompts).toEqual([])
})
test("includes current and historical CE artifacts for Pi cleanup", async () => {
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToPi(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyPiArtifacts(bundle)
expect(artifacts.skills).toContain("bug-reproduction-validator")
expect(artifacts.skills).toContain("creating-agent-skills")
expect(artifacts.skills).toContain("repo-research-analyst")
expect(artifacts.skills).toContain("reproduce-bug")
expect(artifacts.skills).toContain("resolve_pr_parallel")
expect(artifacts.skills).not.toContain("ce:plan")
expect(artifacts.skills).not.toContain("ce-plan")
expect(artifacts.prompts).toContain("codify.md")
expect(artifacts.prompts).toContain("compound-plan.md")
expect(artifacts.prompts).toContain("plan.md")
expect(artifacts.prompts).toContain("report-bug.md")
expect(artifacts.prompts).toContain("workflows-review.md")
expect(artifacts.prompts).toContain("technical_review.md")
})
test("includes historical CE artifacts for Kiro install cleanup", async () => {
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToKiro(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyKiroArtifacts(bundle)
expect(artifacts.skills).toContain("reproduce-bug")
expect(artifacts.skills).toContain("repo-research-analyst")
expect(artifacts.skills).toContain("creating-agent-skills")
expect(artifacts.skills).toContain("compound-plan")
expect(artifacts.skills).toContain("plan")
expect(artifacts.skills).toContain("resolve_pr_parallel")
expect(artifacts.skills).not.toContain("ce-plan")
expect(artifacts.agents).toContain("repo-research-analyst")
expect(artifacts.agents).not.toContain("ce-repo-research-analyst")
})
test("includes only historical CE artifacts for deprecated Windsurf cleanup", async () => {
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const artifacts = getLegacyWindsurfArtifacts(plugin)
expect(artifacts.skills).toContain("ce-review")
expect(artifacts.skills).toContain("creating-agent-skills")
expect(artifacts.skills).toContain("reproduce-bug")
expect(artifacts.skills).toContain("resolve_pr_parallel")
expect(artifacts.skills).toContain("repo-research-analyst")
expect(artifacts.workflows).toContain("codify.md")
expect(artifacts.workflows).toContain("compound-plan.md")
expect(artifacts.workflows).toContain("plan.md")
expect(artifacts.workflows).toContain("workflows-plan.md")
expect(artifacts.workflows).toContain("ce-plan.md")
expect(artifacts.workflows).toContain("technical_review.md")
// Names present in the current CE bundle but NOT on the historical
// allow-list must never be cleanup candidates, so user-authored files at
// those paths survive `cleanup --target windsurf`.
expect(artifacts.skills).not.toContain("ce-debug")
})
})

View File

@@ -1,268 +0,0 @@
import { describe, expect, test } from "bun:test"
import { convertClaudeToQwen } from "../src/converters/claude-to-qwen"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "compound-engineering", version: "1.2.0", description: "A plugin for engineers" },
agents: [
{
name: "security-sentinel",
description: "Security-focused agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities in ~/.claude/settings.",
sourcePath: "/tmp/plugin/agents/security-sentinel.md",
},
{
name: "brainstorm-agent",
description: "Creative brainstormer",
model: "inherit",
body: "Generate ideas.",
sourcePath: "/tmp/plugin/agents/brainstorm-agent.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work. Config at ~/.claude/settings.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
{
name: "disabled-cmd",
description: "Disabled",
model: "inherit",
allowedTools: [],
body: "Should be excluded.",
disableModelInvocation: true,
sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: {
local: { command: "npx", args: ["-y", "some-mcp"], env: { API_KEY: "${YOUR_API_KEY}" } },
remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
},
}
const defaultOptions = {
agentMode: "subagent" as const,
inferTemperature: false,
}
describe("convertClaudeToQwen", () => {
test("converts agents to yaml format with frontmatter", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
const agent = bundle.agents.find((a) => a.name === "security-sentinel")
expect(agent).toBeDefined()
expect(agent!.format).toBe("yaml")
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.name).toBe("security-sentinel")
expect(parsed.data.description).toBe("Security-focused agent")
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
expect(parsed.body).toContain("Focus on vulnerabilities")
})
test("agent with inherit model has no model field in frontmatter", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
const agent = bundle.agents.find((a) => a.name === "brainstorm-agent")
expect(agent).toBeDefined()
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBeUndefined()
})
test("inferTemperature injects temperature based on agent name/description", () => {
const bundle = convertClaudeToQwen(fixturePlugin, { ...defaultOptions, inferTemperature: true })
const sentinel = bundle.agents.find((a) => a.name === "security-sentinel")
const parsed = parseFrontmatter(sentinel!.content)
expect(parsed.data.temperature).toBe(0.1) // review/security → 0.1
const brainstorm = bundle.agents.find((a) => a.name === "brainstorm-agent")
const bParsed = parseFrontmatter(brainstorm!.content)
expect(bParsed.data.temperature).toBe(0.6) // brainstorm → 0.6
})
test("inferTemperature returns undefined for unrecognized agents (no temperature set)", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "my-helper", description: "Generic helper", model: "inherit", body: "help", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, { ...defaultOptions, inferTemperature: true })
const agent = bundle.agents[0]
const parsed = parseFrontmatter(agent.content)
expect(parsed.data.temperature).toBeUndefined()
})
test("converts commands to command files excluding disableModelInvocation", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
const planCmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
expect(planCmd).toBeDefined()
const parsed = parseFrontmatter(planCmd!.content)
expect(parsed.data.description).toBe("Planning command")
expect(parsed.data.allowedTools).toEqual(["Read"])
const disabled = bundle.commandFiles.find((c) => c.name === "disabled-cmd")
expect(disabled).toBeUndefined()
})
test("config uses plugin manifest name and version", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
expect(bundle.config.name).toBe("compound-engineering")
expect(bundle.config.version).toBe("1.2.0")
expect(bundle.config.commands).toBe("commands")
expect(bundle.config.skills).toBe("skills")
expect(bundle.config.agents).toBe("agents")
})
test("stdio MCP servers are included in config", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
expect(bundle.config.mcpServers).toBeDefined()
const local = bundle.config.mcpServers!.local
expect(local.command).toBe("npx")
expect(local.args).toEqual(["-y", "some-mcp"])
// No cwd field
expect((local as any).cwd).toBeUndefined()
})
test("remote MCP servers are skipped with a warning (not converted to curl)", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
// Only local (stdio) server should be present
expect(bundle.config.mcpServers).toBeDefined()
expect(bundle.config.mcpServers!.remote).toBeUndefined()
expect(bundle.config.mcpServers!.local).toBeDefined()
})
test("placeholder env vars are extracted as settings", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
expect(bundle.config.settings).toBeDefined()
const apiKeySetting = bundle.config.settings!.find((s) => s.envVar === "API_KEY")
expect(apiKeySetting).toBeDefined()
expect(apiKeySetting!.sensitive).toBe(true)
expect(apiKeySetting!.name).toBe("Api Key")
})
test("plugin with no MCP servers has no mcpServers in config", () => {
const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
const bundle = convertClaudeToQwen(plugin, defaultOptions)
expect(bundle.config.mcpServers).toBeUndefined()
})
test("context file uses plugin.manifest.name and manifest.description", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
expect(bundle.contextFile).toContain("# compound-engineering")
expect(bundle.contextFile).toContain("A plugin for engineers")
expect(bundle.contextFile).toContain("## Agents")
expect(bundle.contextFile).toContain("security-sentinel")
expect(bundle.contextFile).toContain("## Commands")
expect(bundle.contextFile).toContain("/workflows:plan")
// Disabled commands excluded
expect(bundle.contextFile).not.toContain("disabled-cmd")
expect(bundle.contextFile).toContain("## Skills")
expect(bundle.contextFile).toContain("existing-skill")
})
test("paths are rewritten from .claude/ to .qwen/ in agent and command content", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
const agent = bundle.agents.find((a) => a.name === "security-sentinel")
expect(agent!.content).toContain("~/.qwen/settings")
expect(agent!.content).not.toContain("~/.claude/settings")
const cmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
expect(cmd!.content).toContain("~/.qwen/settings")
expect(cmd!.content).not.toContain("~/.claude/settings")
})
test("opencode paths are NOT rewritten (only claude paths)", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "test-agent",
description: "test",
model: "inherit",
body: "See .opencode/config and ~/.config/opencode/settings",
sourcePath: "/tmp/a.md",
},
],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const agent = bundle.agents[0]
// opencode paths should NOT be rewritten
expect(agent.content).toContain(".opencode/config")
expect(agent.content).not.toContain(".qwen/config")
})
test("skillDirs passes through original skills", () => {
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
const skill = bundle.skillDirs.find((s) => s.name === "existing-skill")
expect(skill).toBeDefined()
expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
})
test("normalizes bare aliases to provider-prefixed model IDs", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "a", description: "d", model: "sonnet", body: "b", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-6")
})
test("prefixes claude models with anthropic/", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("anthropic/claude-opus-4-5")
})
test("prefixes qwen models with qwen/ provider", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "a", description: "d", model: "qwen-max", body: "b", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("qwen/qwen-max")
})
test("prefixes minimax models with minimax/ provider", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "a", description: "d", model: "minimax-m2.7", body: "b", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("minimax/minimax-m2.7")
})
test("passes through already-namespaced models unchanged", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }],
}
const bundle = convertClaudeToQwen(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("google/gemini-2.0")
})
})

View File

@@ -1,204 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { writeQwenBundle } from "../src/targets/qwen"
import type { QwenBundle } from "../src/types/qwen"
function makeBundle(mcpServers?: Record<string, { command: string }>): QwenBundle {
return {
config: {
name: "test-plugin",
version: "1.0.0",
commands: "commands",
skills: "skills",
agents: "agents",
mcpServers,
},
agents: [],
commandFiles: [],
skillDirs: [],
}
}
const LEGACY_LINT_DESCRIPTION = "Use this agent when you need to run linting and code quality checks on Ruby and ERB files. Run before pushing to origin."
describe("writeQwenBundle", () => {
test("cleans legacy agents before writing new agent files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-agent-cleanup-order-"))
const bundle: QwenBundle = {
...makeBundle(),
agents: [
{
name: "lint",
format: "markdown",
content: `---\nname: lint\ndescription: ${JSON.stringify(LEGACY_LINT_DESCRIPTION)}\n---\n\nReplacement agent\n`,
},
],
}
await writeQwenBundle(tempRoot, bundle)
const lintPath = path.join(tempRoot, "agents", "lint.md")
expect(await fs.readFile(lintPath, "utf8")).toContain("Replacement agent")
})
test("removes stale plugin MCP servers on re-install", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-converge-"))
await writeQwenBundle(tempRoot, makeBundle({ old: { command: "old-server" } }))
await writeQwenBundle(tempRoot, makeBundle({ fresh: { command: "new-server" } }))
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.mcpServers.fresh).toBeDefined()
expect(result.mcpServers.old).toBeUndefined()
})
test("preserves user-added MCP servers across re-installs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-user-mcp-"))
// User has their own MCP server alongside plugin-managed ones (tracking key present)
await fs.writeFile(
path.join(tempRoot, "qwen-extension.json"),
JSON.stringify({
name: "user-project",
mcpServers: { "user-tool": { command: "my-tool" } },
_compound_managed_mcp: [],
}),
)
await writeQwenBundle(tempRoot, makeBundle({ plugin: { command: "plugin-server" } }))
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result.mcpServers.plugin).toBeDefined()
})
test("preserves unknown top-level keys from existing config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-preserve-"))
await fs.writeFile(
path.join(tempRoot, "qwen-extension.json"),
JSON.stringify({ name: "user-project", customField: "should-survive" }),
)
await writeQwenBundle(tempRoot, makeBundle({ plugin: { command: "p" } }))
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.customField).toBe("should-survive")
// Tracking key should be written so future installs can prune stale plugin keys
expect(result._compound_managed_keys).toBeInstanceOf(Array)
expect(result._compound_managed_keys).not.toContain("customField")
})
test("prunes stale servers from legacy config without tracking key", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-legacy-"))
// Simulate old writer output: has mcpServers but no _compound_managed_mcp
await fs.writeFile(
path.join(tempRoot, "qwen-extension.json"),
JSON.stringify({
name: "old-project",
mcpServers: { old: { command: "old-server" }, renamed: { command: "renamed-server" } },
}),
)
await writeQwenBundle(tempRoot, makeBundle({ fresh: { command: "new-server" } }))
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.mcpServers.fresh).toBeDefined()
expect(result.mcpServers.old).toBeUndefined()
expect(result.mcpServers.renamed).toBeUndefined()
expect(result._compound_managed_mcp).toEqual(["fresh"])
})
test("does not prune untracked user config when plugin has zero MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-untracked-"))
// Pre-existing user config with no tracking key (never had the plugin before)
await fs.writeFile(
path.join(tempRoot, "qwen-extension.json"),
JSON.stringify({
name: "user-project",
mcpServers: { "user-tool": { command: "my-tool" } },
}),
)
// Plugin installs with zero MCP servers
await writeQwenBundle(tempRoot, makeBundle())
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result._compound_managed_mcp).toEqual([])
})
test("cleans up all plugin MCP servers when bundle has none", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-zero-"))
await writeQwenBundle(tempRoot, makeBundle({ old: { command: "old-server" } }))
await writeQwenBundle(tempRoot, makeBundle())
const result = JSON.parse(await fs.readFile(path.join(tempRoot, "qwen-extension.json"), "utf8"))
expect(result.mcpServers).toBeUndefined()
expect(result._compound_managed_mcp).toEqual([])
})
test("preserves user servers across zero-MCP-then-MCP round trip", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-roundtrip-"))
// 1. Install with plugin MCP
await writeQwenBundle(tempRoot, makeBundle({ plugin: { command: "plugin-server" } }))
// 2. User adds their own server (with tracking key present)
const configPath = path.join(tempRoot, "qwen-extension.json")
const afterInstall = JSON.parse(await fs.readFile(configPath, "utf8"))
afterInstall.mcpServers["user-tool"] = { command: "my-tool" }
await fs.writeFile(configPath, JSON.stringify(afterInstall))
// 3. Install with zero plugin MCP
await writeQwenBundle(tempRoot, makeBundle())
// 4. Install with plugin MCP again
await writeQwenBundle(tempRoot, makeBundle({ new_plugin: { command: "new-plugin" } }))
const result = JSON.parse(await fs.readFile(configPath, "utf8"))
expect(result.mcpServers["user-tool"]).toBeDefined()
expect(result.mcpServers.new_plugin).toBeDefined()
expect(result.mcpServers.plugin).toBeUndefined()
})
test("prunes stale top-level plugin keys when incoming config drops them", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qwen-stale-keys-"))
// First install with settings
const bundleWithSettings: QwenBundle = {
config: {
name: "test-plugin",
version: "1.0.0",
commands: "commands",
skills: "skills",
agents: "agents",
settings: [{ name: "api-key", description: "API key", envVar: "API_KEY", sensitive: true }],
},
agents: [],
commandFiles: [],
skillDirs: [],
}
await writeQwenBundle(tempRoot, bundleWithSettings)
// User adds their own top-level key
const configPath = path.join(tempRoot, "qwen-extension.json")
const afterInstall = JSON.parse(await fs.readFile(configPath, "utf8"))
afterInstall.userCustom = "should-survive"
await fs.writeFile(configPath, JSON.stringify(afterInstall))
// Second install without settings
await writeQwenBundle(tempRoot, makeBundle())
const result = JSON.parse(await fs.readFile(configPath, "utf8"))
expect(result.settings).toBeUndefined()
expect(result.userCustom).toBe("should-survive")
expect(result.name).toBe("test-plugin")
})
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { resolveTargetOutputRoot } from "../src/utils/resolve-output"
import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../src/utils/resolve-output"
const baseOptions = {
outputRoot: "/tmp/output",
@@ -21,111 +21,54 @@ describe("resolveTargetOutputRoot", () => {
expect(result).toBe(baseOptions.piHome)
})
test("droid returns ~/.factory", () => {
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "droid" })
expect(result).toBe(path.join(os.homedir(), ".factory"))
})
test("cursor with no explicit output uses cwd", () => {
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "cursor" })
expect(result).toBe(path.join(process.cwd(), ".cursor"))
})
test("cursor with explicit output uses outputRoot", () => {
test("opencode with explicit output returns outputRoot as-is", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "cursor",
hasExplicitOutput: true,
})
expect(result).toBe(path.join("/tmp/output", ".cursor"))
})
test("windsurf default scope (global) resolves to ~/.codeium/windsurf/", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "windsurf",
scope: "global",
})
expect(result).toBe(path.join(os.homedir(), ".codeium", "windsurf"))
})
test("windsurf workspace scope resolves to cwd/.windsurf/", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "windsurf",
scope: "workspace",
})
expect(result).toBe(path.join(process.cwd(), ".windsurf"))
})
test("windsurf with explicit output overrides global scope", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "windsurf",
hasExplicitOutput: true,
scope: "global",
targetName: "opencode",
})
expect(result).toBe("/tmp/output")
})
test("windsurf with explicit output overrides workspace scope", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "windsurf",
hasExplicitOutput: true,
scope: "workspace",
})
expect(result).toBe("/tmp/output")
})
describe("opencode without explicit output", () => {
const originalEnv = process.env.OPENCODE_CONFIG_DIR
test("windsurf with no scope and no explicit output uses cwd/.windsurf/", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "windsurf",
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalEnv
}
})
expect(result).toBe(path.join(process.cwd(), ".windsurf"))
})
test("opencode returns outputRoot as-is", () => {
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "opencode" })
expect(result).toBe("/tmp/output")
})
test("openclaw uses openclawHome + pluginName", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "openclaw",
openclawHome: "/custom/openclaw/extensions",
pluginName: "my-plugin",
test("falls back to ~/.config/opencode when OPENCODE_CONFIG_DIR is unset", () => {
delete process.env.OPENCODE_CONFIG_DIR
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "opencode" })
expect(result).toBe(path.join(os.homedir(), ".config", "opencode"))
})
expect(result).toBe("/custom/openclaw/extensions/my-plugin")
})
test("openclaw falls back to default home when not provided", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "openclaw",
pluginName: "my-plugin",
test("respects OPENCODE_CONFIG_DIR when set", () => {
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode"
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "opencode" })
expect(result).toBe("/custom/opencode")
})
expect(result).toBe(path.join(os.homedir(), ".openclaw", "extensions", "my-plugin"))
})
test("qwen uses qwenHome + pluginName", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "qwen",
qwenHome: "/custom/qwen/extensions",
pluginName: "my-plugin",
})
expect(result).toBe("/custom/qwen/extensions/my-plugin")
})
test("qwen falls back to default home when not provided", () => {
const result = resolveTargetOutputRoot({
...baseOptions,
targetName: "qwen",
pluginName: "my-plugin",
})
expect(result).toBe(path.join(os.homedir(), ".qwen", "extensions", "my-plugin"))
})
})
describe("resolveOpenCodeWriteScope", () => {
test("returns 'global' when no explicit output and no requested scope", () => {
expect(resolveOpenCodeWriteScope(false, undefined)).toBe("global")
})
test("returns undefined when explicit output is given and no requested scope", () => {
expect(resolveOpenCodeWriteScope(true, undefined)).toBeUndefined()
})
test("honors explicit requested scope even without explicit output", () => {
expect(resolveOpenCodeWriteScope(false, "workspace")).toBe("workspace")
})
test("honors explicit requested scope when explicit output is given", () => {
expect(resolveOpenCodeWriteScope(true, "global")).toBe("global")
})
})

View File

@@ -1,91 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToCodex } from "../src/sync/codex"
describe("syncToCodex", () => {
test("writes stdio and remote MCP servers into a managed block without clobbering user config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const configPath = path.join(tempRoot, "config.toml")
await fs.writeFile(
configPath,
[
"[custom]",
"enabled = true",
"",
"# BEGIN compound-plugin Claude Code MCP",
"[mcp_servers.old]",
"command = \"old\"",
"# END compound-plugin Claude Code MCP",
"",
"[post]",
"value = 2",
"",
].join("\n"),
)
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToCodex(config, tempRoot)
const skillPath = path.join(tempRoot, "skills", "skill-one")
expect((await fs.lstat(skillPath)).isSymbolicLink()).toBe(true)
const content = await fs.readFile(configPath, "utf8")
expect(content).toContain("[custom]")
expect(content).toContain("[post]")
expect(content).not.toContain("[mcp_servers.old]")
expect(content).toContain("[mcp_servers.local]")
expect(content).toContain("command = \"echo\"")
expect(content).toContain("[mcp_servers.remote]")
expect(content).toContain("url = \"https://example.com/mcp\"")
expect(content).toContain("http_headers")
// Old markers should be replaced with new ones
expect(content).not.toContain("# BEGIN compound-plugin Claude Code MCP")
expect(content.match(/# BEGIN Compound Engineering plugin MCP/g)?.length).toBe(1)
const perms = (await fs.stat(configPath)).mode & 0o777
expect(perms).toBe(0o600)
})
test("cleans up stale managed block when syncing with zero MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-zero-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const configPath = path.join(tempRoot, "config.toml")
// First sync with MCP servers
const configWithServers: ClaudeHomeConfig = {
skills: [{ name: "skill-one", sourceDir: fixtureSkillDir, skillPath: path.join(fixtureSkillDir, "SKILL.md") }],
mcpServers: { old: { command: "old-server" } },
}
await syncToCodex(configWithServers, tempRoot)
expect(await fs.readFile(configPath, "utf8")).toContain("[mcp_servers.old]")
// Second sync with zero MCP servers
const configEmpty: ClaudeHomeConfig = {
skills: [{ name: "skill-one", sourceDir: fixtureSkillDir, skillPath: path.join(fixtureSkillDir, "SKILL.md") }],
mcpServers: {},
}
await syncToCodex(configEmpty, tempRoot)
const content = await fs.readFile(configPath, "utf8")
expect(content).not.toContain("[mcp_servers.old]")
expect(content).not.toContain("# BEGIN")
})
})

View File

@@ -1,204 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { syncToCopilot } from "../src/sync/copilot"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToCopilot", () => {
test("symlinks skills to .github/skills/", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true)
})
test("converts personal commands into Copilot skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const skillContent = await fs.readFile(
path.join(tempRoot, "skills", "workflows-plan", "SKILL.md"),
"utf8",
)
expect(skillContent).toContain("name: workflows-plan")
expect(skillContent).toContain("Planning command")
expect(skillContent).toContain("## Arguments")
})
test("skips skills with invalid names", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
const config: ClaudeHomeConfig = {
skills: [
{
name: "../escape-attempt",
sourceDir: "/tmp/bad-skill",
skillPath: "/tmp/bad-skill/SKILL.md",
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const skillsDir = path.join(tempRoot, "skills")
const entries = await fs.readdir(skillsDir).catch(() => [])
expect(entries).toHaveLength(0)
})
test("merges MCP config with existing file", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
const mcpPath = path.join(tempRoot, "mcp-config.json")
await fs.writeFile(
mcpPath,
JSON.stringify({
mcpServers: {
existing: { type: "local", command: "node", args: ["server.js"], tools: ["*"] },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}
await syncToCopilot(config, tempRoot)
const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { command?: string; url?: string; type: string }>
}
expect(merged.mcpServers.existing?.command).toBe("node")
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(merged.mcpServers.context7?.type).toBe("http")
})
test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-env-"))
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
server: {
command: "echo",
args: ["hello"],
env: { API_KEY: "secret", COPILOT_MCP_TOKEN: "already-prefixed" },
},
},
}
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { env?: Record<string, string> }>
}
expect(mcpConfig.mcpServers.server?.env).toEqual({
COPILOT_MCP_API_KEY: "secret",
COPILOT_MCP_TOKEN: "already-prefixed",
})
})
test("writes MCP config with restricted permissions", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-perms-"))
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
server: { command: "echo", args: ["hello"] },
},
}
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "mcp-config.json")
const stat = await fs.stat(mcpPath)
// Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
const perms = stat.mode & 0o777
expect(perms).toBe(0o600)
})
test("does not write MCP config when no MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-nomcp-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const mcpExists = await fs.access(path.join(tempRoot, "mcp-config.json")).then(() => true).catch(() => false)
expect(mcpExists).toBe(false)
})
test("preserves explicit SSE transport for legacy remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-sse-"))
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: {
type: "sse",
url: "https://example.com/sse",
},
},
}
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { type?: string; url?: string }>
}
expect(mcpConfig.mcpServers.legacy).toEqual({
type: "sse",
tools: ["*"],
url: "https://example.com/sse",
})
})
})

View File

@@ -1,97 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { syncToDroid } from "../src/sync/droid"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToDroid", () => {
test("symlinks skills to factory skills dir and writes mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}
await syncToDroid(config, tempRoot)
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true)
const mcpConfig = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
) as {
mcpServers: Record<string, { type: string; url?: string; disabled: boolean }>
}
expect(mcpConfig.mcpServers.context7?.type).toBe("http")
expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(mcpConfig.mcpServers.context7?.disabled).toBe(false)
})
test("merges existing mcp.json and overwrites same-named servers from Claude", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
shared: { type: "http", url: "https://old.example.com", disabled: true },
existing: { type: "stdio", command: "node", disabled: false },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToDroid(config, tempRoot)
const mcpConfig = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { type: string; url?: string; command?: string; disabled: boolean }>
}
expect(mcpConfig.theme).toBe("dark")
expect(mcpConfig.mcpServers.existing?.command).toBe("node")
expect(mcpConfig.mcpServers.shared?.url).toBe("https://new.example.com")
expect(mcpConfig.mcpServers.shared?.disabled).toBe(false)
})
test("skips skills with invalid names", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-invalid-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "../escape",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToDroid(config, tempRoot)
const entries = await fs.readdir(path.join(tempRoot, "skills"))
expect(entries).toHaveLength(0)
})
})

View File

@@ -1,160 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { syncToGemini } from "../src/sync/gemini"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToGemini", () => {
test("symlinks skills and writes settings.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
local: { command: "echo", args: ["hello"], env: { FOO: "bar" } },
},
}
await syncToGemini(config, tempRoot)
// Check skill symlink
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true)
// Check settings.json
const settingsPath = path.join(tempRoot, "settings.json")
const settings = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
mcpServers: Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string> }>
}
expect(settings.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(settings.mcpServers.local?.command).toBe("echo")
expect(settings.mcpServers.local?.args).toEqual(["hello"])
expect(settings.mcpServers.local?.env).toEqual({ FOO: "bar" })
})
test("merges existing settings.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-merge-"))
const settingsPath = path.join(tempRoot, "settings.json")
await fs.writeFile(
settingsPath,
JSON.stringify({
theme: "dark",
mcpServers: { existing: { command: "node", args: ["server.js"] } },
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}
await syncToGemini(config, tempRoot)
const merged = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
theme: string
mcpServers: Record<string, { command?: string; url?: string }>
}
// Preserves existing settings
expect(merged.theme).toBe("dark")
// Preserves existing MCP servers
expect(merged.mcpServers.existing?.command).toBe("node")
// Adds new MCP servers
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
})
test("writes personal commands as Gemini TOML prompts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToGemini(config, tempRoot)
const content = await fs.readFile(
path.join(tempRoot, "commands", "workflows", "plan.toml"),
"utf8",
)
expect(content).toContain("Planning command")
expect(content).toContain("User request: {{args}}")
})
test("does not write settings.json when no MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToGemini(config, tempRoot)
// Skills should still be symlinked
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true)
// But settings.json should not exist
const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false)
expect(settingsExists).toBe(false)
})
test("skips mirrored ~/.agents skills when syncing to ~/.gemini and removes stale duplicate symlinks", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-home-"))
const geminiRoot = path.join(tempHome, ".gemini")
const agentsSkillDir = path.join(tempHome, ".agents", "skills", "skill-one")
await fs.mkdir(path.join(agentsSkillDir), { recursive: true })
await fs.writeFile(path.join(agentsSkillDir, "SKILL.md"), "# Skill One\n", "utf8")
await fs.mkdir(path.join(geminiRoot, "skills"), { recursive: true })
await fs.symlink(agentsSkillDir, path.join(geminiRoot, "skills", "skill-one"))
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: agentsSkillDir,
skillPath: path.join(agentsSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToGemini(config, geminiRoot)
const duplicateExists = await fs.access(path.join(geminiRoot, "skills", "skill-one")).then(() => true).catch(() => false)
expect(duplicateExists).toBe(false)
})
})

View File

@@ -1,83 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToKiro } from "../src/sync/kiro"
describe("syncToKiro", () => {
test("writes user-scope settings/mcp.json with local and remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { TOKEN: "secret" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToKiro(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}>
}
expect(content.mcpServers.local?.command).toBe("echo")
expect(content.mcpServers.local?.args).toEqual(["hello"])
expect(content.mcpServers.local?.env).toEqual({ TOKEN: "secret" })
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
})
test("merges existing settings/mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-merge-"))
await fs.mkdir(path.join(tempRoot, "settings"), { recursive: true })
await fs.writeFile(
path.join(tempRoot, "settings", "mcp.json"),
JSON.stringify({
note: "preserve",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToKiro(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
note: string
mcpServers: Record<string, { command?: string; url?: string }>
}
expect(content.note).toBe("preserve")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
})
})

View File

@@ -1,51 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToOpenClaw } from "../src/sync/openclaw"
describe("syncToOpenClaw", () => {
test("symlinks skills and warns instead of writing unvalidated MCP config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-openclaw-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
body: "Plan the work.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToOpenClaw(config, tempRoot)
} finally {
console.warn = originalWarn
}
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const openclawConfigExists = await fs.access(path.join(tempRoot, "openclaw.json")).then(() => true).catch(() => false)
expect(openclawConfigExists).toBe(false)
expect(warnings.some((warning) => warning.includes("OpenClaw personal command sync is skipped"))).toBe(true)
expect(warnings.some((warning) => warning.includes("OpenClaw MCP sync is skipped"))).toBe(true)
})
})

View File

@@ -1,68 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { syncToPi } from "../src/sync/pi"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToPi", () => {
test("symlinks skills and writes MCPorter config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
local: { command: "echo", args: ["hello"] },
},
}
await syncToPi(config, tempRoot)
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true)
const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
const mcporterConfig = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
mcpServers: Record<string, { baseUrl?: string; command?: string }>
}
expect(mcporterConfig.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
expect(mcporterConfig.mcpServers.local?.command).toBe("echo")
})
test("merges existing MCPorter config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-merge-"))
const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
await fs.writeFile(
mcporterPath,
JSON.stringify({ mcpServers: { existing: { baseUrl: "https://example.com/mcp" } } }, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}
await syncToPi(config, tempRoot)
const merged = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
mcpServers: Record<string, { baseUrl?: string }>
}
expect(merged.mcpServers.existing?.baseUrl).toBe("https://example.com/mcp")
expect(merged.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
})
})

View File

@@ -1,75 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToQwen } from "../src/sync/qwen"
describe("syncToQwen", () => {
test("defaults ambiguous remote URLs to httpUrl and warns", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-"))
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToQwen(config, tempRoot)
} finally {
console.warn = originalWarn
}
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
mcpServers: Record<string, { httpUrl?: string; url?: string; headers?: Record<string, string> }>
}
expect(content.mcpServers.remote?.httpUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.url).toBeUndefined()
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
expect(warnings.some((warning) => warning.includes("ambiguous remote transport"))).toBe(true)
})
test("uses legacy url only for explicit SSE servers and preserves existing settings", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-sse-"))
await fs.writeFile(
path.join(tempRoot, "settings.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToQwen(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; httpUrl?: string; url?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.legacy?.url).toBe("https://example.com/sse")
expect(content.mcpServers.legacy?.httpUrl).toBeUndefined()
})
})

View File

@@ -1,89 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToWindsurf } from "../src/sync/windsurf"
describe("syncToWindsurf", () => {
test("writes stdio, http, and sse MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
local: { command: "npx", args: ["serve"], env: { FOO: "bar" } },
remoteHttp: { url: "https://example.com/mcp", headers: { Authorization: "Bearer a" } },
remoteSse: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToWindsurf(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
serverUrl?: string
url?: string
}>
}
expect(content.mcpServers.local).toEqual({
command: "npx",
args: ["serve"],
env: { FOO: "bar" },
})
expect(content.mcpServers.remoteHttp?.serverUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remoteSse?.url).toBe("https://example.com/sse")
const perms = (await fs.stat(path.join(tempRoot, "mcp_config.json"))).mode & 0o777
expect(perms).toBe(0o600)
})
test("merges existing config and overwrites same-named servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp_config.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
shared: { serverUrl: "https://old.example.com" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToWindsurf(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; serverUrl?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.shared?.serverUrl).toBe("https://new.example.com")
})
})

View File

@@ -1,633 +0,0 @@
import { describe, expect, test } from "bun:test"
import { convertClaudeToWindsurf, transformContentForWindsurf, normalizeName } from "../src/converters/claude-to-windsurf"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "Security Reviewer",
description: "Security-focused agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities.",
sourcePath: "/tmp/plugin/agents/security-reviewer.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: {
local: { command: "echo", args: ["hello"] },
},
}
const defaultOptions = {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
}
describe("convertClaudeToWindsurf", () => {
test("converts agents to skills with correct name and description in SKILL.md", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
expect(skill!.content).toContain("name: security-reviewer")
expect(skill!.content).toContain("description: Security-focused agent")
expect(skill!.content).toContain("Focus on vulnerabilities.")
})
test("agent capabilities included in skill content", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer")
expect(skill!.content).toContain("## Capabilities")
expect(skill!.content).toContain("- Threat modeling")
expect(skill!.content).toContain("- OWASP")
})
test("agent with empty description gets default description", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "my-agent",
body: "Do things.",
sourcePath: "/tmp/plugin/agents/my-agent.md",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].content).toContain("description: Converted from Claude agent my-agent")
})
test("agent model field silently dropped", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer")
expect(skill!.content).not.toContain("model:")
})
test("agent with empty body gets default body text", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "Empty Agent",
description: "An empty agent",
body: "",
sourcePath: "/tmp/plugin/agents/empty.md",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].content).toContain("Instructions converted from the Empty Agent agent.")
})
test("converts commands to workflows with description", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
expect(bundle.commandWorkflows).toHaveLength(1)
const workflow = bundle.commandWorkflows[0]
expect(workflow.name).toBe("workflows-plan")
expect(workflow.description).toBe("Planning command")
expect(workflow.body).toContain("Plan the work.")
})
test("command argumentHint preserved as note in body", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
const workflow = bundle.commandWorkflows[0]
expect(workflow.body).toContain("> Arguments: [FOCUS]")
})
test("command with no description gets fallback", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "my-command",
body: "Do things.",
sourcePath: "/tmp/plugin/commands/my-command.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.commandWorkflows[0].description).toBe("Converted from Claude command my-command")
})
test("command with disableModelInvocation is still included", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "disabled-command",
description: "Disabled command",
disableModelInvocation: true,
body: "Disabled body.",
sourcePath: "/tmp/plugin/commands/disabled.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.commandWorkflows).toHaveLength(1)
expect(bundle.commandWorkflows[0].name).toBe("disabled-command")
})
test("command allowedTools silently dropped", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
const workflow = bundle.commandWorkflows[0]
expect(workflow.body).not.toContain("allowedTools")
})
test("skills pass through as directory references", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
expect(bundle.skillDirs).toHaveLength(1)
expect(bundle.skillDirs[0].name).toBe("existing-skill")
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
})
test("name normalization handles various inputs", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{ name: "My Cool Agent!!!", description: "Cool", body: "Body.", sourcePath: "/tmp/a.md" },
{ name: "UPPERCASE-AGENT", description: "Upper", body: "Body.", sourcePath: "/tmp/b.md" },
{ name: "agent--with--double-hyphens", description: "Hyphens", body: "Body.", sourcePath: "/tmp/c.md" },
],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].name).toBe("my-cool-agent")
expect(bundle.agentSkills[1].name).toBe("uppercase-agent")
expect(bundle.agentSkills[2].name).toBe("agent-with-double-hyphens")
})
test("name deduplication within agent skills", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{ name: "reviewer", description: "First", body: "Body.", sourcePath: "/tmp/a.md" },
{ name: "Reviewer", description: "Second", body: "Body.", sourcePath: "/tmp/b.md" },
],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].name).toBe("reviewer")
expect(bundle.agentSkills[1].name).toBe("reviewer-2")
})
test("agent skill name deduplicates against pass-through skill names", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{ name: "existing-skill", description: "Agent with same name as skill", body: "Body.", sourcePath: "/tmp/a.md" },
],
commands: [],
skills: [
{
name: "existing-skill",
description: "Pass-through skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].name).toBe("existing-skill-2")
})
test("agent skill and command with same normalized name are NOT deduplicated (separate sets)", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{ name: "review", description: "Agent", body: "Body.", sourcePath: "/tmp/a.md" },
],
commands: [
{ name: "review", description: "Command", body: "Body.", sourcePath: "/tmp/b.md" },
],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills[0].name).toBe("review")
expect(bundle.commandWorkflows[0].name).toBe("review")
})
test("large agent skill does not emit 12K character limit warning (skills have no limit)", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (msg: string) => warnings.push(msg)
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "large-agent",
description: "Large agent",
body: "x".repeat(12_000),
sourcePath: "/tmp/a.md",
},
],
commands: [],
skills: [],
}
convertClaudeToWindsurf(plugin, defaultOptions)
console.warn = originalWarn
expect(warnings.some((w) => w.includes("12000") || w.includes("limit"))).toBe(false)
})
test("hooks present emits console.warn", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (msg: string) => warnings.push(msg)
const plugin: ClaudePlugin = {
...fixturePlugin,
hooks: { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "echo test" }] }] } },
agents: [],
commands: [],
skills: [],
}
convertClaudeToWindsurf(plugin, defaultOptions)
console.warn = originalWarn
expect(warnings.some((w) => w.includes("Windsurf"))).toBe(true)
})
test("empty plugin produces empty bundle with null mcpConfig", () => {
const plugin: ClaudePlugin = {
root: "/tmp/empty",
manifest: { name: "empty", version: "1.0.0" },
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.agentSkills).toHaveLength(0)
expect(bundle.commandWorkflows).toHaveLength(0)
expect(bundle.skillDirs).toHaveLength(0)
expect(bundle.mcpConfig).toBeNull()
})
// MCP config tests
test("stdio server produces correct mcpConfig JSON structure", () => {
const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions)
expect(bundle.mcpConfig).not.toBeNull()
expect(bundle.mcpConfig!.mcpServers.local).toEqual({
command: "echo",
args: ["hello"],
})
})
test("stdio server with env vars includes actual values (not redacted)", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
myserver: {
command: "serve",
env: {
API_KEY: "secret123",
PORT: "3000",
},
},
},
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.mcpConfig!.mcpServers.myserver.env).toEqual({
API_KEY: "secret123",
PORT: "3000",
})
})
test("HTTP/SSE server produces correct mcpConfig with serverUrl", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer abc" } },
},
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.mcpConfig!.mcpServers.remote).toEqual({
serverUrl: "https://example.com/mcp",
headers: { Authorization: "Bearer abc" },
})
})
test("mixed stdio and HTTP servers both included", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
local: { command: "echo", args: ["hello"] },
remote: { url: "https://example.com/mcp" },
},
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(Object.keys(bundle.mcpConfig!.mcpServers)).toHaveLength(2)
expect(bundle.mcpConfig!.mcpServers.local.command).toBe("echo")
expect(bundle.mcpConfig!.mcpServers.remote.serverUrl).toBe("https://example.com/mcp")
})
test("hasPotentialSecrets emits console.warn for sensitive env keys", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" "))
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
myserver: {
command: "serve",
env: { API_KEY: "secret123", PORT: "3000" },
},
},
agents: [],
commands: [],
skills: [],
}
convertClaudeToWindsurf(plugin, defaultOptions)
console.warn = originalWarn
expect(warnings.some((w) => w.includes("secrets") && w.includes("myserver"))).toBe(true)
})
test("no secrets warning when env vars are safe", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" "))
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
myserver: {
command: "serve",
env: { PORT: "3000", HOST: "localhost" },
},
},
agents: [],
commands: [],
skills: [],
}
convertClaudeToWindsurf(plugin, defaultOptions)
console.warn = originalWarn
expect(warnings.some((w) => w.includes("secrets"))).toBe(false)
})
test("no MCP servers produces null mcpConfig", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: undefined,
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.mcpConfig).toBeNull()
})
test("server with no command and no URL is skipped with warning", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" "))
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
broken: {} as { command: string },
},
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
console.warn = originalWarn
expect(bundle.mcpConfig).toBeNull()
expect(warnings.some((w) => w.includes("broken") && w.includes("no command or URL"))).toBe(true)
})
test("server command without args omits args field", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
mcpServers: {
simple: { command: "myserver" },
},
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToWindsurf(plugin, defaultOptions)
expect(bundle.mcpConfig!.mcpServers.simple).toEqual({ command: "myserver" })
expect(bundle.mcpConfig!.mcpServers.simple.args).toBeUndefined()
})
})
describe("transformContentForWindsurf", () => {
test("transforms .claude/ paths to .windsurf/", () => {
const result = transformContentForWindsurf("Read .claude/settings.json for config.")
expect(result).toContain(".windsurf/settings.json")
expect(result).not.toContain(".claude/")
})
test("transforms ~/.claude/ paths to ~/.codeium/windsurf/", () => {
const result = transformContentForWindsurf("Check ~/.claude/config for settings.")
expect(result).toContain("~/.codeium/windsurf/config")
expect(result).not.toContain("~/.claude/")
})
test("transforms Task agent(args) to skill reference", () => {
const input = `Run these:
- Task repo-research-analyst(feature_description)
- Task learnings-researcher(feature_description)
Task best-practices-researcher(topic)`
const result = transformContentForWindsurf(input)
expect(result).toContain("Use the @repo-research-analyst skill: feature_description")
expect(result).toContain("Use the @learnings-researcher skill: feature_description")
expect(result).toContain("Use the @best-practices-researcher skill: topic")
expect(result).not.toContain("Task repo-research-analyst")
})
test("transforms namespaced Task agent calls using final segment", () => {
const input = `Run agents:
- Task compound-engineering:research:repo-research-analyst(feature_description)
- Task compound-engineering:review:security-reviewer(code_diff)`
const result = transformContentForWindsurf(input)
expect(result).toContain("Use the @repo-research-analyst skill: feature_description")
expect(result).toContain("Use the @security-reviewer skill: code_diff")
expect(result).not.toContain("compound-engineering:")
})
test("transforms zero-argument Task calls", () => {
const input = `- Task compound-engineering:review:code-simplicity-reviewer()`
const result = transformContentForWindsurf(input)
expect(result).toContain("Use the @code-simplicity-reviewer skill")
expect(result).not.toContain("compound-engineering:")
expect(result).not.toContain("code-simplicity-reviewer skill:")
})
test("keeps @agent references as-is for known agents (Windsurf skill invocation syntax)", () => {
const result = transformContentForWindsurf("Ask @security-sentinel for a review.", ["security-sentinel"])
expect(result).toContain("@security-sentinel")
expect(result).not.toContain("/agents/")
})
test("does not transform @unknown-name when not in known agents", () => {
const result = transformContentForWindsurf("Contact @someone-else for help.", ["security-sentinel"])
expect(result).toContain("@someone-else")
})
test("transforms slash command refs to /{workflow-name} (per spec)", () => {
const result = transformContentForWindsurf("Run /workflows:plan to start planning.")
expect(result).toContain("/workflows-plan")
expect(result).not.toContain("/commands/")
})
test("does not transform partial .claude paths in middle of word", () => {
const result = transformContentForWindsurf("Check some-package/.claude-config/settings")
expect(result).toContain("some-package/")
})
test("handles case sensitivity in @agent-name matching", () => {
const result = transformContentForWindsurf("Delegate to @My-Agent for help.", ["my-agent"])
// @My-Agent won't match my-agent since regex is case-sensitive on the known names
expect(result).toContain("@My-Agent")
})
test("handles multiple occurrences of same transform", () => {
const result = transformContentForWindsurf(
"Use .claude/foo and .claude/bar for config.",
)
expect(result).toContain(".windsurf/foo")
expect(result).toContain(".windsurf/bar")
expect(result).not.toContain(".claude/")
})
})
describe("normalizeName", () => {
test("lowercases and hyphenates spaces", () => {
expect(normalizeName("Security Reviewer")).toBe("security-reviewer")
})
test("replaces colons with hyphens", () => {
expect(normalizeName("workflows:plan")).toBe("workflows-plan")
})
test("collapses consecutive hyphens", () => {
expect(normalizeName("agent--with--double-hyphens")).toBe("agent-with-double-hyphens")
})
test("strips leading/trailing hyphens", () => {
expect(normalizeName("-leading-and-trailing-")).toBe("leading-and-trailing")
})
test("empty string returns item", () => {
expect(normalizeName("")).toBe("item")
})
test("non-letter start returns item", () => {
expect(normalizeName("123-agent")).toBe("item")
})
})
describe("convertClaudeToWindsurf dedupe", () => {
test("agent skill deduplicates against sanitized pass-through skill names", () => {
const { convertClaudeToWindsurf } = require("../src/converters/claude-to-windsurf")
const plugin: import("../src/types/claude").ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "ce-plan",
description: "Planning agent",
body: "Plan things.",
sourcePath: "/tmp/plugin/agents/ce-plan.md",
},
],
commands: [],
skills: [
{
name: "ce-plan",
description: "Planning skill",
sourceDir: "/tmp/plugin/skills/ce-plan",
skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md",
},
],
hooks: undefined,
mcpServers: undefined,
}
const bundle = convertClaudeToWindsurf(plugin, {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
})
// The agent skill should get a deduplicated name since "ce-plan" normalizes
// to "ce-plan" which collides with the pass-through skill on disk
expect(bundle.agentSkills[0].name).not.toBe("ce-plan")
})
})

View File

@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeWindsurfBundle } from "../src/targets/windsurf"
import type { WindsurfBundle } from "../src/types/windsurf"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
const emptyBundle: WindsurfBundle = {
agentSkills: [],
commandWorkflows: [],
skillDirs: [],
mcpConfig: null,
}
describe("writeWindsurfBundle", () => {
test("creates correct directory structure with all components", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-test-"))
const bundle: WindsurfBundle = {
agentSkills: [
{
name: "security-reviewer",
content: "---\nname: security-reviewer\ndescription: Security-focused agent\n---\n\n# security-reviewer\n\nReview code for vulnerabilities.\n",
},
],
commandWorkflows: [
{
name: "workflows-plan",
description: "Planning command",
body: "> Arguments: [FOCUS]\n\nPlan the work.",
},
],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
mcpConfig: {
mcpServers: {
local: { command: "echo", args: ["hello"] },
},
},
}
await writeWindsurfBundle(tempRoot, bundle)
// No AGENTS.md — removed in v0.11.0
expect(await exists(path.join(tempRoot, "AGENTS.md"))).toBe(false)
// Agent skill written as skills/<name>/SKILL.md
const agentSkillPath = path.join(tempRoot, "skills", "security-reviewer", "SKILL.md")
expect(await exists(agentSkillPath)).toBe(true)
const agentContent = await fs.readFile(agentSkillPath, "utf8")
expect(agentContent).toContain("name: security-reviewer")
expect(agentContent).toContain("description: Security-focused agent")
expect(agentContent).toContain("Review code for vulnerabilities.")
// No workflows/agents/ or workflows/commands/ subdirectories (flat per spec)
expect(await exists(path.join(tempRoot, "workflows", "agents"))).toBe(false)
expect(await exists(path.join(tempRoot, "workflows", "commands"))).toBe(false)
// Command workflow flat in outputRoot/workflows/ (per spec)
const cmdWorkflowPath = path.join(tempRoot, "workflows", "workflows-plan.md")
expect(await exists(cmdWorkflowPath)).toBe(true)
const cmdContent = await fs.readFile(cmdWorkflowPath, "utf8")
expect(cmdContent).toContain("description: Planning command")
expect(cmdContent).toContain("Plan the work.")
// Copied skill directly in outputRoot/skills/
expect(await exists(path.join(tempRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
// MCP config directly in outputRoot/
const mcpPath = path.join(tempRoot, "mcp_config.json")
expect(await exists(mcpPath)).toBe(true)
const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(mcpContent.mcpServers.local).toEqual({ command: "echo", args: ["hello"] })
})
test("transforms Task calls in copied SKILL.md files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-skill-transform-"))
const sourceSkillDir = path.join(tempRoot, "source-skill")
await fs.mkdir(sourceSkillDir, { recursive: true })
await fs.writeFile(
path.join(sourceSkillDir, "SKILL.md"),
`---
name: ce-plan
description: Planning workflow
---
Run these research agents:
- Task compound-engineering:research:repo-research-analyst(feature_description)
- Task compound-engineering:research:learnings-researcher(feature_description)
- Task compound-engineering:review:code-simplicity-reviewer()
`,
)
const bundle: WindsurfBundle = {
...emptyBundle,
skillDirs: [{ name: "ce-plan", sourceDir: sourceSkillDir }],
}
await writeWindsurfBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, "skills", "ce-plan", "SKILL.md"),
"utf8",
)
expect(installedSkill).toContain("Use the @repo-research-analyst skill: feature_description")
expect(installedSkill).toContain("Use the @learnings-researcher skill: feature_description")
expect(installedSkill).toContain("Use the @code-simplicity-reviewer skill")
expect(installedSkill).not.toContain("Task compound-engineering:")
})
test("writes directly into outputRoot without nesting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-direct-"))
const bundle: WindsurfBundle = {
...emptyBundle,
agentSkills: [
{
name: "reviewer",
content: "---\nname: reviewer\ndescription: A reviewer\n---\n\n# reviewer\n\nReview content.\n",
},
],
}
await writeWindsurfBundle(tempRoot, bundle)
// Skill should be directly in outputRoot/skills/reviewer/SKILL.md
expect(await exists(path.join(tempRoot, "skills", "reviewer", "SKILL.md"))).toBe(true)
// Should NOT create a .windsurf subdirectory
expect(await exists(path.join(tempRoot, ".windsurf"))).toBe(false)
})
test("handles empty bundle gracefully", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-empty-"))
await writeWindsurfBundle(tempRoot, emptyBundle)
expect(await exists(tempRoot)).toBe(true)
// No mcp_config.json for null mcpConfig
expect(await exists(path.join(tempRoot, "mcp_config.json"))).toBe(false)
})
test("path traversal in agent skill name is rejected", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-traversal-"))
const bundle: WindsurfBundle = {
...emptyBundle,
agentSkills: [
{ name: "../escape", content: "Bad content." },
],
}
expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
})
test("path traversal in command workflow name is rejected", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-traversal2-"))
const bundle: WindsurfBundle = {
...emptyBundle,
commandWorkflows: [
{ name: "../escape", description: "Malicious", body: "Bad content." },
],
}
expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
})
test("skill directory containment check prevents escape", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-skill-escape-"))
const bundle: WindsurfBundle = {
...emptyBundle,
skillDirs: [
{ name: "../escape", sourceDir: "/tmp/fake-skill" },
],
}
expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
})
test("agent skill files have YAML frontmatter with name and description", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-fm-"))
const bundle: WindsurfBundle = {
...emptyBundle,
agentSkills: [
{
name: "test-agent",
content: "---\nname: test-agent\ndescription: Test agent description\n---\n\n# test-agent\n\nDo test things.\n",
},
],
}
await writeWindsurfBundle(tempRoot, bundle)
const skillPath = path.join(tempRoot, "skills", "test-agent", "SKILL.md")
const content = await fs.readFile(skillPath, "utf8")
expect(content).toContain("---")
expect(content).toContain("name: test-agent")
expect(content).toContain("description: Test agent description")
expect(content).toContain("# test-agent")
expect(content).toContain("Do test things.")
})
// MCP config merge tests
test("writes mcp_config.json to outputRoot", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-mcp-"))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: {
myserver: { command: "serve", args: ["--port", "3000"] },
},
},
}
await writeWindsurfBundle(tempRoot, bundle)
const mcpPath = path.join(tempRoot, "mcp_config.json")
expect(await exists(mcpPath)).toBe(true)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(content.mcpServers.myserver.command).toBe("serve")
expect(content.mcpServers.myserver.args).toEqual(["--port", "3000"])
})
test("merges with existing mcp_config.json preserving user servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-merge-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
// Write existing config with a user server
await fs.writeFile(mcpPath, JSON.stringify({
mcpServers: {
"user-server": { command: "my-tool", args: ["--flag"] },
},
}, null, 2))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: {
"plugin-server": { command: "plugin-tool" },
},
},
}
await writeWindsurfBundle(tempRoot, bundle)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
// Both servers should be present
expect(content.mcpServers["user-server"].command).toBe("my-tool")
expect(content.mcpServers["plugin-server"].command).toBe("plugin-tool")
})
test("backs up existing mcp_config.json before overwrite", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-backup-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
await fs.writeFile(mcpPath, '{"mcpServers":{}}')
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { new: { command: "new-tool" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
// A backup file should exist
const files = await fs.readdir(tempRoot)
const backupFiles = files.filter((f) => f.startsWith("mcp_config.json.bak."))
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
})
test("handles corrupted existing mcp_config.json with warning", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-corrupt-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
await fs.writeFile(mcpPath, "not valid json{{{")
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" "))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { new: { command: "new-tool" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
console.warn = originalWarn
expect(warnings.some((w) => w.includes("could not be parsed"))).toBe(true)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(content.mcpServers.new.command).toBe("new-tool")
})
test("handles existing mcp_config.json with array at root", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-array-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
await fs.writeFile(mcpPath, "[1,2,3]")
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { new: { command: "new-tool" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(content.mcpServers.new.command).toBe("new-tool")
// Array root should be replaced with object
expect(Array.isArray(content)).toBe(false)
})
test("preserves non-mcpServers keys in existing file", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-preserve-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
await fs.writeFile(mcpPath, JSON.stringify({
customSetting: true,
version: 2,
mcpServers: { old: { command: "old-tool" } },
}, null, 2))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { new: { command: "new-tool" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(content.customSetting).toBe(true)
expect(content.version).toBe(2)
expect(content.mcpServers.new.command).toBe("new-tool")
expect(content.mcpServers.old.command).toBe("old-tool")
})
test("server name collision: plugin entry wins", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-collision-"))
const mcpPath = path.join(tempRoot, "mcp_config.json")
await fs.writeFile(mcpPath, JSON.stringify({
mcpServers: { shared: { command: "old-version" } },
}, null, 2))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { shared: { command: "new-version" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(content.mcpServers.shared.command).toBe("new-version")
})
test("mcp_config.json written with restrictive permissions", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-perms-"))
const bundle: WindsurfBundle = {
...emptyBundle,
mcpConfig: {
mcpServers: { server: { command: "tool" } },
},
}
await writeWindsurfBundle(tempRoot, bundle)
const mcpPath = path.join(tempRoot, "mcp_config.json")
const stat = await fs.stat(mcpPath)
// On Unix: 0o600 = owner read+write only. On Windows, permissions work differently.
if (process.platform !== "win32") {
const mode = stat.mode & 0o777
expect(mode).toBe(0o600)
}
})
})