Files
claude-engineering-plugin/tests/copilot-converter.test.ts
Brayan Jules 4f7c598f27 feat: Add GitHub Copilot converter target
Add Copilot as the 6th converter target, transforming Claude Code plugins
into Copilot's native format: custom agents (.agent.md), agent skills
(SKILL.md), and MCP server configuration JSON.

Component mapping:
- Agents → .github/agents/{name}.agent.md (with Copilot frontmatter)
- Commands → .github/skills/{name}/SKILL.md
- Skills → .github/skills/{name}/ (copied as-is)
- MCP servers → .github/copilot-mcp-config.json
- Hooks → skipped with warning

Also adds `compound sync copilot` support and fixes YAML quoting for
the `*` character in frontmatter serialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:14:40 -03:00

442 lines
14 KiB
TypeScript

import { describe, expect, test, spyOn } from "bun:test"
import { convertClaudeToCopilot, transformContentForCopilot } from "../src/converters/claude-to-copilot"
import { parseFrontmatter } from "../src/utils/frontmatter"
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 code review 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: undefined,
}
const defaultOptions = {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
}
describe("convertClaudeToCopilot", () => {
test("converts agents to .agent.md with Copilot frontmatter", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
expect(bundle.agents).toHaveLength(1)
const agent = bundle.agents[0]
expect(agent.name).toBe("security-reviewer")
const parsed = parseFrontmatter(agent.content)
expect(parsed.data.description).toBe("Security-focused code review agent")
expect(parsed.data.tools).toEqual(["*"])
expect(parsed.data.infer).toBe(true)
expect(parsed.body).toContain("Capabilities")
expect(parsed.body).toContain("Threat modeling")
expect(parsed.body).toContain("Focus on vulnerabilities.")
})
test("agent description is required, fallback generated if missing", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "basic-agent",
body: "Do things.",
sourcePath: "/tmp/plugin/agents/basic.md",
},
],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.description).toBe("Converted from Claude agent basic-agent")
})
test("agent with empty body gets default body", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "empty-agent",
description: "Empty agent",
body: "",
sourcePath: "/tmp/plugin/agents/empty.md",
},
],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.body).toContain("Instructions converted from the empty-agent agent.")
})
test("agent capabilities are prepended to body", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
})
test("agent model field is passed through", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBe("claude-sonnet-4-20250514")
})
test("agent without model omits model field", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "no-model",
description: "No model agent",
body: "Content.",
sourcePath: "/tmp/plugin/agents/no-model.md",
},
],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.model).toBeUndefined()
})
test("agent tools defaults to [*]", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.tools).toEqual(["*"])
})
test("agent infer defaults to true", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.agents[0].content)
expect(parsed.data.infer).toBe(true)
})
test("warns when agent body exceeds 30k characters", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "large-agent",
description: "Large agent",
body: "x".repeat(31_000),
sourcePath: "/tmp/plugin/agents/large.md",
},
],
commands: [],
skills: [],
}
convertClaudeToCopilot(plugin, defaultOptions)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("exceeds 30000 characters"),
)
warnSpy.mockRestore()
})
test("converts commands to skills with SKILL.md format", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
expect(bundle.generatedSkills).toHaveLength(1)
const skill = bundle.generatedSkills[0]
expect(skill.name).toBe("plan")
const parsed = parseFrontmatter(skill.content)
expect(parsed.data.name).toBe("plan")
expect(parsed.data.description).toBe("Planning command")
expect(parsed.body).toContain("Plan the work.")
})
test("flattens namespaced command names", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
expect(bundle.generatedSkills[0].name).toBe("plan")
})
test("command name collision after flattening is deduplicated", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "workflows:plan",
description: "Workflow plan",
body: "Plan body.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
{
name: "plan",
description: "Top-level plan",
body: "Top plan body.",
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
const names = bundle.generatedSkills.map((s) => s.name)
expect(names).toEqual(["plan", "plan-2"])
})
test("command allowedTools is silently dropped", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const skill = bundle.generatedSkills[0]
expect(skill.content).not.toContain("allowedTools")
expect(skill.content).not.toContain("allowed-tools")
})
test("command with argument-hint gets Arguments section", () => {
const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
const skill = bundle.generatedSkills[0]
expect(skill.content).toContain("## Arguments")
expect(skill.content).toContain("[FOCUS]")
})
test("passes through skill directories", () => {
const bundle = convertClaudeToCopilot(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("skill and generated skill name collision is deduplicated", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "existing-skill",
description: "Colliding command",
body: "This collides with skill name.",
sourcePath: "/tmp/plugin/commands/existing-skill.md",
},
],
agents: [],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
// The command should get deduplicated since the skill name is reserved
expect(bundle.generatedSkills[0].name).toBe("existing-skill-2")
expect(bundle.skillDirs[0].name).toBe("existing-skill")
})
test("converts MCP servers with COPILOT_MCP_ prefix", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
playwright: {
command: "npx",
args: ["-y", "@anthropic/mcp-playwright"],
env: { DISPLAY: ":0", API_KEY: "secret" },
},
},
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.mcpConfig).toBeDefined()
expect(bundle.mcpConfig!.playwright.type).toBe("local")
expect(bundle.mcpConfig!.playwright.command).toBe("npx")
expect(bundle.mcpConfig!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
expect(bundle.mcpConfig!.playwright.tools).toEqual(["*"])
expect(bundle.mcpConfig!.playwright.env).toEqual({
COPILOT_MCP_DISPLAY: ":0",
COPILOT_MCP_API_KEY: "secret",
})
})
test("MCP env vars already prefixed are not double-prefixed", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
server: {
command: "node",
args: ["server.js"],
env: { COPILOT_MCP_TOKEN: "abc" },
},
},
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.mcpConfig!.server.env).toEqual({ COPILOT_MCP_TOKEN: "abc" })
})
test("MCP servers get type field (local vs sse)", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
local: { command: "npx", args: ["server"] },
remote: { url: "https://mcp.example.com/sse" },
},
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.mcpConfig!.local.type).toBe("local")
expect(bundle.mcpConfig!.remote.type).toBe("sse")
})
test("MCP headers pass through for remote servers", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
remote: {
url: "https://mcp.example.com/sse",
headers: { Authorization: "Bearer token" },
},
},
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.mcpConfig!.remote.url).toBe("https://mcp.example.com/sse")
expect(bundle.mcpConfig!.remote.headers).toEqual({ Authorization: "Bearer token" })
})
test("warns when hooks are present", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
hooks: {
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }],
},
},
}
convertClaudeToCopilot(plugin, defaultOptions)
expect(warnSpy).toHaveBeenCalledWith(
"Warning: Copilot does not support hooks. Hooks were skipped during conversion.",
)
warnSpy.mockRestore()
})
test("no warning when hooks are absent", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
convertClaudeToCopilot(fixturePlugin, defaultOptions)
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
test("plugin with zero agents produces empty agents array", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.agents).toHaveLength(0)
})
test("plugin with only skills works", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
expect(bundle.agents).toHaveLength(0)
expect(bundle.generatedSkills).toHaveLength(0)
expect(bundle.skillDirs).toHaveLength(1)
})
})
describe("transformContentForCopilot", () => {
test("rewrites .claude/ paths to .github/", () => {
const input = "Read `.claude/compound-engineering.local.md` for config."
const result = transformContentForCopilot(input)
expect(result).toContain(".github/compound-engineering.local.md")
expect(result).not.toContain(".claude/")
})
test("rewrites ~/.claude/ paths to ~/.copilot/", () => {
const input = "Global config at ~/.claude/settings.json"
const result = transformContentForCopilot(input)
expect(result).toContain("~/.copilot/settings.json")
expect(result).not.toContain("~/.claude/")
})
test("transforms Task agent calls to skill references", () => {
const input = `Run agents:
- Task repo-research-analyst(feature_description)
- Task learnings-researcher(feature_description)
Task best-practices-researcher(topic)`
const result = transformContentForCopilot(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).not.toContain("Task repo-research-analyst(")
})
test("flattens slash commands", () => {
const input = `1. Run /deepen-plan to enhance
2. Start /workflows:work to implement
3. File at /tmp/output.md`
const result = transformContentForCopilot(input)
expect(result).toContain("/deepen-plan")
expect(result).toContain("/work")
expect(result).not.toContain("/workflows:work")
// File paths preserved
expect(result).toContain("/tmp/output.md")
})
test("transforms @agent references to agent references", () => {
const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
const result = transformContentForCopilot(input)
expect(result).toContain("the security-sentinel agent")
expect(result).toContain("the dhh-rails-reviewer agent")
expect(result).not.toContain("@security-sentinel")
})
})