@every-env/compound-plugin 0.5.1 → 0.7.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +20 -3
- package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
- package/docs/specs/gemini.md +122 -0
- package/package.json +1 -1
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/CHANGELOG.md +17 -0
- package/plugins/compound-engineering/commands/workflows/plan.md +3 -0
- package/plugins/compound-engineering/commands/workflows/work.md +8 -1
- package/src/commands/convert.ts +14 -25
- package/src/commands/install.ts +28 -26
- package/src/commands/sync.ts +44 -21
- package/src/converters/claude-to-gemini.ts +193 -0
- package/src/converters/claude-to-opencode.ts +16 -0
- package/src/converters/claude-to-pi.ts +205 -0
- package/src/sync/cursor.ts +78 -0
- package/src/sync/droid.ts +21 -0
- package/src/sync/pi.ts +88 -0
- package/src/targets/gemini.ts +68 -0
- package/src/targets/index.ts +18 -0
- package/src/targets/pi.ts +131 -0
- package/src/templates/pi/compat-extension.ts +452 -0
- package/src/types/gemini.ts +29 -0
- package/src/types/pi.ts +40 -0
- package/src/utils/resolve-home.ts +17 -0
- package/tests/cli.test.ts +76 -0
- package/tests/converter.test.ts +29 -0
- package/tests/gemini-converter.test.ts +373 -0
- package/tests/gemini-writer.test.ts +181 -0
- package/tests/pi-converter.test.ts +116 -0
- package/tests/pi-writer.test.ts +99 -0
- package/tests/sync-cursor.test.ts +92 -0
- package/tests/sync-droid.test.ts +57 -0
- package/tests/sync-pi.test.ts +68 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { convertClaudeToGemini, toToml, transformContentForGemini } from "../src/converters/claude-to-gemini"
|
|
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: "fixture", version: "1.0.0" },
|
|
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.",
|
|
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.",
|
|
27
|
+
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
skills: [
|
|
31
|
+
{
|
|
32
|
+
name: "existing-skill",
|
|
33
|
+
description: "Existing skill",
|
|
34
|
+
sourceDir: "/tmp/plugin/skills/existing-skill",
|
|
35
|
+
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
hooks: undefined,
|
|
39
|
+
mcpServers: {
|
|
40
|
+
local: { command: "echo", args: ["hello"] },
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("convertClaudeToGemini", () => {
|
|
45
|
+
test("converts agents to skills with SKILL.md frontmatter", () => {
|
|
46
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
47
|
+
agentMode: "subagent",
|
|
48
|
+
inferTemperature: false,
|
|
49
|
+
permissions: "none",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
|
|
53
|
+
expect(skill).toBeDefined()
|
|
54
|
+
const parsed = parseFrontmatter(skill!.content)
|
|
55
|
+
expect(parsed.data.name).toBe("security-reviewer")
|
|
56
|
+
expect(parsed.data.description).toBe("Security-focused agent")
|
|
57
|
+
expect(parsed.body).toContain("Focus on vulnerabilities.")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("agent with capabilities prepended to body", () => {
|
|
61
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
62
|
+
agentMode: "subagent",
|
|
63
|
+
inferTemperature: false,
|
|
64
|
+
permissions: "none",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
|
|
68
|
+
expect(skill).toBeDefined()
|
|
69
|
+
const parsed = parseFrontmatter(skill!.content)
|
|
70
|
+
expect(parsed.body).toContain("## Capabilities")
|
|
71
|
+
expect(parsed.body).toContain("- Threat modeling")
|
|
72
|
+
expect(parsed.body).toContain("- OWASP")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("agent with empty description gets default description", () => {
|
|
76
|
+
const plugin: ClaudePlugin = {
|
|
77
|
+
...fixturePlugin,
|
|
78
|
+
agents: [
|
|
79
|
+
{
|
|
80
|
+
name: "my-agent",
|
|
81
|
+
body: "Do things.",
|
|
82
|
+
sourcePath: "/tmp/plugin/agents/my-agent.md",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
commands: [],
|
|
86
|
+
skills: [],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
90
|
+
agentMode: "subagent",
|
|
91
|
+
inferTemperature: false,
|
|
92
|
+
permissions: "none",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
|
|
96
|
+
expect(parsed.data.description).toBe("Use this skill for my-agent tasks")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("agent model field silently dropped", () => {
|
|
100
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
101
|
+
agentMode: "subagent",
|
|
102
|
+
inferTemperature: false,
|
|
103
|
+
permissions: "none",
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
|
|
107
|
+
const parsed = parseFrontmatter(skill!.content)
|
|
108
|
+
expect(parsed.data.model).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("agent with empty body gets default body text", () => {
|
|
112
|
+
const plugin: ClaudePlugin = {
|
|
113
|
+
...fixturePlugin,
|
|
114
|
+
agents: [
|
|
115
|
+
{
|
|
116
|
+
name: "Empty Agent",
|
|
117
|
+
description: "An empty agent",
|
|
118
|
+
body: "",
|
|
119
|
+
sourcePath: "/tmp/plugin/agents/empty.md",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
commands: [],
|
|
123
|
+
skills: [],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
127
|
+
agentMode: "subagent",
|
|
128
|
+
inferTemperature: false,
|
|
129
|
+
permissions: "none",
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
|
|
133
|
+
expect(parsed.body).toContain("Instructions converted from the Empty Agent agent.")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("converts commands to TOML with prompt and description", () => {
|
|
137
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
138
|
+
agentMode: "subagent",
|
|
139
|
+
inferTemperature: false,
|
|
140
|
+
permissions: "none",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(bundle.commands).toHaveLength(1)
|
|
144
|
+
const command = bundle.commands[0]
|
|
145
|
+
expect(command.name).toBe("workflows/plan")
|
|
146
|
+
expect(command.content).toContain('description = "Planning command"')
|
|
147
|
+
expect(command.content).toContain('prompt = """')
|
|
148
|
+
expect(command.content).toContain("Plan the work.")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("namespaced command creates correct path", () => {
|
|
152
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
153
|
+
agentMode: "subagent",
|
|
154
|
+
inferTemperature: false,
|
|
155
|
+
permissions: "none",
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const command = bundle.commands.find((c) => c.name === "workflows/plan")
|
|
159
|
+
expect(command).toBeDefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("command with argument-hint gets {{args}} placeholder", () => {
|
|
163
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
164
|
+
agentMode: "subagent",
|
|
165
|
+
inferTemperature: false,
|
|
166
|
+
permissions: "none",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const command = bundle.commands[0]
|
|
170
|
+
expect(command.content).toContain("{{args}}")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("command with disable-model-invocation is still included", () => {
|
|
174
|
+
const plugin: ClaudePlugin = {
|
|
175
|
+
...fixturePlugin,
|
|
176
|
+
commands: [
|
|
177
|
+
{
|
|
178
|
+
name: "disabled-command",
|
|
179
|
+
description: "Disabled command",
|
|
180
|
+
disableModelInvocation: true,
|
|
181
|
+
body: "Disabled body.",
|
|
182
|
+
sourcePath: "/tmp/plugin/commands/disabled.md",
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
agents: [],
|
|
186
|
+
skills: [],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
190
|
+
agentMode: "subagent",
|
|
191
|
+
inferTemperature: false,
|
|
192
|
+
permissions: "none",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Gemini TOML commands are prompts, not code — always include
|
|
196
|
+
expect(bundle.commands).toHaveLength(1)
|
|
197
|
+
expect(bundle.commands[0].name).toBe("disabled-command")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("command allowedTools silently dropped", () => {
|
|
201
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
202
|
+
agentMode: "subagent",
|
|
203
|
+
inferTemperature: false,
|
|
204
|
+
permissions: "none",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const command = bundle.commands[0]
|
|
208
|
+
expect(command.content).not.toContain("allowedTools")
|
|
209
|
+
expect(command.content).not.toContain("Read")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("skills pass through as directory references", () => {
|
|
213
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
214
|
+
agentMode: "subagent",
|
|
215
|
+
inferTemperature: false,
|
|
216
|
+
permissions: "none",
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
220
|
+
expect(bundle.skillDirs[0].name).toBe("existing-skill")
|
|
221
|
+
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test("MCP servers convert to settings.json-compatible config", () => {
|
|
225
|
+
const bundle = convertClaudeToGemini(fixturePlugin, {
|
|
226
|
+
agentMode: "subagent",
|
|
227
|
+
inferTemperature: false,
|
|
228
|
+
permissions: "none",
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
expect(bundle.mcpServers?.local?.command).toBe("echo")
|
|
232
|
+
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test("plugin with zero agents produces empty generatedSkills", () => {
|
|
236
|
+
const plugin: ClaudePlugin = {
|
|
237
|
+
...fixturePlugin,
|
|
238
|
+
agents: [],
|
|
239
|
+
commands: [],
|
|
240
|
+
skills: [],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
244
|
+
agentMode: "subagent",
|
|
245
|
+
inferTemperature: false,
|
|
246
|
+
permissions: "none",
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(bundle.generatedSkills).toHaveLength(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test("plugin with only skills works correctly", () => {
|
|
253
|
+
const plugin: ClaudePlugin = {
|
|
254
|
+
...fixturePlugin,
|
|
255
|
+
agents: [],
|
|
256
|
+
commands: [],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
260
|
+
agentMode: "subagent",
|
|
261
|
+
inferTemperature: false,
|
|
262
|
+
permissions: "none",
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(bundle.generatedSkills).toHaveLength(0)
|
|
266
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
267
|
+
expect(bundle.commands).toHaveLength(0)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("agent name colliding with skill name gets deduplicated", () => {
|
|
271
|
+
const plugin: ClaudePlugin = {
|
|
272
|
+
...fixturePlugin,
|
|
273
|
+
skills: [{ name: "security-reviewer", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }],
|
|
274
|
+
agents: [{ name: "Security Reviewer", description: "Agent version", body: "Body.", sourcePath: "/tmp/agents/sr.md" }],
|
|
275
|
+
commands: [],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const bundle = convertClaudeToGemini(plugin, {
|
|
279
|
+
agentMode: "subagent",
|
|
280
|
+
inferTemperature: false,
|
|
281
|
+
permissions: "none",
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Agent should be deduplicated since skill already has "security-reviewer"
|
|
285
|
+
expect(bundle.generatedSkills[0].name).toBe("security-reviewer-2")
|
|
286
|
+
expect(bundle.skillDirs[0].name).toBe("security-reviewer")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("hooks present emits console.warn", () => {
|
|
290
|
+
const warnings: string[] = []
|
|
291
|
+
const originalWarn = console.warn
|
|
292
|
+
console.warn = (msg: string) => warnings.push(msg)
|
|
293
|
+
|
|
294
|
+
const plugin: ClaudePlugin = {
|
|
295
|
+
...fixturePlugin,
|
|
296
|
+
hooks: { hooks: { PreToolUse: [{ matcher: "*", body: "hook body" }] } },
|
|
297
|
+
agents: [],
|
|
298
|
+
commands: [],
|
|
299
|
+
skills: [],
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
convertClaudeToGemini(plugin, {
|
|
303
|
+
agentMode: "subagent",
|
|
304
|
+
inferTemperature: false,
|
|
305
|
+
permissions: "none",
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
console.warn = originalWarn
|
|
309
|
+
expect(warnings.some((w) => w.includes("Gemini"))).toBe(true)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe("transformContentForGemini", () => {
|
|
314
|
+
test("transforms .claude/ paths to .gemini/", () => {
|
|
315
|
+
const result = transformContentForGemini("Read .claude/settings.json for config.")
|
|
316
|
+
expect(result).toContain(".gemini/settings.json")
|
|
317
|
+
expect(result).not.toContain(".claude/")
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test("transforms ~/.claude/ paths to ~/.gemini/", () => {
|
|
321
|
+
const result = transformContentForGemini("Check ~/.claude/config for settings.")
|
|
322
|
+
expect(result).toContain("~/.gemini/config")
|
|
323
|
+
expect(result).not.toContain("~/.claude/")
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("transforms Task agent(args) to natural language skill reference", () => {
|
|
327
|
+
const input = `Run these:
|
|
328
|
+
|
|
329
|
+
- Task repo-research-analyst(feature_description)
|
|
330
|
+
- Task learnings-researcher(feature_description)
|
|
331
|
+
|
|
332
|
+
Task best-practices-researcher(topic)`
|
|
333
|
+
|
|
334
|
+
const result = transformContentForGemini(input)
|
|
335
|
+
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
|
|
336
|
+
expect(result).toContain("Use the learnings-researcher skill to: feature_description")
|
|
337
|
+
expect(result).toContain("Use the best-practices-researcher skill to: topic")
|
|
338
|
+
expect(result).not.toContain("Task repo-research-analyst")
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test("transforms @agent references to skill references", () => {
|
|
342
|
+
const result = transformContentForGemini("Ask @security-sentinel for a review.")
|
|
343
|
+
expect(result).toContain("the security-sentinel skill")
|
|
344
|
+
expect(result).not.toContain("@security-sentinel")
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe("toToml", () => {
|
|
349
|
+
test("produces valid TOML with description and prompt", () => {
|
|
350
|
+
const result = toToml("A description", "The prompt content")
|
|
351
|
+
expect(result).toContain('description = "A description"')
|
|
352
|
+
expect(result).toContain('prompt = """')
|
|
353
|
+
expect(result).toContain("The prompt content")
|
|
354
|
+
expect(result).toContain('"""')
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test("escapes quotes in description", () => {
|
|
358
|
+
const result = toToml('Say "hello"', "Prompt")
|
|
359
|
+
expect(result).toContain('description = "Say \\"hello\\""')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test("escapes triple quotes in prompt", () => {
|
|
363
|
+
const result = toToml("A command", 'Content with """ inside it')
|
|
364
|
+
// Should not contain an unescaped """ that would close the TOML multi-line string prematurely
|
|
365
|
+
// The prompt section should have the escaped version
|
|
366
|
+
expect(result).toContain('description = "A command"')
|
|
367
|
+
expect(result).toContain('prompt = """')
|
|
368
|
+
// The inner """ should be escaped
|
|
369
|
+
expect(result).not.toMatch(/""".*""".*"""/s) // Should not have 3 separate triple-quote sequences (open, content, close would make 3)
|
|
370
|
+
// Verify it contains the escaped form
|
|
371
|
+
expect(result).toContain('\\"\\"\\"')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import os from "os"
|
|
5
|
+
import { writeGeminiBundle } from "../src/targets/gemini"
|
|
6
|
+
import type { GeminiBundle } from "../src/types/gemini"
|
|
7
|
+
|
|
8
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(filePath)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("writeGeminiBundle", () => {
|
|
18
|
+
test("writes skills, commands, and settings.json", async () => {
|
|
19
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"))
|
|
20
|
+
const bundle: GeminiBundle = {
|
|
21
|
+
generatedSkills: [
|
|
22
|
+
{
|
|
23
|
+
name: "security-reviewer",
|
|
24
|
+
content: "---\nname: security-reviewer\ndescription: Security\n---\n\nReview code.",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
skillDirs: [
|
|
28
|
+
{
|
|
29
|
+
name: "skill-one",
|
|
30
|
+
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
commands: [
|
|
34
|
+
{
|
|
35
|
+
name: "plan",
|
|
36
|
+
content: 'description = "Plan"\nprompt = """\nPlan the work.\n"""',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
mcpServers: {
|
|
40
|
+
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await writeGeminiBundle(tempRoot, bundle)
|
|
45
|
+
|
|
46
|
+
expect(await exists(path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"))).toBe(true)
|
|
47
|
+
expect(await exists(path.join(tempRoot, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
|
48
|
+
expect(await exists(path.join(tempRoot, ".gemini", "commands", "plan.toml"))).toBe(true)
|
|
49
|
+
expect(await exists(path.join(tempRoot, ".gemini", "settings.json"))).toBe(true)
|
|
50
|
+
|
|
51
|
+
const skillContent = await fs.readFile(
|
|
52
|
+
path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"),
|
|
53
|
+
"utf8",
|
|
54
|
+
)
|
|
55
|
+
expect(skillContent).toContain("Review code.")
|
|
56
|
+
|
|
57
|
+
const commandContent = await fs.readFile(
|
|
58
|
+
path.join(tempRoot, ".gemini", "commands", "plan.toml"),
|
|
59
|
+
"utf8",
|
|
60
|
+
)
|
|
61
|
+
expect(commandContent).toContain("Plan the work.")
|
|
62
|
+
|
|
63
|
+
const settingsContent = JSON.parse(
|
|
64
|
+
await fs.readFile(path.join(tempRoot, ".gemini", "settings.json"), "utf8"),
|
|
65
|
+
)
|
|
66
|
+
expect(settingsContent.mcpServers.playwright.command).toBe("npx")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("namespaced commands create subdirectories", async () => {
|
|
70
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-ns-"))
|
|
71
|
+
const bundle: GeminiBundle = {
|
|
72
|
+
generatedSkills: [],
|
|
73
|
+
skillDirs: [],
|
|
74
|
+
commands: [
|
|
75
|
+
{
|
|
76
|
+
name: "workflows/plan",
|
|
77
|
+
content: 'description = "Plan"\nprompt = """\nPlan.\n"""',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await writeGeminiBundle(tempRoot, bundle)
|
|
83
|
+
|
|
84
|
+
expect(await exists(path.join(tempRoot, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("does not double-nest when output root is .gemini", async () => {
|
|
88
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-home-"))
|
|
89
|
+
const geminiRoot = path.join(tempRoot, ".gemini")
|
|
90
|
+
const bundle: GeminiBundle = {
|
|
91
|
+
generatedSkills: [
|
|
92
|
+
{ name: "reviewer", content: "Reviewer skill content" },
|
|
93
|
+
],
|
|
94
|
+
skillDirs: [],
|
|
95
|
+
commands: [
|
|
96
|
+
{ name: "plan", content: "Plan content" },
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await writeGeminiBundle(geminiRoot, bundle)
|
|
101
|
+
|
|
102
|
+
expect(await exists(path.join(geminiRoot, "skills", "reviewer", "SKILL.md"))).toBe(true)
|
|
103
|
+
expect(await exists(path.join(geminiRoot, "commands", "plan.toml"))).toBe(true)
|
|
104
|
+
// Should NOT double-nest under .gemini/.gemini
|
|
105
|
+
expect(await exists(path.join(geminiRoot, ".gemini"))).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("handles empty bundles gracefully", async () => {
|
|
109
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-empty-"))
|
|
110
|
+
const bundle: GeminiBundle = {
|
|
111
|
+
generatedSkills: [],
|
|
112
|
+
skillDirs: [],
|
|
113
|
+
commands: [],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await writeGeminiBundle(tempRoot, bundle)
|
|
117
|
+
expect(await exists(tempRoot)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("backs up existing settings.json before overwrite", async () => {
|
|
121
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-backup-"))
|
|
122
|
+
const geminiRoot = path.join(tempRoot, ".gemini")
|
|
123
|
+
await fs.mkdir(geminiRoot, { recursive: true })
|
|
124
|
+
|
|
125
|
+
// Write existing settings.json
|
|
126
|
+
const settingsPath = path.join(geminiRoot, "settings.json")
|
|
127
|
+
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
|
128
|
+
|
|
129
|
+
const bundle: GeminiBundle = {
|
|
130
|
+
generatedSkills: [],
|
|
131
|
+
skillDirs: [],
|
|
132
|
+
commands: [],
|
|
133
|
+
mcpServers: {
|
|
134
|
+
newServer: { command: "new-cmd" },
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await writeGeminiBundle(geminiRoot, bundle)
|
|
139
|
+
|
|
140
|
+
// New settings.json should have the new content
|
|
141
|
+
const newContent = JSON.parse(await fs.readFile(settingsPath, "utf8"))
|
|
142
|
+
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
|
|
143
|
+
|
|
144
|
+
// A backup file should exist
|
|
145
|
+
const files = await fs.readdir(geminiRoot)
|
|
146
|
+
const backupFiles = files.filter((f) => f.startsWith("settings.json.bak."))
|
|
147
|
+
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("merges mcpServers into existing settings.json without clobbering other keys", async () => {
|
|
151
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-merge-"))
|
|
152
|
+
const geminiRoot = path.join(tempRoot, ".gemini")
|
|
153
|
+
await fs.mkdir(geminiRoot, { recursive: true })
|
|
154
|
+
|
|
155
|
+
// Write existing settings.json with other keys
|
|
156
|
+
const settingsPath = path.join(geminiRoot, "settings.json")
|
|
157
|
+
await fs.writeFile(settingsPath, JSON.stringify({
|
|
158
|
+
model: "gemini-2.5-pro",
|
|
159
|
+
mcpServers: { old: { command: "old-cmd" } },
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
const bundle: GeminiBundle = {
|
|
163
|
+
generatedSkills: [],
|
|
164
|
+
skillDirs: [],
|
|
165
|
+
commands: [],
|
|
166
|
+
mcpServers: {
|
|
167
|
+
newServer: { command: "new-cmd" },
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await writeGeminiBundle(geminiRoot, bundle)
|
|
172
|
+
|
|
173
|
+
const content = JSON.parse(await fs.readFile(settingsPath, "utf8"))
|
|
174
|
+
// Should preserve existing model key
|
|
175
|
+
expect(content.model).toBe("gemini-2.5-pro")
|
|
176
|
+
// Should preserve existing MCP server
|
|
177
|
+
expect(content.mcpServers.old.command).toBe("old-cmd")
|
|
178
|
+
// Should add new MCP server
|
|
179
|
+
expect(content.mcpServers.newServer.command).toBe("new-cmd")
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { loadClaudePlugin } from "../src/parsers/claude"
|
|
4
|
+
import { convertClaudeToPi } from "../src/converters/claude-to-pi"
|
|
5
|
+
import { parseFrontmatter } from "../src/utils/frontmatter"
|
|
6
|
+
import type { ClaudePlugin } from "../src/types/claude"
|
|
7
|
+
|
|
8
|
+
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
|
9
|
+
|
|
10
|
+
describe("convertClaudeToPi", () => {
|
|
11
|
+
test("converts commands, skills, extensions, and MCPorter config", async () => {
|
|
12
|
+
const plugin = await loadClaudePlugin(fixtureRoot)
|
|
13
|
+
const bundle = convertClaudeToPi(plugin, {
|
|
14
|
+
agentMode: "subagent",
|
|
15
|
+
inferTemperature: false,
|
|
16
|
+
permissions: "none",
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// Prompts are normalized command names
|
|
20
|
+
expect(bundle.prompts.some((prompt) => prompt.name === "workflows-review")).toBe(true)
|
|
21
|
+
expect(bundle.prompts.some((prompt) => prompt.name === "plan_review")).toBe(true)
|
|
22
|
+
|
|
23
|
+
// Commands with disable-model-invocation are excluded
|
|
24
|
+
expect(bundle.prompts.some((prompt) => prompt.name === "deploy-docs")).toBe(false)
|
|
25
|
+
|
|
26
|
+
const workflowsReview = bundle.prompts.find((prompt) => prompt.name === "workflows-review")
|
|
27
|
+
expect(workflowsReview).toBeDefined()
|
|
28
|
+
const parsedPrompt = parseFrontmatter(workflowsReview!.content)
|
|
29
|
+
expect(parsedPrompt.data.description).toBe("Run a multi-agent review workflow")
|
|
30
|
+
|
|
31
|
+
// Existing skills are copied and agents are converted into generated Pi skills
|
|
32
|
+
expect(bundle.skillDirs.some((skill) => skill.name === "skill-one")).toBe(true)
|
|
33
|
+
expect(bundle.generatedSkills.some((skill) => skill.name === "repo-research-analyst")).toBe(true)
|
|
34
|
+
|
|
35
|
+
// Pi compatibility extension is included (with subagent + MCPorter tools)
|
|
36
|
+
const compatExtension = bundle.extensions.find((extension) => extension.name === "compound-engineering-compat.ts")
|
|
37
|
+
expect(compatExtension).toBeDefined()
|
|
38
|
+
expect(compatExtension!.content).toContain('name: "subagent"')
|
|
39
|
+
expect(compatExtension!.content).toContain('name: "mcporter_call"')
|
|
40
|
+
|
|
41
|
+
// Claude MCP config is translated to MCPorter config
|
|
42
|
+
expect(bundle.mcporterConfig?.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
|
|
43
|
+
expect(bundle.mcporterConfig?.mcpServers["local-tooling"]?.command).toBe("echo")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("transforms Task calls, AskUserQuestion, slash commands, and todo tool references", () => {
|
|
47
|
+
const plugin: ClaudePlugin = {
|
|
48
|
+
root: "/tmp/plugin",
|
|
49
|
+
manifest: { name: "fixture", version: "1.0.0" },
|
|
50
|
+
agents: [],
|
|
51
|
+
commands: [
|
|
52
|
+
{
|
|
53
|
+
name: "workflows:plan",
|
|
54
|
+
description: "Plan workflow",
|
|
55
|
+
body: [
|
|
56
|
+
"Run these in order:",
|
|
57
|
+
"- Task repo-research-analyst(feature_description)",
|
|
58
|
+
"- Task learnings-researcher(feature_description)",
|
|
59
|
+
"Use AskUserQuestion tool for follow-up.",
|
|
60
|
+
"Then use /workflows:work and /prompts:deepen-plan.",
|
|
61
|
+
"Track progress with TodoWrite and TodoRead.",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
sourcePath: "/tmp/plugin/commands/plan.md",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
skills: [],
|
|
67
|
+
hooks: undefined,
|
|
68
|
+
mcpServers: undefined,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const bundle = convertClaudeToPi(plugin, {
|
|
72
|
+
agentMode: "subagent",
|
|
73
|
+
inferTemperature: false,
|
|
74
|
+
permissions: "none",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(bundle.prompts).toHaveLength(1)
|
|
78
|
+
const parsedPrompt = parseFrontmatter(bundle.prompts[0].content)
|
|
79
|
+
|
|
80
|
+
expect(parsedPrompt.body).toContain("Run subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".")
|
|
81
|
+
expect(parsedPrompt.body).toContain("Run subagent with agent=\"learnings-researcher\" and task=\"feature_description\".")
|
|
82
|
+
expect(parsedPrompt.body).toContain("ask_user_question")
|
|
83
|
+
expect(parsedPrompt.body).toContain("/workflows-work")
|
|
84
|
+
expect(parsedPrompt.body).toContain("/deepen-plan")
|
|
85
|
+
expect(parsedPrompt.body).toContain("file-based todos (todos/ + /skill:file-todos)")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("appends MCPorter compatibility note when command references MCP", () => {
|
|
89
|
+
const plugin: ClaudePlugin = {
|
|
90
|
+
root: "/tmp/plugin",
|
|
91
|
+
manifest: { name: "fixture", version: "1.0.0" },
|
|
92
|
+
agents: [],
|
|
93
|
+
commands: [
|
|
94
|
+
{
|
|
95
|
+
name: "docs",
|
|
96
|
+
description: "Read MCP docs",
|
|
97
|
+
body: "Use MCP servers for docs lookup.",
|
|
98
|
+
sourcePath: "/tmp/plugin/commands/docs.md",
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
skills: [],
|
|
102
|
+
hooks: undefined,
|
|
103
|
+
mcpServers: undefined,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bundle = convertClaudeToPi(plugin, {
|
|
107
|
+
agentMode: "subagent",
|
|
108
|
+
inferTemperature: false,
|
|
109
|
+
permissions: "none",
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const parsedPrompt = parseFrontmatter(bundle.prompts[0].content)
|
|
113
|
+
expect(parsedPrompt.body).toContain("Pi + MCPorter note")
|
|
114
|
+
expect(parsedPrompt.body).toContain("mcporter_call")
|
|
115
|
+
})
|
|
116
|
+
})
|