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).toBeUndefined() expect(parsed.data.infer).toBeUndefined() expect(parsed.data["user-invocable"]).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("model field is dropped (Copilot model format differs from Claude model IDs)", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) expect(parsed.data.model).toBeUndefined() }) test("agent omits tools (Copilot uses defaults when omitted)", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) expect(parsed.data.tools).toBeUndefined() }) test("agent replaces infer with user-invocable", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) expect(parsed.data.infer).toBeUndefined() expect(parsed.data["user-invocable"]).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("workflows-plan") const parsed = parseFrontmatter(skill.content) expect(parsed.data.name).toBe("workflows-plan") expect(parsed.data.description).toBe("Planning command") expect(parsed.body).toContain("Plan the work.") }) test("preserves namespaced command names with hyphens", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) expect(bundle.generatedSkills[0].name).toBe("workflows-plan") }) test("command name collision after normalization is deduplicated", () => { const plugin: ClaudePlugin = { ...fixturePlugin, commands: [ { name: "workflows:plan", description: "Workflow plan", body: "Plan body.", sourcePath: "/tmp/plugin/commands/workflows/plan.md", }, { name: "workflows:plan", description: "Duplicate plan", body: "Duplicate body.", sourcePath: "/tmp/plugin/commands/workflows/plan2.md", }, ], agents: [], skills: [], } const bundle = convertClaudeToCopilot(plugin, defaultOptions) const names = bundle.generatedSkills.map((s) => s.name) expect(names).toEqual(["workflows-plan", "workflows-plan-2"]) }) test("namespaced and non-namespaced commands produce distinct names", () => { 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(["workflows-plan", "plan"]) }) 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("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 = transformContentForCopilot(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).not.toContain("compound-engineering:") }) test("transforms zero-argument Task calls", () => { const input = `- Task compound-engineering:review:code-simplicity-reviewer()` const result = transformContentForCopilot(input) expect(result).toContain("Use the code-simplicity-reviewer skill") expect(result).not.toContain("compound-engineering:") expect(result).not.toContain("skill to:") }) test("replaces colons with hyphens in slash commands", () => { const input = `1. Run /todo-resolve to enhance 2. Start /workflows:work to implement 3. File at /tmp/output.md` const result = transformContentForCopilot(input) expect(result).toContain("/todo-resolve") expect(result).toContain("/workflows-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") }) test("replaces ce: namespace with ce- in body text", () => { const input = "prefer ce:brainstorm first. Then run ce:plan and ce:review. Use ce:* skills." const result = transformContentForCopilot(input) expect(result).toBe("prefer ce-brainstorm first. Then run ce-plan and ce-review. Use ce-* skills.") expect(result).not.toContain("ce:") }) test("replaces multi-colon ce: references fully", () => { const input = "run ce:work:beta and ce:review:deep" const result = transformContentForCopilot(input) expect(result).toBe("run ce-work-beta and ce-review-deep") expect(result).not.toContain(":") }) test("ce: replacement does not corrupt non-command patterns", () => { const input = "Use source: explicit and Confidence: high. See https://example.com/ace:thing" const result = transformContentForCopilot(input) expect(result).toContain("source: explicit") expect(result).toContain("Confidence: high") expect(result).toContain("ace:thing") }) test("ce: replacement does not corrupt URLs", () => { const input = "See https://example.com/ce:plan and http://docs.example.com/ce:review/overview" const result = transformContentForCopilot(input) expect(result).toContain("https://example.com/ce:plan") expect(result).toContain("http://docs.example.com/ce:review/overview") }) test("generated skill deduplicates against sanitized pass-through skill names", () => { const plugin: ClaudePlugin = { ...fixturePlugin, agents: [], commands: [ { name: "ce:plan", description: "Planning command", model: "inherit", allowedTools: [], body: "Plan the work.", sourcePath: "/tmp/plugin/commands/ce-plan.md", }, ], skills: [ { name: "ce:plan", description: "Planning skill", sourceDir: "/tmp/plugin/skills/ce-plan", skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md", }, ], } const bundle = convertClaudeToCopilot(plugin, defaultOptions) // The generated skill from the command should get a deduplicated name // since "ce:plan" and "ce-plan" both map to "ce-plan" on disk expect(bundle.generatedSkills[0].name).not.toBe("ce-plan") }) })