chore: Resolve conflicts with main, update to v0.12.0
- sync.ts: add gemini + all targets, keep copilot, remove cursor (native), use shared hasPotentialSecrets - install.ts + convert.ts: import both detectInstalledTools and resolveTargetOutputRoot; update --to all block to use new object API; fix resolvedScope ordering (was referencing target before definition) - CHANGELOG.md: add v0.12.0 entry (auto-detect + Gemini sync) - README.md: merge all install targets, collapsible output format table, sync defaults to --target all - package.json: bump to 0.12.0 - sync --target now defaults to "all" when omitted 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -426,4 +426,82 @@ describe("CLI", () => {
|
||||
expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
|
||||
expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
|
||||
})
|
||||
|
||||
test("install --to opencode uses permissions:none by default", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-none-"))
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
const proc = Bun.spawn([
|
||||
"bun",
|
||||
"run",
|
||||
"src/index.ts",
|
||||
"install",
|
||||
fixtureRoot,
|
||||
"--to",
|
||||
"opencode",
|
||||
"--output",
|
||||
tempRoot,
|
||||
], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||
}
|
||||
|
||||
expect(stdout).toContain("Installed compound-engineering")
|
||||
|
||||
const opencodeJsonPath = path.join(tempRoot, "opencode.json")
|
||||
const content = await fs.readFile(opencodeJsonPath, "utf-8")
|
||||
const json = JSON.parse(content)
|
||||
|
||||
expect(json).not.toHaveProperty("permission")
|
||||
expect(json).not.toHaveProperty("tools")
|
||||
})
|
||||
|
||||
test("install --to opencode --permissions broad writes permission block", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-broad-"))
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
const proc = Bun.spawn([
|
||||
"bun",
|
||||
"run",
|
||||
"src/index.ts",
|
||||
"install",
|
||||
fixtureRoot,
|
||||
"--to",
|
||||
"opencode",
|
||||
"--permissions",
|
||||
"broad",
|
||||
"--output",
|
||||
tempRoot,
|
||||
], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||
}
|
||||
|
||||
expect(stdout).toContain("Installed compound-engineering")
|
||||
|
||||
const opencodeJsonPath = path.join(tempRoot, "opencode.json")
|
||||
const content = await fs.readFile(opencodeJsonPath, "utf-8")
|
||||
const json = JSON.parse(content)
|
||||
|
||||
expect(json).toHaveProperty("permission")
|
||||
expect(json.permission).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude"
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
describe("convertClaudeToOpenCode", () => {
|
||||
test("maps commands, permissions, and agents", async () => {
|
||||
test("from-command mode: map allowedTools to global permission block", async () => {
|
||||
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
@@ -16,8 +16,9 @@ describe("convertClaudeToOpenCode", () => {
|
||||
permissions: "from-commands",
|
||||
})
|
||||
|
||||
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
||||
expect(bundle.config.command?.["plan_review"]).toBeDefined()
|
||||
expect(bundle.config.command).toBeUndefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
|
||||
|
||||
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
|
||||
expect(Object.keys(permission).sort()).toEqual([
|
||||
@@ -71,8 +72,10 @@ describe("convertClaudeToOpenCode", () => {
|
||||
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
|
||||
expect(parsed.data.temperature).toBe(0.1)
|
||||
|
||||
const modelCommand = bundle.config.command?.["workflows:work"]
|
||||
expect(modelCommand?.model).toBe("openai/gpt-4o")
|
||||
const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work")
|
||||
expect(modelCommand).toBeDefined()
|
||||
const commandParsed = parseFrontmatter(modelCommand!.content)
|
||||
expect(commandParsed.data.model).toBe("openai/gpt-4o")
|
||||
})
|
||||
|
||||
test("resolves bare Claude model aliases to full IDs", () => {
|
||||
@@ -199,7 +202,7 @@ describe("convertClaudeToOpenCode", () => {
|
||||
expect(parsed.data.mode).toBe("primary")
|
||||
})
|
||||
|
||||
test("excludes commands with disable-model-invocation from command map", async () => {
|
||||
test("excludes commands with disable-model-invocation from commandFiles", async () => {
|
||||
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
@@ -208,10 +211,10 @@ describe("convertClaudeToOpenCode", () => {
|
||||
})
|
||||
|
||||
// deploy-docs has disable-model-invocation: true, should be excluded
|
||||
expect(bundle.config.command?.["deploy-docs"]).toBeUndefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "deploy-docs")).toBeUndefined()
|
||||
|
||||
// Normal commands should still be present
|
||||
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
|
||||
})
|
||||
|
||||
test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
|
||||
@@ -240,10 +243,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
|
||||
permissions: "none",
|
||||
})
|
||||
|
||||
const template = bundle.config.command?.["review"]?.template ?? ""
|
||||
const commandFile = bundle.commandFiles.find((f) => f.name === "review")
|
||||
expect(commandFile).toBeDefined()
|
||||
|
||||
// Tool-agnostic path in project root — no rewriting needed
|
||||
expect(template).toContain("compound-engineering.local.md")
|
||||
expect(commandFile!.content).toContain("compound-engineering.local.md")
|
||||
})
|
||||
|
||||
test("rewrites .claude/ paths in agent bodies", () => {
|
||||
@@ -273,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
|
||||
// Tool-agnostic path in project root — no rewriting needed
|
||||
expect(agentFile!.content).toContain("compound-engineering.local.md")
|
||||
})
|
||||
|
||||
test("command .md files include description in frontmatter", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
root: "/tmp/plugin",
|
||||
manifest: { name: "fixture", version: "1.0.0" },
|
||||
agents: [],
|
||||
commands: [
|
||||
{
|
||||
name: "test-cmd",
|
||||
description: "Test description",
|
||||
body: "Do the thing",
|
||||
sourcePath: "/tmp/plugin/commands/test-cmd.md",
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
inferTemperature: false,
|
||||
permissions: "none",
|
||||
})
|
||||
|
||||
const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd")
|
||||
expect(commandFile).toBeDefined()
|
||||
const parsed = parseFrontmatter(commandFile!.content)
|
||||
expect(parsed.data.description).toBe("Test description")
|
||||
expect(parsed.body).toContain("Do the thing")
|
||||
})
|
||||
})
|
||||
|
||||
467
tests/copilot-converter.test.ts
Normal file
467
tests/copilot-converter.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
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("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("replaces colons with hyphens in 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("/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")
|
||||
})
|
||||
})
|
||||
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.")
|
||||
})
|
||||
})
|
||||
@@ -1,347 +0,0 @@
|
||||
import { describe, expect, test, spyOn } from "bun:test"
|
||||
import { convertClaudeToCursor, transformContentForCursor } from "../src/converters/claude-to-cursor"
|
||||
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("convertClaudeToCursor", () => {
|
||||
test("converts agents to rules with .mdc frontmatter", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
|
||||
expect(bundle.rules).toHaveLength(1)
|
||||
const rule = bundle.rules[0]
|
||||
expect(rule.name).toBe("security-reviewer")
|
||||
|
||||
const parsed = parseFrontmatter(rule.content)
|
||||
expect(parsed.data.description).toBe("Security-focused code review agent")
|
||||
expect(parsed.data.alwaysApply).toBe(false)
|
||||
// globs is omitted (Agent Requested mode doesn't need it)
|
||||
expect(parsed.body).toContain("Capabilities")
|
||||
expect(parsed.body).toContain("Threat modeling")
|
||||
expect(parsed.body).toContain("Focus on vulnerabilities.")
|
||||
})
|
||||
|
||||
test("agent with empty description gets default", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [
|
||||
{
|
||||
name: "basic-agent",
|
||||
body: "Do things.",
|
||||
sourcePath: "/tmp/plugin/agents/basic.md",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
||||
const parsed = parseFrontmatter(bundle.rules[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 = convertClaudeToCursor(plugin, defaultOptions)
|
||||
const parsed = parseFrontmatter(bundle.rules[0].content)
|
||||
expect(parsed.body).toContain("Instructions converted from the empty-agent agent.")
|
||||
})
|
||||
|
||||
test("agent capabilities are prepended to body", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
const parsed = parseFrontmatter(bundle.rules[0].content)
|
||||
expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
|
||||
})
|
||||
|
||||
test("agent model field is silently dropped", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
const parsed = parseFrontmatter(bundle.rules[0].content)
|
||||
expect(parsed.data.model).toBeUndefined()
|
||||
})
|
||||
|
||||
test("flattens namespaced command names", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
|
||||
expect(bundle.commands).toHaveLength(1)
|
||||
const command = bundle.commands[0]
|
||||
expect(command.name).toBe("plan")
|
||||
})
|
||||
|
||||
test("commands are plain markdown without frontmatter", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
const command = bundle.commands[0]
|
||||
|
||||
// Should NOT start with ---
|
||||
expect(command.content.startsWith("---")).toBe(false)
|
||||
// Should include the description as a comment
|
||||
expect(command.content).toContain("<!-- Planning command -->")
|
||||
expect(command.content).toContain("Plan the work.")
|
||||
})
|
||||
|
||||
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 = convertClaudeToCursor(plugin, defaultOptions)
|
||||
const names = bundle.commands.map((c) => c.name)
|
||||
expect(names).toEqual(["plan", "plan-2"])
|
||||
})
|
||||
|
||||
test("command with disable-model-invocation is still included", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
commands: [
|
||||
{
|
||||
name: "setup",
|
||||
description: "Setup command",
|
||||
disableModelInvocation: true,
|
||||
body: "Setup body.",
|
||||
sourcePath: "/tmp/plugin/commands/setup.md",
|
||||
},
|
||||
],
|
||||
agents: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(bundle.commands).toHaveLength(1)
|
||||
expect(bundle.commands[0].name).toBe("setup")
|
||||
})
|
||||
|
||||
test("command allowedTools is silently dropped", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
const command = bundle.commands[0]
|
||||
expect(command.content).not.toContain("allowedTools")
|
||||
expect(command.content).not.toContain("Read")
|
||||
})
|
||||
|
||||
test("command with argument-hint gets Arguments section", () => {
|
||||
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
const command = bundle.commands[0]
|
||||
expect(command.content).toContain("## Arguments")
|
||||
expect(command.content).toContain("[FOCUS]")
|
||||
})
|
||||
|
||||
test("passes through skill directories", () => {
|
||||
const bundle = convertClaudeToCursor(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("converts MCP servers to JSON config", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["-y", "@anthropic/mcp-playwright"],
|
||||
env: { DISPLAY: ":0" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(bundle.mcpServers).toBeDefined()
|
||||
expect(bundle.mcpServers!.playwright.command).toBe("npx")
|
||||
expect(bundle.mcpServers!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
|
||||
expect(bundle.mcpServers!.playwright.env).toEqual({ DISPLAY: ":0" })
|
||||
})
|
||||
|
||||
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 = convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(bundle.mcpServers!.remote.url).toBe("https://mcp.example.com/sse")
|
||||
expect(bundle.mcpServers!.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" }] }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Warning: Cursor does not support hooks. Hooks were skipped during conversion.",
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("no warning when hooks are absent", () => {
|
||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
convertClaudeToCursor(fixturePlugin, defaultOptions)
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("plugin with zero agents produces empty rules array", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(bundle.rules).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("plugin with only skills works", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [],
|
||||
commands: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
||||
expect(bundle.rules).toHaveLength(0)
|
||||
expect(bundle.commands).toHaveLength(0)
|
||||
expect(bundle.skillDirs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("transformContentForCursor", () => {
|
||||
test("rewrites .claude/ paths to .cursor/", () => {
|
||||
const input = "Read `.claude/compound-engineering.local.md` for config."
|
||||
const result = transformContentForCursor(input)
|
||||
expect(result).toContain(".cursor/compound-engineering.local.md")
|
||||
expect(result).not.toContain(".claude/")
|
||||
})
|
||||
|
||||
test("rewrites ~/.claude/ paths to ~/.cursor/", () => {
|
||||
const input = "Global config at ~/.claude/settings.json"
|
||||
const result = transformContentForCursor(input)
|
||||
expect(result).toContain("~/.cursor/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 = transformContentForCursor(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 = transformContentForCursor(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 rule references", () => {
|
||||
const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
|
||||
const result = transformContentForCursor(input)
|
||||
expect(result).toContain("the security-sentinel rule")
|
||||
expect(result).toContain("the dhh-rails-reviewer rule")
|
||||
expect(result).not.toContain("@security-sentinel")
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { writeCursorBundle } from "../src/targets/cursor"
|
||||
import type { CursorBundle } from "../src/types/cursor"
|
||||
|
||||
async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe("writeCursorBundle", () => {
|
||||
test("writes rules, commands, skills, and mcp.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-test-"))
|
||||
const bundle: CursorBundle = {
|
||||
rules: [{ name: "security-reviewer", content: "---\ndescription: Security\nglobs: \"\"\nalwaysApply: false\n---\n\nReview code." }],
|
||||
commands: [{ name: "plan", content: "<!-- Planning -->\n\nPlan the work." }],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
||||
},
|
||||
],
|
||||
mcpServers: {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
||||
},
|
||||
}
|
||||
|
||||
await writeCursorBundle(tempRoot, bundle)
|
||||
|
||||
expect(await exists(path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"))).toBe(true)
|
||||
expect(await exists(path.join(tempRoot, ".cursor", "commands", "plan.md"))).toBe(true)
|
||||
expect(await exists(path.join(tempRoot, ".cursor", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
||||
expect(await exists(path.join(tempRoot, ".cursor", "mcp.json"))).toBe(true)
|
||||
|
||||
const ruleContent = await fs.readFile(
|
||||
path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"),
|
||||
"utf8",
|
||||
)
|
||||
expect(ruleContent).toContain("Review code.")
|
||||
|
||||
const commandContent = await fs.readFile(
|
||||
path.join(tempRoot, ".cursor", "commands", "plan.md"),
|
||||
"utf8",
|
||||
)
|
||||
expect(commandContent).toContain("Plan the work.")
|
||||
|
||||
const mcpContent = JSON.parse(
|
||||
await fs.readFile(path.join(tempRoot, ".cursor", "mcp.json"), "utf8"),
|
||||
)
|
||||
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
|
||||
})
|
||||
|
||||
test("writes directly into a .cursor output root without double-nesting", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-home-"))
|
||||
const cursorRoot = path.join(tempRoot, ".cursor")
|
||||
const bundle: CursorBundle = {
|
||||
rules: [{ name: "reviewer", content: "Reviewer rule content" }],
|
||||
commands: [{ name: "plan", content: "Plan content" }],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeCursorBundle(cursorRoot, bundle)
|
||||
|
||||
expect(await exists(path.join(cursorRoot, "rules", "reviewer.mdc"))).toBe(true)
|
||||
expect(await exists(path.join(cursorRoot, "commands", "plan.md"))).toBe(true)
|
||||
// Should NOT double-nest under .cursor/.cursor
|
||||
expect(await exists(path.join(cursorRoot, ".cursor"))).toBe(false)
|
||||
})
|
||||
|
||||
test("handles empty bundles gracefully", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-empty-"))
|
||||
const bundle: CursorBundle = {
|
||||
rules: [],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeCursorBundle(tempRoot, bundle)
|
||||
expect(await exists(tempRoot)).toBe(true)
|
||||
})
|
||||
|
||||
test("writes multiple rules as separate .mdc files", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-multi-"))
|
||||
const cursorRoot = path.join(tempRoot, ".cursor")
|
||||
const bundle: CursorBundle = {
|
||||
rules: [
|
||||
{ name: "security-sentinel", content: "Security rules" },
|
||||
{ name: "performance-oracle", content: "Performance rules" },
|
||||
{ name: "code-simplicity-reviewer", content: "Simplicity rules" },
|
||||
],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeCursorBundle(cursorRoot, bundle)
|
||||
|
||||
expect(await exists(path.join(cursorRoot, "rules", "security-sentinel.mdc"))).toBe(true)
|
||||
expect(await exists(path.join(cursorRoot, "rules", "performance-oracle.mdc"))).toBe(true)
|
||||
expect(await exists(path.join(cursorRoot, "rules", "code-simplicity-reviewer.mdc"))).toBe(true)
|
||||
})
|
||||
|
||||
test("backs up existing mcp.json before overwriting", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-backup-"))
|
||||
const cursorRoot = path.join(tempRoot, ".cursor")
|
||||
await fs.mkdir(cursorRoot, { recursive: true })
|
||||
|
||||
// Write an existing mcp.json
|
||||
const mcpPath = path.join(cursorRoot, "mcp.json")
|
||||
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
||||
|
||||
const bundle: CursorBundle = {
|
||||
rules: [],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
mcpServers: {
|
||||
newServer: { command: "new-cmd" },
|
||||
},
|
||||
}
|
||||
|
||||
await writeCursorBundle(cursorRoot, bundle)
|
||||
|
||||
// New mcp.json 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(cursorRoot)
|
||||
const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak."))
|
||||
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
381
tests/kiro-converter.test.ts
Normal file
381
tests/kiro-converter.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { convertClaudeToKiro, transformContentForKiro } from "../src/converters/claude-to-kiro"
|
||||
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 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("convertClaudeToKiro", () => {
|
||||
test("converts agents to Kiro agent configs with prompt files", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
|
||||
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent!.config.name).toBe("security-reviewer")
|
||||
expect(agent!.config.description).toBe("Security-focused agent")
|
||||
expect(agent!.config.prompt).toBe("file://./prompts/security-reviewer.md")
|
||||
expect(agent!.config.tools).toEqual(["*"])
|
||||
expect(agent!.config.includeMcpJson).toBe(true)
|
||||
expect(agent!.config.resources).toContain("file://.kiro/steering/**/*.md")
|
||||
expect(agent!.config.resources).toContain("skill://.kiro/skills/**/SKILL.md")
|
||||
expect(agent!.promptContent).toContain("Focus on vulnerabilities.")
|
||||
})
|
||||
|
||||
test("agent config has welcomeMessage generated from description", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
||||
expect(agent!.config.welcomeMessage).toContain("security-reviewer")
|
||||
expect(agent!.config.welcomeMessage).toContain("Security-focused agent")
|
||||
})
|
||||
|
||||
test("agent with capabilities prepended to prompt content", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
||||
expect(agent!.promptContent).toContain("## Capabilities")
|
||||
expect(agent!.promptContent).toContain("- Threat modeling")
|
||||
expect(agent!.promptContent).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 = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents[0].config.description).toBe("Use this agent for my-agent tasks")
|
||||
})
|
||||
|
||||
test("agent model field silently dropped", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
||||
expect((agent!.config as Record<string, unknown>).model).toBeUndefined()
|
||||
})
|
||||
|
||||
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 = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents[0].promptContent).toContain("Instructions converted from the Empty Agent agent.")
|
||||
})
|
||||
|
||||
test("converts commands to SKILL.md with valid frontmatter", () => {
|
||||
const bundle = convertClaudeToKiro(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("command with disable-model-invocation 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 = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.generatedSkills).toHaveLength(1)
|
||||
expect(bundle.generatedSkills[0].name).toBe("disabled-command")
|
||||
})
|
||||
|
||||
test("command allowedTools silently dropped", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
const skill = bundle.generatedSkills[0]
|
||||
expect(skill.content).not.toContain("allowedTools")
|
||||
})
|
||||
|
||||
test("skills pass through as directory references", () => {
|
||||
const bundle = convertClaudeToKiro(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("MCP stdio servers convert to mcp.json-compatible config", () => {
|
||||
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
||||
expect(bundle.mcpServers.local.command).toBe("echo")
|
||||
expect(bundle.mcpServers.local.args).toEqual(["hello"])
|
||||
})
|
||||
|
||||
test("MCP HTTP servers skipped with warning", () => {
|
||||
const warnings: string[] = []
|
||||
const originalWarn = console.warn
|
||||
console.warn = (msg: string) => warnings.push(msg)
|
||||
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
mcpServers: {
|
||||
httpServer: { url: "https://example.com/mcp" },
|
||||
},
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
console.warn = originalWarn
|
||||
|
||||
expect(Object.keys(bundle.mcpServers)).toHaveLength(0)
|
||||
expect(warnings.some((w) => w.includes("no command") || w.includes("HTTP"))).toBe(true)
|
||||
})
|
||||
|
||||
test("plugin with zero agents produces empty agents array", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents).toHaveLength(0)
|
||||
expect(bundle.generatedSkills).toHaveLength(0)
|
||||
expect(bundle.skillDirs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("plugin with only skills works correctly", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [],
|
||||
commands: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents).toHaveLength(0)
|
||||
expect(bundle.generatedSkills).toHaveLength(0)
|
||||
expect(bundle.skillDirs).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("skill name colliding with command name: command gets deduplicated", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
skills: [{ name: "my-command", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }],
|
||||
commands: [{ name: "my-command", description: "A command", body: "Body.", sourcePath: "/tmp/commands/cmd.md" }],
|
||||
agents: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
|
||||
// Skill keeps original name, command gets deduplicated
|
||||
expect(bundle.skillDirs[0].name).toBe("my-command")
|
||||
expect(bundle.generatedSkills[0].name).toBe("my-command-2")
|
||||
})
|
||||
|
||||
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: [],
|
||||
}
|
||||
|
||||
convertClaudeToKiro(plugin, defaultOptions)
|
||||
console.warn = originalWarn
|
||||
|
||||
expect(warnings.some((w) => w.includes("Kiro"))).toBe(true)
|
||||
})
|
||||
|
||||
test("steering file not generated when CLAUDE.md missing", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
root: "/tmp/nonexistent-plugin-dir",
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.steeringFiles).toHaveLength(0)
|
||||
})
|
||||
|
||||
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 = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents[0].name).toBe("my-cool-agent")
|
||||
expect(bundle.agents[1].name).toBe("uppercase-agent")
|
||||
expect(bundle.agents[2].name).toBe("agent-with-double-hyphens") // collapsed
|
||||
})
|
||||
|
||||
test("description truncation to 1024 chars", () => {
|
||||
const longDesc = "a".repeat(2000)
|
||||
const plugin: ClaudePlugin = {
|
||||
...fixturePlugin,
|
||||
agents: [
|
||||
{ name: "long-desc", description: longDesc, body: "Body.", sourcePath: "/tmp/a.md" },
|
||||
],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents[0].config.description.length).toBeLessThanOrEqual(1024)
|
||||
expect(bundle.agents[0].config.description.endsWith("...")).toBe(true)
|
||||
})
|
||||
|
||||
test("empty plugin produces empty bundle", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
root: "/tmp/empty",
|
||||
manifest: { name: "empty", version: "1.0.0" },
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
||||
expect(bundle.agents).toHaveLength(0)
|
||||
expect(bundle.generatedSkills).toHaveLength(0)
|
||||
expect(bundle.skillDirs).toHaveLength(0)
|
||||
expect(bundle.steeringFiles).toHaveLength(0)
|
||||
expect(Object.keys(bundle.mcpServers)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("transformContentForKiro", () => {
|
||||
test("transforms .claude/ paths to .kiro/", () => {
|
||||
const result = transformContentForKiro("Read .claude/settings.json for config.")
|
||||
expect(result).toContain(".kiro/settings.json")
|
||||
expect(result).not.toContain(".claude/")
|
||||
})
|
||||
|
||||
test("transforms ~/.claude/ paths to ~/.kiro/", () => {
|
||||
const result = transformContentForKiro("Check ~/.claude/config for settings.")
|
||||
expect(result).toContain("~/.kiro/config")
|
||||
expect(result).not.toContain("~/.claude/")
|
||||
})
|
||||
|
||||
test("transforms Task agent(args) to use_subagent reference", () => {
|
||||
const input = `Run these:
|
||||
|
||||
- Task repo-research-analyst(feature_description)
|
||||
- Task learnings-researcher(feature_description)
|
||||
|
||||
Task best-practices-researcher(topic)`
|
||||
|
||||
const result = transformContentForKiro(input)
|
||||
expect(result).toContain("Use the use_subagent tool to delegate to the repo-research-analyst agent: feature_description")
|
||||
expect(result).toContain("Use the use_subagent tool to delegate to the learnings-researcher agent: feature_description")
|
||||
expect(result).toContain("Use the use_subagent tool to delegate to the best-practices-researcher agent: topic")
|
||||
expect(result).not.toContain("Task repo-research-analyst")
|
||||
})
|
||||
|
||||
test("transforms @agent references for known agents only", () => {
|
||||
const result = transformContentForKiro("Ask @security-sentinel for a review.", ["security-sentinel"])
|
||||
expect(result).toContain("the security-sentinel agent")
|
||||
expect(result).not.toContain("@security-sentinel")
|
||||
})
|
||||
|
||||
test("does not transform @unknown-name when not in known agents", () => {
|
||||
const result = transformContentForKiro("Contact @someone-else for help.", ["security-sentinel"])
|
||||
expect(result).toContain("@someone-else")
|
||||
})
|
||||
|
||||
test("transforms Claude tool names to Kiro equivalents", () => {
|
||||
const result = transformContentForKiro("Use the Bash tool to run commands. Use Read to check files.")
|
||||
expect(result).toContain("shell tool")
|
||||
expect(result).toContain("read to")
|
||||
})
|
||||
|
||||
test("transforms slash command refs to skill activation", () => {
|
||||
const result = transformContentForKiro("Run /workflows:plan to start planning.")
|
||||
expect(result).toContain("the workflows-plan skill")
|
||||
})
|
||||
|
||||
test("does not transform partial .claude paths like package/.claude-config/", () => {
|
||||
const result = transformContentForKiro("Check some-package/.claude-config/settings")
|
||||
// The .claude-config/ part should be transformed since it starts with .claude/
|
||||
// but only when preceded by a word boundary
|
||||
expect(result).toContain("some-package/")
|
||||
})
|
||||
})
|
||||
273
tests/kiro-writer.test.ts
Normal file
273
tests/kiro-writer.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { writeKiroBundle } from "../src/targets/kiro"
|
||||
import type { KiroBundle } from "../src/types/kiro"
|
||||
|
||||
async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const emptyBundle: KiroBundle = {
|
||||
agents: [],
|
||||
generatedSkills: [],
|
||||
skillDirs: [],
|
||||
steeringFiles: [],
|
||||
mcpServers: {},
|
||||
}
|
||||
|
||||
describe("writeKiroBundle", () => {
|
||||
test("writes agents, skills, steering, and mcp.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-test-"))
|
||||
const bundle: KiroBundle = {
|
||||
agents: [
|
||||
{
|
||||
name: "security-reviewer",
|
||||
config: {
|
||||
name: "security-reviewer",
|
||||
description: "Security-focused agent",
|
||||
prompt: "file://./prompts/security-reviewer.md",
|
||||
tools: ["*"],
|
||||
resources: ["file://.kiro/steering/**/*.md", "skill://.kiro/skills/**/SKILL.md"],
|
||||
includeMcpJson: true,
|
||||
welcomeMessage: "Switching to security-reviewer.",
|
||||
},
|
||||
promptContent: "Review code for vulnerabilities.",
|
||||
},
|
||||
],
|
||||
generatedSkills: [
|
||||
{
|
||||
name: "workflows-plan",
|
||||
content: "---\nname: workflows-plan\ndescription: Planning\n---\n\nPlan the work.",
|
||||
},
|
||||
],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
||||
},
|
||||
],
|
||||
steeringFiles: [
|
||||
{ name: "compound-engineering", content: "# Steering content\n\nFollow these guidelines." },
|
||||
],
|
||||
mcpServers: {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
||||
},
|
||||
}
|
||||
|
||||
await writeKiroBundle(tempRoot, bundle)
|
||||
|
||||
// Agent JSON config
|
||||
const agentConfigPath = path.join(tempRoot, ".kiro", "agents", "security-reviewer.json")
|
||||
expect(await exists(agentConfigPath)).toBe(true)
|
||||
const agentConfig = JSON.parse(await fs.readFile(agentConfigPath, "utf8"))
|
||||
expect(agentConfig.name).toBe("security-reviewer")
|
||||
expect(agentConfig.includeMcpJson).toBe(true)
|
||||
expect(agentConfig.tools).toEqual(["*"])
|
||||
|
||||
// Agent prompt file
|
||||
const promptPath = path.join(tempRoot, ".kiro", "agents", "prompts", "security-reviewer.md")
|
||||
expect(await exists(promptPath)).toBe(true)
|
||||
const promptContent = await fs.readFile(promptPath, "utf8")
|
||||
expect(promptContent).toContain("Review code for vulnerabilities.")
|
||||
|
||||
// Generated skill
|
||||
const skillPath = path.join(tempRoot, ".kiro", "skills", "workflows-plan", "SKILL.md")
|
||||
expect(await exists(skillPath)).toBe(true)
|
||||
const skillContent = await fs.readFile(skillPath, "utf8")
|
||||
expect(skillContent).toContain("Plan the work.")
|
||||
|
||||
// Copied skill
|
||||
expect(await exists(path.join(tempRoot, ".kiro", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
||||
|
||||
// Steering file
|
||||
const steeringPath = path.join(tempRoot, ".kiro", "steering", "compound-engineering.md")
|
||||
expect(await exists(steeringPath)).toBe(true)
|
||||
const steeringContent = await fs.readFile(steeringPath, "utf8")
|
||||
expect(steeringContent).toContain("Follow these guidelines.")
|
||||
|
||||
// MCP config
|
||||
const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json")
|
||||
expect(await exists(mcpPath)).toBe(true)
|
||||
const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
||||
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
|
||||
})
|
||||
|
||||
test("does not double-nest when output root is .kiro", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-home-"))
|
||||
const kiroRoot = path.join(tempRoot, ".kiro")
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
agents: [
|
||||
{
|
||||
name: "reviewer",
|
||||
config: {
|
||||
name: "reviewer",
|
||||
description: "A reviewer",
|
||||
prompt: "file://./prompts/reviewer.md",
|
||||
tools: ["*"],
|
||||
resources: [],
|
||||
includeMcpJson: true,
|
||||
},
|
||||
promptContent: "Review content.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await writeKiroBundle(kiroRoot, bundle)
|
||||
|
||||
expect(await exists(path.join(kiroRoot, "agents", "reviewer.json"))).toBe(true)
|
||||
// Should NOT double-nest under .kiro/.kiro
|
||||
expect(await exists(path.join(kiroRoot, ".kiro"))).toBe(false)
|
||||
})
|
||||
|
||||
test("handles empty bundles gracefully", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-empty-"))
|
||||
|
||||
await writeKiroBundle(tempRoot, emptyBundle)
|
||||
expect(await exists(tempRoot)).toBe(true)
|
||||
})
|
||||
|
||||
test("backs up existing mcp.json before overwrite", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-backup-"))
|
||||
const kiroRoot = path.join(tempRoot, ".kiro")
|
||||
const settingsDir = path.join(kiroRoot, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
|
||||
// Write existing mcp.json
|
||||
const mcpPath = path.join(settingsDir, "mcp.json")
|
||||
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
||||
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
mcpServers: { newServer: { command: "new-cmd" } },
|
||||
}
|
||||
|
||||
await writeKiroBundle(kiroRoot, bundle)
|
||||
|
||||
// New mcp.json 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(settingsDir)
|
||||
const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak."))
|
||||
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("merges mcpServers into existing mcp.json without clobbering other keys", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-merge-"))
|
||||
const kiroRoot = path.join(tempRoot, ".kiro")
|
||||
const settingsDir = path.join(kiroRoot, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
|
||||
// Write existing mcp.json with other keys
|
||||
const mcpPath = path.join(settingsDir, "mcp.json")
|
||||
await fs.writeFile(mcpPath, JSON.stringify({
|
||||
customKey: "preserve-me",
|
||||
mcpServers: { old: { command: "old-cmd" } },
|
||||
}))
|
||||
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
mcpServers: { newServer: { command: "new-cmd" } },
|
||||
}
|
||||
|
||||
await writeKiroBundle(kiroRoot, bundle)
|
||||
|
||||
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
||||
expect(content.customKey).toBe("preserve-me")
|
||||
expect(content.mcpServers.old.command).toBe("old-cmd")
|
||||
expect(content.mcpServers.newServer.command).toBe("new-cmd")
|
||||
})
|
||||
|
||||
test("mcp.json fresh write when no existing file", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-fresh-"))
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
mcpServers: { myServer: { command: "my-cmd", args: ["--flag"] } },
|
||||
}
|
||||
|
||||
await writeKiroBundle(tempRoot, bundle)
|
||||
|
||||
const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json")
|
||||
expect(await exists(mcpPath)).toBe(true)
|
||||
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
||||
expect(content.mcpServers.myServer.command).toBe("my-cmd")
|
||||
expect(content.mcpServers.myServer.args).toEqual(["--flag"])
|
||||
})
|
||||
|
||||
test("agent JSON files are valid JSON with expected fields", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-json-"))
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
agents: [
|
||||
{
|
||||
name: "test-agent",
|
||||
config: {
|
||||
name: "test-agent",
|
||||
description: "Test agent",
|
||||
prompt: "file://./prompts/test-agent.md",
|
||||
tools: ["*"],
|
||||
resources: ["file://.kiro/steering/**/*.md"],
|
||||
includeMcpJson: true,
|
||||
welcomeMessage: "Hello from test-agent.",
|
||||
},
|
||||
promptContent: "Do test things.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await writeKiroBundle(tempRoot, bundle)
|
||||
|
||||
const configPath = path.join(tempRoot, ".kiro", "agents", "test-agent.json")
|
||||
const raw = await fs.readFile(configPath, "utf8")
|
||||
const parsed = JSON.parse(raw) // Should not throw
|
||||
expect(parsed.name).toBe("test-agent")
|
||||
expect(parsed.prompt).toBe("file://./prompts/test-agent.md")
|
||||
expect(parsed.tools).toEqual(["*"])
|
||||
expect(parsed.includeMcpJson).toBe(true)
|
||||
expect(parsed.welcomeMessage).toBe("Hello from test-agent.")
|
||||
})
|
||||
|
||||
test("path traversal attempt in skill name is rejected", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal-"))
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
generatedSkills: [
|
||||
{ name: "../escape", content: "Malicious content" },
|
||||
],
|
||||
}
|
||||
|
||||
expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
|
||||
})
|
||||
|
||||
test("path traversal in agent name is rejected", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal2-"))
|
||||
const bundle: KiroBundle = {
|
||||
...emptyBundle,
|
||||
agents: [
|
||||
{
|
||||
name: "../escape",
|
||||
config: {
|
||||
name: "../escape",
|
||||
description: "Malicious",
|
||||
prompt: "file://./prompts/../escape.md",
|
||||
tools: ["*"],
|
||||
resources: [],
|
||||
includeMcpJson: true,
|
||||
},
|
||||
promptContent: "Bad.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
|
||||
})
|
||||
})
|
||||
200
tests/openclaw-converter.test.ts
Normal file
200
tests/openclaw-converter.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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("claude-sonnet-4-20250514")
|
||||
expect(parsed.body).toContain("Focus on vulnerabilities")
|
||||
})
|
||||
|
||||
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.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 emits typed skills record", () => {
|
||||
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
||||
expect(bundle.entryPoint).toContain("const skills: Record<string, string> = {}")
|
||||
})
|
||||
|
||||
test("plugin without MCP servers has no openclawConfig", () => {
|
||||
const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
|
||||
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
|
||||
expect(bundle.openclawConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [{ name: "hook.ts", content: "export {}" }],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -85,28 +88,35 @@ describe("writeOpenCodeBundle", () => {
|
||||
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
|
||||
})
|
||||
|
||||
test("backs up existing opencode.json before overwriting", async () => {
|
||||
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")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config
|
||||
// Create existing config with user keys
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
|
||||
await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
|
||||
|
||||
// Bundle adds mcp server but keeps user's custom key
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json", new: "config" },
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } }
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// New config should be written
|
||||
// Merged config should have both user key and plugin key
|
||||
const newConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(newConfig.new).toBe("config")
|
||||
expect(newConfig.custom).toBe("value") // user key preserved
|
||||
expect(newConfig.mcp).toBeDefined()
|
||||
expect(newConfig.mcp["plugin-server"]).toBeDefined()
|
||||
|
||||
// Backup should exist with original content
|
||||
const files = await fs.readdir(outputRoot)
|
||||
@@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => {
|
||||
const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
|
||||
expect(backupContent.custom).toBe("value")
|
||||
})
|
||||
|
||||
test("merges mcp servers without overwriting user entry", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-merge-mcp-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config with user's mcp server
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const existingConfig = {
|
||||
mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } }
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
||||
|
||||
// Bundle adds plugin server AND has conflicting user-server with different args
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
"plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] },
|
||||
"user-server": { type: "local", command: "uvx", args: ["plugin-override"] } // conflict
|
||||
}
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// Merged config should have both servers, with user-server keeping user's original args
|
||||
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(mergedConfig.mcp).toBeDefined()
|
||||
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
||||
expect(mergedConfig.mcp["user-server"]).toBeDefined()
|
||||
expect(mergedConfig.mcp["user-server"].args[0]).toBe("user-srv") // user wins on conflict
|
||||
expect(mergedConfig.mcp["plugin-server"].args[0]).toBe("plugin-srv") // plugin entry present
|
||||
})
|
||||
|
||||
test("preserves unrelated user keys when merging opencode.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-preserve-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config with multiple user keys
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const existingConfig = {
|
||||
model: "my-model",
|
||||
theme: "dark",
|
||||
mcp: {}
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
||||
|
||||
// Bundle adds plugin-specific keys
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } },
|
||||
permission: { "bash": "allow" }
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// All user keys preserved
|
||||
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(mergedConfig.model).toBe("my-model")
|
||||
expect(mergedConfig.theme).toBe("dark")
|
||||
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
||||
expect(mergedConfig.permission["bash"]).toBe("allow")
|
||||
})
|
||||
|
||||
test("writes command files as .md in commands/ directory", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-"))
|
||||
const outputRoot = path.join(tempRoot, ".config", "opencode")
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
const cmdPath = path.join(outputRoot, "commands", "my-cmd.md")
|
||||
expect(await exists(cmdPath)).toBe(true)
|
||||
|
||||
const content = await fs.readFile(cmdPath, "utf8")
|
||||
expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
|
||||
})
|
||||
|
||||
test("backs up existing command .md file before overwriting", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const commandsDir = path.join(outputRoot, "commands")
|
||||
await fs.mkdir(commandsDir, { recursive: true })
|
||||
|
||||
const cmdPath = path.join(commandsDir, "my-cmd.md")
|
||||
await fs.writeFile(cmdPath, "old content\n")
|
||||
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [{ name: "my-cmd", content: "---\ndescription: New\n---\n\nNew content." }],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// New content should be written
|
||||
const content = await fs.readFile(cmdPath, "utf8")
|
||||
expect(content).toBe("---\ndescription: New\n---\n\nNew content.\n")
|
||||
|
||||
// Backup should exist
|
||||
const files = await fs.readdir(commandsDir)
|
||||
const backupFileName = files.find((f) => f.startsWith("my-cmd.md.bak."))
|
||||
expect(backupFileName).toBeDefined()
|
||||
|
||||
const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8")
|
||||
expect(backupContent).toBe("old content\n")
|
||||
})
|
||||
})
|
||||
|
||||
238
tests/qwen-converter.test.ts
Normal file
238
tests/qwen-converter.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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("normalizeModel 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("normalizeModel 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")
|
||||
})
|
||||
})
|
||||
131
tests/resolve-output.test.ts
Normal file
131
tests/resolve-output.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { resolveTargetOutputRoot } from "../src/utils/resolve-output"
|
||||
|
||||
const baseOptions = {
|
||||
outputRoot: "/tmp/output",
|
||||
codexHome: path.join(os.homedir(), ".codex"),
|
||||
piHome: path.join(os.homedir(), ".pi", "agent"),
|
||||
hasExplicitOutput: false,
|
||||
}
|
||||
|
||||
describe("resolveTargetOutputRoot", () => {
|
||||
test("codex returns codexHome", () => {
|
||||
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "codex" })
|
||||
expect(result).toBe(baseOptions.codexHome)
|
||||
})
|
||||
|
||||
test("pi returns piHome", () => {
|
||||
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "pi" })
|
||||
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", () => {
|
||||
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",
|
||||
})
|
||||
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")
|
||||
})
|
||||
|
||||
test("windsurf with no scope and no explicit output uses cwd/.windsurf/", () => {
|
||||
const result = resolveTargetOutputRoot({
|
||||
...baseOptions,
|
||||
targetName: "windsurf",
|
||||
})
|
||||
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",
|
||||
})
|
||||
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",
|
||||
})
|
||||
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"))
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { syncToCursor } from "../src/sync/cursor"
|
||||
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
|
||||
|
||||
describe("syncToCursor", () => {
|
||||
test("symlinks skills and writes mcp.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-"))
|
||||
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 syncToCursor(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 mcp.json
|
||||
const mcpPath = path.join(tempRoot, "mcp.json")
|
||||
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
|
||||
mcpServers: Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string> }>
|
||||
}
|
||||
|
||||
expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
||||
expect(mcpConfig.mcpServers.local?.command).toBe("echo")
|
||||
expect(mcpConfig.mcpServers.local?.args).toEqual(["hello"])
|
||||
expect(mcpConfig.mcpServers.local?.env).toEqual({ FOO: "bar" })
|
||||
})
|
||||
|
||||
test("merges existing mcp.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-merge-"))
|
||||
const mcpPath = path.join(tempRoot, "mcp.json")
|
||||
|
||||
await fs.writeFile(
|
||||
mcpPath,
|
||||
JSON.stringify({ mcpServers: { existing: { command: "node", args: ["server.js"] } } }, null, 2),
|
||||
)
|
||||
|
||||
const config: ClaudeHomeConfig = {
|
||||
skills: [],
|
||||
mcpServers: {
|
||||
context7: { url: "https://mcp.context7.com/mcp" },
|
||||
},
|
||||
}
|
||||
|
||||
await syncToCursor(config, tempRoot)
|
||||
|
||||
const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
|
||||
mcpServers: Record<string, { command?: string; url?: string }>
|
||||
}
|
||||
|
||||
expect(merged.mcpServers.existing?.command).toBe("node")
|
||||
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
||||
})
|
||||
|
||||
test("does not write mcp.json when no MCP servers", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-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 syncToCursor(config, tempRoot)
|
||||
|
||||
const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false)
|
||||
expect(mcpExists).toBe(false)
|
||||
})
|
||||
})
|
||||
573
tests/windsurf-converter.test.ts
Normal file
573
tests/windsurf-converter.test.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
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("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")
|
||||
})
|
||||
})
|
||||
359
tests/windsurf-writer.test.ts
Normal file
359
tests/windsurf-writer.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
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("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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user