@every-env/compound-plugin 0.8.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +5 -1
  3. package/CHANGELOG.md +50 -0
  4. package/CLAUDE.md +3 -3
  5. package/README.md +52 -14
  6. package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
  7. package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
  8. package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
  9. package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
  10. package/docs/solutions/adding-converter-target-providers.md +692 -0
  11. package/docs/solutions/plugin-versioning-requirements.md +3 -3
  12. package/docs/specs/kiro.md +171 -0
  13. package/docs/specs/windsurf.md +477 -0
  14. package/package.json +1 -1
  15. package/plans/landing-page-launchkit-refresh.md +2 -2
  16. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  17. package/plugins/compound-engineering/CHANGELOG.md +72 -1
  18. package/plugins/compound-engineering/CLAUDE.md +9 -7
  19. package/plugins/compound-engineering/README.md +10 -7
  20. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  21. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  22. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  23. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  24. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  25. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  26. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  27. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  28. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  29. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  30. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  31. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  32. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  33. package/plugins/compound-engineering/commands/lfg.md +3 -3
  34. package/plugins/compound-engineering/commands/slfg.md +3 -3
  35. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  36. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  37. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  38. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  39. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  40. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  41. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  42. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  43. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  44. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  45. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  46. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  47. package/plugins/compound-engineering/skills/setup/SKILL.md +2 -2
  48. package/src/commands/convert.ts +101 -23
  49. package/src/commands/install.ts +102 -41
  50. package/src/commands/sync.ts +58 -38
  51. package/src/converters/claude-to-kiro.ts +262 -0
  52. package/src/converters/claude-to-openclaw.ts +240 -0
  53. package/src/converters/claude-to-opencode.ts +12 -10
  54. package/src/converters/claude-to-qwen.ts +238 -0
  55. package/src/converters/claude-to-windsurf.ts +205 -0
  56. package/src/sync/gemini.ts +76 -0
  57. package/src/targets/index.ts +69 -1
  58. package/src/targets/kiro.ts +122 -0
  59. package/src/targets/openclaw.ts +96 -0
  60. package/src/targets/opencode.ts +76 -10
  61. package/src/targets/qwen.ts +64 -0
  62. package/src/targets/windsurf.ts +104 -0
  63. package/src/types/kiro.ts +44 -0
  64. package/src/types/openclaw.ts +52 -0
  65. package/src/types/opencode.ts +7 -8
  66. package/src/types/qwen.ts +48 -0
  67. package/src/types/windsurf.ts +34 -0
  68. package/src/utils/detect-tools.ts +46 -0
  69. package/src/utils/files.ts +7 -0
  70. package/src/utils/resolve-output.ts +50 -0
  71. package/src/utils/secrets.ts +24 -0
  72. package/tests/cli.test.ts +78 -0
  73. package/tests/converter.test.ts +43 -10
  74. package/tests/detect-tools.test.ts +96 -0
  75. package/tests/kiro-converter.test.ts +381 -0
  76. package/tests/kiro-writer.test.ts +273 -0
  77. package/tests/openclaw-converter.test.ts +200 -0
  78. package/tests/opencode-writer.test.ts +142 -5
  79. package/tests/qwen-converter.test.ts +238 -0
  80. package/tests/resolve-output.test.ts +131 -0
  81. package/tests/sync-gemini.test.ts +106 -0
  82. package/tests/windsurf-converter.test.ts +573 -0
  83. package/tests/windsurf-writer.test.ts +359 -0
  84. package/docs/css/docs.css +0 -675
  85. package/docs/css/style.css +0 -2886
  86. package/docs/index.html +0 -1046
  87. package/docs/js/main.js +0 -225
  88. package/docs/pages/agents.html +0 -649
  89. package/docs/pages/changelog.html +0 -534
  90. package/docs/pages/commands.html +0 -523
  91. package/docs/pages/getting-started.html +0 -582
  92. package/docs/pages/mcp-servers.html +0 -409
  93. package/docs/pages/skills.html +0 -611
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { convertClaudeToOpenClaw } from "../src/converters/claude-to-openclaw"
3
+ import { parseFrontmatter } from "../src/utils/frontmatter"
4
+ import type { ClaudePlugin } from "../src/types/claude"
5
+
6
+ const fixturePlugin: ClaudePlugin = {
7
+ root: "/tmp/plugin",
8
+ manifest: { name: "compound-engineering", version: "1.0.0", description: "A plugin" },
9
+ agents: [
10
+ {
11
+ name: "security-reviewer",
12
+ description: "Security-focused agent",
13
+ capabilities: ["Threat modeling", "OWASP"],
14
+ model: "claude-sonnet-4-20250514",
15
+ body: "Focus on vulnerabilities in ~/.claude/settings.",
16
+ sourcePath: "/tmp/plugin/agents/security-reviewer.md",
17
+ },
18
+ ],
19
+ commands: [
20
+ {
21
+ name: "workflows:plan",
22
+ description: "Planning command",
23
+ argumentHint: "[FOCUS]",
24
+ model: "inherit",
25
+ allowedTools: ["Read"],
26
+ body: "Plan the work. See ~/.claude/settings for config.",
27
+ sourcePath: "/tmp/plugin/commands/workflows/plan.md",
28
+ },
29
+ {
30
+ name: "disabled-cmd",
31
+ description: "Disabled command",
32
+ model: "inherit",
33
+ allowedTools: [],
34
+ body: "Should be excluded.",
35
+ disableModelInvocation: true,
36
+ sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
37
+ },
38
+ ],
39
+ skills: [
40
+ {
41
+ name: "existing-skill",
42
+ description: "Existing skill",
43
+ sourceDir: "/tmp/plugin/skills/existing-skill",
44
+ skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
45
+ },
46
+ ],
47
+ hooks: undefined,
48
+ mcpServers: {
49
+ local: { command: "npx", args: ["-y", "some-mcp-server"] },
50
+ remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
51
+ },
52
+ }
53
+
54
+ const defaultOptions = {
55
+ agentMode: "subagent" as const,
56
+ inferTemperature: false,
57
+ permissions: "none" as const,
58
+ }
59
+
60
+ describe("convertClaudeToOpenClaw", () => {
61
+ test("converts agents to skill files with SKILL.md content", () => {
62
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
63
+
64
+ const skill = bundle.skills.find((s) => s.name === "security-reviewer")
65
+ expect(skill).toBeDefined()
66
+ expect(skill!.dir).toBe("agent-security-reviewer")
67
+ const parsed = parseFrontmatter(skill!.content)
68
+ expect(parsed.data.name).toBe("security-reviewer")
69
+ expect(parsed.data.description).toBe("Security-focused agent")
70
+ expect(parsed.data.model).toBe("claude-sonnet-4-20250514")
71
+ expect(parsed.body).toContain("Focus on vulnerabilities")
72
+ })
73
+
74
+ test("converts commands to skill files (excluding disableModelInvocation)", () => {
75
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
76
+
77
+ const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
78
+ expect(cmdSkill).toBeDefined()
79
+ expect(cmdSkill!.dir).toBe("cmd-workflows:plan")
80
+
81
+ const disabledSkill = bundle.skills.find((s) => s.name === "disabled-cmd")
82
+ expect(disabledSkill).toBeUndefined()
83
+ })
84
+
85
+ test("commands list excludes disableModelInvocation commands", () => {
86
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
87
+
88
+ const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
89
+ expect(cmd).toBeDefined()
90
+ expect(cmd!.description).toBe("Planning command")
91
+ expect(cmd!.acceptsArgs).toBe(true)
92
+
93
+ const disabled = bundle.commands.find((c) => c.name === "disabled-cmd")
94
+ expect(disabled).toBeUndefined()
95
+ })
96
+
97
+ test("command colons are replaced with dashes in command registrations", () => {
98
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
99
+
100
+ const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
101
+ expect(cmd).toBeDefined()
102
+ expect(cmd!.name).not.toContain(":")
103
+ })
104
+
105
+ test("manifest includes plugin id, display name, and skills list", () => {
106
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
107
+
108
+ expect(bundle.manifest.id).toBe("compound-engineering")
109
+ expect(bundle.manifest.name).toBe("Compound Engineering")
110
+ expect(bundle.manifest.kind).toBe("tool")
111
+ expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer")
112
+ expect(bundle.manifest.skills).toContain("skills/cmd-workflows:plan")
113
+ expect(bundle.manifest.skills).toContain("skills/existing-skill")
114
+ })
115
+
116
+ test("package.json uses plugin name and version", () => {
117
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
118
+
119
+ expect(bundle.packageJson.name).toBe("openclaw-compound-engineering")
120
+ expect(bundle.packageJson.version).toBe("1.0.0")
121
+ expect(bundle.packageJson.type).toBe("module")
122
+ })
123
+
124
+ test("skillDirCopies includes original skill directories", () => {
125
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
126
+
127
+ const copy = bundle.skillDirCopies.find((s) => s.name === "existing-skill")
128
+ expect(copy).toBeDefined()
129
+ expect(copy!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
130
+ })
131
+
132
+ test("stdio MCP servers included in openclaw config", () => {
133
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
134
+
135
+ expect(bundle.openclawConfig).toBeDefined()
136
+ const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
137
+ expect(mcp.local).toBeDefined()
138
+ expect((mcp.local as any).type).toBe("stdio")
139
+ expect((mcp.local as any).command).toBe("npx")
140
+ })
141
+
142
+ test("HTTP MCP servers included as http type in openclaw config", () => {
143
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
144
+
145
+ const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
146
+ expect(mcp.remote).toBeDefined()
147
+ expect((mcp.remote as any).type).toBe("http")
148
+ expect((mcp.remote as any).url).toBe("https://mcp.example.com/api")
149
+ })
150
+
151
+ test("paths are rewritten from .claude/ to .openclaw/ in skill content", () => {
152
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
153
+
154
+ const agentSkill = bundle.skills.find((s) => s.name === "security-reviewer")
155
+ expect(agentSkill!.content).toContain("~/.openclaw/settings")
156
+ expect(agentSkill!.content).not.toContain("~/.claude/settings")
157
+
158
+ const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
159
+ expect(cmdSkill!.content).toContain("~/.openclaw/settings")
160
+ expect(cmdSkill!.content).not.toContain("~/.claude/settings")
161
+ })
162
+
163
+ test("generateEntryPoint uses JSON.stringify for safe string escaping", () => {
164
+ const plugin: ClaudePlugin = {
165
+ ...fixturePlugin,
166
+ commands: [
167
+ {
168
+ name: "tricky-cmd",
169
+ description: 'Has "quotes" and \\backslashes\\ and\nnewlines',
170
+ model: "inherit",
171
+ allowedTools: [],
172
+ body: "body",
173
+ sourcePath: "/tmp/cmd.md",
174
+ },
175
+ ],
176
+ }
177
+ const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
178
+
179
+ // Entry point must be valid JS/TS — JSON.stringify handles all special chars
180
+ expect(bundle.entryPoint).toContain('"tricky-cmd"')
181
+ expect(bundle.entryPoint).toContain('\\"quotes\\"')
182
+ expect(bundle.entryPoint).toContain("\\\\backslashes\\\\")
183
+ expect(bundle.entryPoint).toContain("\\n")
184
+ // No raw unescaped newline inside a string literal
185
+ const lines = bundle.entryPoint.split("\n")
186
+ const nameLine = lines.find((l) => l.includes("tricky-cmd") && l.includes("name:"))
187
+ expect(nameLine).toBeDefined()
188
+ })
189
+
190
+ test("generateEntryPoint emits typed skills record", () => {
191
+ const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
192
+ expect(bundle.entryPoint).toContain("const skills: Record<string, string> = {}")
193
+ })
194
+
195
+ test("plugin without MCP servers has no openclawConfig", () => {
196
+ const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
197
+ const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
198
+ expect(bundle.openclawConfig).toBeUndefined()
199
+ })
200
+ })
@@ -21,6 +21,7 @@ describe("writeOpenCodeBundle", () => {
21
21
  config: { $schema: "https://opencode.ai/config.json" },
22
22
  agents: [{ name: "agent-one", content: "Agent content" }],
23
23
  plugins: [{ name: "hook.ts", content: "export {}" }],
24
+ commandFiles: [],
24
25
  skillDirs: [
25
26
  {
26
27
  name: "skill-one",
@@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => {
44
45
  config: { $schema: "https://opencode.ai/config.json" },
45
46
  agents: [{ name: "agent-one", content: "Agent content" }],
46
47
  plugins: [],
48
+ commandFiles: [],
47
49
  skillDirs: [
48
50
  {
49
51
  name: "skill-one",
@@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => {
68
70
  config: { $schema: "https://opencode.ai/config.json" },
69
71
  agents: [{ name: "agent-one", content: "Agent content" }],
70
72
  plugins: [],
73
+ commandFiles: [],
71
74
  skillDirs: [
72
75
  {
73
76
  name: "skill-one",
@@ -85,28 +88,35 @@ describe("writeOpenCodeBundle", () => {
85
88
  expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
86
89
  })
87
90
 
88
- test("backs up existing opencode.json before overwriting", async () => {
91
+ test("merges plugin config into existing opencode.json without destroying user keys", async () => {
89
92
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-"))
90
93
  const outputRoot = path.join(tempRoot, ".opencode")
91
94
  const configPath = path.join(outputRoot, "opencode.json")
92
95
 
93
- // Create existing config
96
+ // Create existing config with user keys
94
97
  await fs.mkdir(outputRoot, { recursive: true })
95
98
  const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
96
99
  await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
97
100
 
101
+ // Bundle adds mcp server but keeps user's custom key
98
102
  const bundle: OpenCodeBundle = {
99
- config: { $schema: "https://opencode.ai/config.json", new: "config" },
103
+ config: {
104
+ $schema: "https://opencode.ai/config.json",
105
+ mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } }
106
+ },
100
107
  agents: [],
101
108
  plugins: [],
109
+ commandFiles: [],
102
110
  skillDirs: [],
103
111
  }
104
112
 
105
113
  await writeOpenCodeBundle(outputRoot, bundle)
106
114
 
107
- // New config should be written
115
+ // Merged config should have both user key and plugin key
108
116
  const newConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
109
- expect(newConfig.new).toBe("config")
117
+ expect(newConfig.custom).toBe("value") // user key preserved
118
+ expect(newConfig.mcp).toBeDefined()
119
+ expect(newConfig.mcp["plugin-server"]).toBeDefined()
110
120
 
111
121
  // Backup should exist with original content
112
122
  const files = await fs.readdir(outputRoot)
@@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => {
116
126
  const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
117
127
  expect(backupContent.custom).toBe("value")
118
128
  })
129
+
130
+ test("merges mcp servers without overwriting user entry", async () => {
131
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-merge-mcp-"))
132
+ const outputRoot = path.join(tempRoot, ".opencode")
133
+ const configPath = path.join(outputRoot, "opencode.json")
134
+
135
+ // Create existing config with user's mcp server
136
+ await fs.mkdir(outputRoot, { recursive: true })
137
+ const existingConfig = {
138
+ mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } }
139
+ }
140
+ await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
141
+
142
+ // Bundle adds plugin server AND has conflicting user-server with different args
143
+ const bundle: OpenCodeBundle = {
144
+ config: {
145
+ $schema: "https://opencode.ai/config.json",
146
+ mcp: {
147
+ "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] },
148
+ "user-server": { type: "local", command: "uvx", args: ["plugin-override"] } // conflict
149
+ }
150
+ },
151
+ agents: [],
152
+ plugins: [],
153
+ commandFiles: [],
154
+ skillDirs: [],
155
+ }
156
+
157
+ await writeOpenCodeBundle(outputRoot, bundle)
158
+
159
+ // Merged config should have both servers, with user-server keeping user's original args
160
+ const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
161
+ expect(mergedConfig.mcp).toBeDefined()
162
+ expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
163
+ expect(mergedConfig.mcp["user-server"]).toBeDefined()
164
+ expect(mergedConfig.mcp["user-server"].args[0]).toBe("user-srv") // user wins on conflict
165
+ expect(mergedConfig.mcp["plugin-server"].args[0]).toBe("plugin-srv") // plugin entry present
166
+ })
167
+
168
+ test("preserves unrelated user keys when merging opencode.json", async () => {
169
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-preserve-"))
170
+ const outputRoot = path.join(tempRoot, ".opencode")
171
+ const configPath = path.join(outputRoot, "opencode.json")
172
+
173
+ // Create existing config with multiple user keys
174
+ await fs.mkdir(outputRoot, { recursive: true })
175
+ const existingConfig = {
176
+ model: "my-model",
177
+ theme: "dark",
178
+ mcp: {}
179
+ }
180
+ await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
181
+
182
+ // Bundle adds plugin-specific keys
183
+ const bundle: OpenCodeBundle = {
184
+ config: {
185
+ $schema: "https://opencode.ai/config.json",
186
+ mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } },
187
+ permission: { "bash": "allow" }
188
+ },
189
+ agents: [],
190
+ plugins: [],
191
+ commandFiles: [],
192
+ skillDirs: [],
193
+ }
194
+
195
+ await writeOpenCodeBundle(outputRoot, bundle)
196
+
197
+ // All user keys preserved
198
+ const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
199
+ expect(mergedConfig.model).toBe("my-model")
200
+ expect(mergedConfig.theme).toBe("dark")
201
+ expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
202
+ expect(mergedConfig.permission["bash"]).toBe("allow")
203
+ })
204
+
205
+ test("writes command files as .md in commands/ directory", async () => {
206
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-"))
207
+ const outputRoot = path.join(tempRoot, ".config", "opencode")
208
+ const bundle: OpenCodeBundle = {
209
+ config: { $schema: "https://opencode.ai/config.json" },
210
+ agents: [],
211
+ plugins: [],
212
+ commandFiles: [{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }],
213
+ skillDirs: [],
214
+ }
215
+
216
+ await writeOpenCodeBundle(outputRoot, bundle)
217
+
218
+ const cmdPath = path.join(outputRoot, "commands", "my-cmd.md")
219
+ expect(await exists(cmdPath)).toBe(true)
220
+
221
+ const content = await fs.readFile(cmdPath, "utf8")
222
+ expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
223
+ })
224
+
225
+ test("backs up existing command .md file before overwriting", async () => {
226
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
227
+ const outputRoot = path.join(tempRoot, ".opencode")
228
+ const commandsDir = path.join(outputRoot, "commands")
229
+ await fs.mkdir(commandsDir, { recursive: true })
230
+
231
+ const cmdPath = path.join(commandsDir, "my-cmd.md")
232
+ await fs.writeFile(cmdPath, "old content\n")
233
+
234
+ const bundle: OpenCodeBundle = {
235
+ config: { $schema: "https://opencode.ai/config.json" },
236
+ agents: [],
237
+ plugins: [],
238
+ commandFiles: [{ name: "my-cmd", content: "---\ndescription: New\n---\n\nNew content." }],
239
+ skillDirs: [],
240
+ }
241
+
242
+ await writeOpenCodeBundle(outputRoot, bundle)
243
+
244
+ // New content should be written
245
+ const content = await fs.readFile(cmdPath, "utf8")
246
+ expect(content).toBe("---\ndescription: New\n---\n\nNew content.\n")
247
+
248
+ // Backup should exist
249
+ const files = await fs.readdir(commandsDir)
250
+ const backupFileName = files.find((f) => f.startsWith("my-cmd.md.bak."))
251
+ expect(backupFileName).toBeDefined()
252
+
253
+ const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8")
254
+ expect(backupContent).toBe("old content\n")
255
+ })
119
256
  })
@@ -0,0 +1,238 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { convertClaudeToQwen } from "../src/converters/claude-to-qwen"
3
+ import { parseFrontmatter } from "../src/utils/frontmatter"
4
+ import type { ClaudePlugin } from "../src/types/claude"
5
+
6
+ const fixturePlugin: ClaudePlugin = {
7
+ root: "/tmp/plugin",
8
+ manifest: { name: "compound-engineering", version: "1.2.0", description: "A plugin for engineers" },
9
+ agents: [
10
+ {
11
+ name: "security-sentinel",
12
+ description: "Security-focused agent",
13
+ capabilities: ["Threat modeling", "OWASP"],
14
+ model: "claude-sonnet-4-20250514",
15
+ body: "Focus on vulnerabilities in ~/.claude/settings.",
16
+ sourcePath: "/tmp/plugin/agents/security-sentinel.md",
17
+ },
18
+ {
19
+ name: "brainstorm-agent",
20
+ description: "Creative brainstormer",
21
+ model: "inherit",
22
+ body: "Generate ideas.",
23
+ sourcePath: "/tmp/plugin/agents/brainstorm-agent.md",
24
+ },
25
+ ],
26
+ commands: [
27
+ {
28
+ name: "workflows:plan",
29
+ description: "Planning command",
30
+ argumentHint: "[FOCUS]",
31
+ model: "inherit",
32
+ allowedTools: ["Read"],
33
+ body: "Plan the work. Config at ~/.claude/settings.",
34
+ sourcePath: "/tmp/plugin/commands/workflows/plan.md",
35
+ },
36
+ {
37
+ name: "disabled-cmd",
38
+ description: "Disabled",
39
+ model: "inherit",
40
+ allowedTools: [],
41
+ body: "Should be excluded.",
42
+ disableModelInvocation: true,
43
+ sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
44
+ },
45
+ ],
46
+ skills: [
47
+ {
48
+ name: "existing-skill",
49
+ description: "Existing skill",
50
+ sourceDir: "/tmp/plugin/skills/existing-skill",
51
+ skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
52
+ },
53
+ ],
54
+ hooks: undefined,
55
+ mcpServers: {
56
+ local: { command: "npx", args: ["-y", "some-mcp"], env: { API_KEY: "${YOUR_API_KEY}" } },
57
+ remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
58
+ },
59
+ }
60
+
61
+ const defaultOptions = {
62
+ agentMode: "subagent" as const,
63
+ inferTemperature: false,
64
+ }
65
+
66
+ describe("convertClaudeToQwen", () => {
67
+ test("converts agents to yaml format with frontmatter", () => {
68
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
69
+
70
+ const agent = bundle.agents.find((a) => a.name === "security-sentinel")
71
+ expect(agent).toBeDefined()
72
+ expect(agent!.format).toBe("yaml")
73
+ const parsed = parseFrontmatter(agent!.content)
74
+ expect(parsed.data.name).toBe("security-sentinel")
75
+ expect(parsed.data.description).toBe("Security-focused agent")
76
+ expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
77
+ expect(parsed.body).toContain("Focus on vulnerabilities")
78
+ })
79
+
80
+ test("agent with inherit model has no model field in frontmatter", () => {
81
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
82
+ const agent = bundle.agents.find((a) => a.name === "brainstorm-agent")
83
+ expect(agent).toBeDefined()
84
+ const parsed = parseFrontmatter(agent!.content)
85
+ expect(parsed.data.model).toBeUndefined()
86
+ })
87
+
88
+ test("inferTemperature injects temperature based on agent name/description", () => {
89
+ const bundle = convertClaudeToQwen(fixturePlugin, { ...defaultOptions, inferTemperature: true })
90
+
91
+ const sentinel = bundle.agents.find((a) => a.name === "security-sentinel")
92
+ const parsed = parseFrontmatter(sentinel!.content)
93
+ expect(parsed.data.temperature).toBe(0.1) // review/security → 0.1
94
+
95
+ const brainstorm = bundle.agents.find((a) => a.name === "brainstorm-agent")
96
+ const bParsed = parseFrontmatter(brainstorm!.content)
97
+ expect(bParsed.data.temperature).toBe(0.6) // brainstorm → 0.6
98
+ })
99
+
100
+ test("inferTemperature returns undefined for unrecognized agents (no temperature set)", () => {
101
+ const plugin: ClaudePlugin = {
102
+ ...fixturePlugin,
103
+ agents: [{ name: "my-helper", description: "Generic helper", model: "inherit", body: "help", sourcePath: "/tmp/a.md" }],
104
+ }
105
+ const bundle = convertClaudeToQwen(plugin, { ...defaultOptions, inferTemperature: true })
106
+ const agent = bundle.agents[0]
107
+ const parsed = parseFrontmatter(agent.content)
108
+ expect(parsed.data.temperature).toBeUndefined()
109
+ })
110
+
111
+ test("converts commands to command files excluding disableModelInvocation", () => {
112
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
113
+
114
+ const planCmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
115
+ expect(planCmd).toBeDefined()
116
+ const parsed = parseFrontmatter(planCmd!.content)
117
+ expect(parsed.data.description).toBe("Planning command")
118
+ expect(parsed.data.allowedTools).toEqual(["Read"])
119
+
120
+ const disabled = bundle.commandFiles.find((c) => c.name === "disabled-cmd")
121
+ expect(disabled).toBeUndefined()
122
+ })
123
+
124
+ test("config uses plugin manifest name and version", () => {
125
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
126
+ expect(bundle.config.name).toBe("compound-engineering")
127
+ expect(bundle.config.version).toBe("1.2.0")
128
+ expect(bundle.config.commands).toBe("commands")
129
+ expect(bundle.config.skills).toBe("skills")
130
+ expect(bundle.config.agents).toBe("agents")
131
+ })
132
+
133
+ test("stdio MCP servers are included in config", () => {
134
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
135
+ expect(bundle.config.mcpServers).toBeDefined()
136
+ const local = bundle.config.mcpServers!.local
137
+ expect(local.command).toBe("npx")
138
+ expect(local.args).toEqual(["-y", "some-mcp"])
139
+ // No cwd field
140
+ expect((local as any).cwd).toBeUndefined()
141
+ })
142
+
143
+ test("remote MCP servers are skipped with a warning (not converted to curl)", () => {
144
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
145
+ // Only local (stdio) server should be present
146
+ expect(bundle.config.mcpServers).toBeDefined()
147
+ expect(bundle.config.mcpServers!.remote).toBeUndefined()
148
+ expect(bundle.config.mcpServers!.local).toBeDefined()
149
+ })
150
+
151
+ test("placeholder env vars are extracted as settings", () => {
152
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
153
+ expect(bundle.config.settings).toBeDefined()
154
+ const apiKeySetting = bundle.config.settings!.find((s) => s.envVar === "API_KEY")
155
+ expect(apiKeySetting).toBeDefined()
156
+ expect(apiKeySetting!.sensitive).toBe(true)
157
+ expect(apiKeySetting!.name).toBe("Api Key")
158
+ })
159
+
160
+ test("plugin with no MCP servers has no mcpServers in config", () => {
161
+ const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
162
+ const bundle = convertClaudeToQwen(plugin, defaultOptions)
163
+ expect(bundle.config.mcpServers).toBeUndefined()
164
+ })
165
+
166
+ test("context file uses plugin.manifest.name and manifest.description", () => {
167
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
168
+ expect(bundle.contextFile).toContain("# compound-engineering")
169
+ expect(bundle.contextFile).toContain("A plugin for engineers")
170
+ expect(bundle.contextFile).toContain("## Agents")
171
+ expect(bundle.contextFile).toContain("security-sentinel")
172
+ expect(bundle.contextFile).toContain("## Commands")
173
+ expect(bundle.contextFile).toContain("/workflows:plan")
174
+ // Disabled commands excluded
175
+ expect(bundle.contextFile).not.toContain("disabled-cmd")
176
+ expect(bundle.contextFile).toContain("## Skills")
177
+ expect(bundle.contextFile).toContain("existing-skill")
178
+ })
179
+
180
+ test("paths are rewritten from .claude/ to .qwen/ in agent and command content", () => {
181
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
182
+
183
+ const agent = bundle.agents.find((a) => a.name === "security-sentinel")
184
+ expect(agent!.content).toContain("~/.qwen/settings")
185
+ expect(agent!.content).not.toContain("~/.claude/settings")
186
+
187
+ const cmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
188
+ expect(cmd!.content).toContain("~/.qwen/settings")
189
+ expect(cmd!.content).not.toContain("~/.claude/settings")
190
+ })
191
+
192
+ test("opencode paths are NOT rewritten (only claude paths)", () => {
193
+ const plugin: ClaudePlugin = {
194
+ ...fixturePlugin,
195
+ agents: [
196
+ {
197
+ name: "test-agent",
198
+ description: "test",
199
+ model: "inherit",
200
+ body: "See .opencode/config and ~/.config/opencode/settings",
201
+ sourcePath: "/tmp/a.md",
202
+ },
203
+ ],
204
+ }
205
+ const bundle = convertClaudeToQwen(plugin, defaultOptions)
206
+ const agent = bundle.agents[0]
207
+ // opencode paths should NOT be rewritten
208
+ expect(agent.content).toContain(".opencode/config")
209
+ expect(agent.content).not.toContain(".qwen/config")
210
+ })
211
+
212
+ test("skillDirs passes through original skills", () => {
213
+ const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
214
+ const skill = bundle.skillDirs.find((s) => s.name === "existing-skill")
215
+ expect(skill).toBeDefined()
216
+ expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
217
+ })
218
+
219
+ test("normalizeModel prefixes claude models with anthropic/", () => {
220
+ const plugin: ClaudePlugin = {
221
+ ...fixturePlugin,
222
+ agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }],
223
+ }
224
+ const bundle = convertClaudeToQwen(plugin, defaultOptions)
225
+ const parsed = parseFrontmatter(bundle.agents[0].content)
226
+ expect(parsed.data.model).toBe("anthropic/claude-opus-4-5")
227
+ })
228
+
229
+ test("normalizeModel passes through already-namespaced models unchanged", () => {
230
+ const plugin: ClaudePlugin = {
231
+ ...fixturePlugin,
232
+ agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }],
233
+ }
234
+ const bundle = convertClaudeToQwen(plugin, defaultOptions)
235
+ const parsed = parseFrontmatter(bundle.agents[0].content)
236
+ expect(parsed.data.model).toBe("google/gemini-2.0")
237
+ })
238
+ })