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>
This commit is contained in:
441
tests/copilot-converter.test.ts
Normal file
441
tests/copilot-converter.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
189
tests/copilot-writer.test.ts
Normal file
189
tests/copilot-writer.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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\ntools:\n - '*'\ninfer: 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("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.")
|
||||
})
|
||||
})
|
||||
148
tests/sync-copilot.test.ts
Normal file
148
tests/sync-copilot.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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("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, "copilot-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")
|
||||
})
|
||||
|
||||
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, "copilot-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, "copilot-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, "copilot-mcp-config.json")).then(() => true).catch(() => false)
|
||||
expect(mcpExists).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user