@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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +20 -3
  4. package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
  5. package/docs/specs/gemini.md +122 -0
  6. package/package.json +1 -1
  7. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  8. package/plugins/compound-engineering/CHANGELOG.md +17 -0
  9. package/plugins/compound-engineering/commands/workflows/plan.md +3 -0
  10. package/plugins/compound-engineering/commands/workflows/work.md +8 -1
  11. package/src/commands/convert.ts +14 -25
  12. package/src/commands/install.ts +28 -26
  13. package/src/commands/sync.ts +44 -21
  14. package/src/converters/claude-to-gemini.ts +193 -0
  15. package/src/converters/claude-to-opencode.ts +16 -0
  16. package/src/converters/claude-to-pi.ts +205 -0
  17. package/src/sync/cursor.ts +78 -0
  18. package/src/sync/droid.ts +21 -0
  19. package/src/sync/pi.ts +88 -0
  20. package/src/targets/gemini.ts +68 -0
  21. package/src/targets/index.ts +18 -0
  22. package/src/targets/pi.ts +131 -0
  23. package/src/templates/pi/compat-extension.ts +452 -0
  24. package/src/types/gemini.ts +29 -0
  25. package/src/types/pi.ts +40 -0
  26. package/src/utils/resolve-home.ts +17 -0
  27. package/tests/cli.test.ts +76 -0
  28. package/tests/converter.test.ts +29 -0
  29. package/tests/gemini-converter.test.ts +373 -0
  30. package/tests/gemini-writer.test.ts +181 -0
  31. package/tests/pi-converter.test.ts +116 -0
  32. package/tests/pi-writer.test.ts +99 -0
  33. package/tests/sync-cursor.test.ts +92 -0
  34. package/tests/sync-droid.test.ts +57 -0
  35. package/tests/sync-pi.test.ts +68 -0
@@ -0,0 +1,452 @@
1
+ export const PI_COMPAT_EXTENSION_SOURCE = `import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
6
+ import { Type } from "@sinclair/typebox"
7
+
8
+ const MAX_BYTES = 50 * 1024
9
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000
10
+ const MAX_PARALLEL_SUBAGENTS = 8
11
+
12
+ type SubagentTask = {
13
+ agent: string
14
+ task: string
15
+ cwd?: string
16
+ }
17
+
18
+ type SubagentResult = {
19
+ agent: string
20
+ task: string
21
+ cwd: string
22
+ exitCode: number
23
+ output: string
24
+ stderr: string
25
+ }
26
+
27
+ function truncate(value: string): string {
28
+ const input = value ?? ""
29
+ if (Buffer.byteLength(input, "utf8") <= MAX_BYTES) return input
30
+ const head = input.slice(0, MAX_BYTES)
31
+ return head + "\\n\\n[Output truncated to 50KB]"
32
+ }
33
+
34
+ function shellEscape(value: string): string {
35
+ return "'" + value.replace(/'/g, "'\\"'\\"'") + "'"
36
+ }
37
+
38
+ function normalizeName(value: string): string {
39
+ return String(value || "")
40
+ .trim()
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9_-]+/g, "-")
43
+ .replace(/-+/g, "-")
44
+ .replace(/^-+|-+$/g, "")
45
+ }
46
+
47
+ function resolveBundledMcporterConfigPath(): string | undefined {
48
+ try {
49
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url))
50
+ const candidates = [
51
+ path.join(extensionDir, "..", "pi-resources", "compound-engineering", "mcporter.json"),
52
+ path.join(extensionDir, "..", "compound-engineering", "mcporter.json"),
53
+ ]
54
+
55
+ for (const candidate of candidates) {
56
+ if (fs.existsSync(candidate)) return candidate
57
+ }
58
+ } catch {
59
+ // noop: bundled path is best-effort fallback
60
+ }
61
+
62
+ return undefined
63
+ }
64
+
65
+ function resolveMcporterConfigPath(cwd: string, explicit?: string): string | undefined {
66
+ if (explicit && explicit.trim()) {
67
+ return path.resolve(explicit)
68
+ }
69
+
70
+ const projectPath = path.join(cwd, ".pi", "compound-engineering", "mcporter.json")
71
+ if (fs.existsSync(projectPath)) return projectPath
72
+
73
+ const globalPath = path.join(os.homedir(), ".pi", "agent", "compound-engineering", "mcporter.json")
74
+ if (fs.existsSync(globalPath)) return globalPath
75
+
76
+ return resolveBundledMcporterConfigPath()
77
+ }
78
+
79
+ function resolveTaskCwd(baseCwd: string, taskCwd?: string): string {
80
+ if (!taskCwd || !taskCwd.trim()) return baseCwd
81
+ const expanded = taskCwd === "~"
82
+ ? os.homedir()
83
+ : taskCwd.startsWith("~" + path.sep)
84
+ ? path.join(os.homedir(), taskCwd.slice(2))
85
+ : taskCwd
86
+ return path.resolve(baseCwd, expanded)
87
+ }
88
+
89
+ async function runSingleSubagent(
90
+ pi: ExtensionAPI,
91
+ baseCwd: string,
92
+ task: SubagentTask,
93
+ signal?: AbortSignal,
94
+ timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
95
+ ): Promise<SubagentResult> {
96
+ const agent = normalizeName(task.agent)
97
+ if (!agent) {
98
+ throw new Error("Subagent task is missing a valid agent name")
99
+ }
100
+
101
+ const taskText = String(task.task ?? "").trim()
102
+ if (!taskText) {
103
+ throw new Error("Subagent task for " + agent + " is empty")
104
+ }
105
+
106
+ const cwd = resolveTaskCwd(baseCwd, task.cwd)
107
+ const prompt = "/skill:" + agent + " " + taskText
108
+ const script = "cd " + shellEscape(cwd) + " && pi --no-session -p " + shellEscape(prompt)
109
+ const result = await pi.exec("bash", ["-lc", script], { signal, timeout: timeoutMs })
110
+
111
+ return {
112
+ agent,
113
+ task: taskText,
114
+ cwd,
115
+ exitCode: result.code,
116
+ output: truncate(result.stdout || ""),
117
+ stderr: truncate(result.stderr || ""),
118
+ }
119
+ }
120
+
121
+ async function runParallelSubagents(
122
+ pi: ExtensionAPI,
123
+ baseCwd: string,
124
+ tasks: SubagentTask[],
125
+ signal?: AbortSignal,
126
+ timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
127
+ maxConcurrency = 4,
128
+ onProgress?: (completed: number, total: number) => void,
129
+ ): Promise<SubagentResult[]> {
130
+ const safeConcurrency = Math.max(1, Math.min(maxConcurrency, MAX_PARALLEL_SUBAGENTS, tasks.length))
131
+ const results: SubagentResult[] = new Array(tasks.length)
132
+
133
+ let nextIndex = 0
134
+ let completed = 0
135
+
136
+ const workers = Array.from({ length: safeConcurrency }, async () => {
137
+ while (true) {
138
+ const current = nextIndex
139
+ nextIndex += 1
140
+ if (current >= tasks.length) return
141
+
142
+ results[current] = await runSingleSubagent(pi, baseCwd, tasks[current], signal, timeoutMs)
143
+ completed += 1
144
+ onProgress?.(completed, tasks.length)
145
+ }
146
+ })
147
+
148
+ await Promise.all(workers)
149
+ return results
150
+ }
151
+
152
+ function formatSubagentSummary(results: SubagentResult[]): string {
153
+ if (results.length === 0) return "No subagent work was executed."
154
+
155
+ const success = results.filter((result) => result.exitCode === 0).length
156
+ const failed = results.length - success
157
+ const header = failed === 0
158
+ ? "Subagent run completed: " + success + "/" + results.length + " succeeded."
159
+ : "Subagent run completed: " + success + "/" + results.length + " succeeded, " + failed + " failed."
160
+
161
+ const lines = results.map((result) => {
162
+ const status = result.exitCode === 0 ? "ok" : "error"
163
+ const body = result.output || result.stderr || "(no output)"
164
+ const preview = body.split("\\n").slice(0, 6).join("\\n")
165
+ return "\\n[" + status + "] " + result.agent + "\\n" + preview
166
+ })
167
+
168
+ return header + lines.join("\\n")
169
+ }
170
+
171
+ export default function (pi: ExtensionAPI) {
172
+ pi.registerTool({
173
+ name: "ask_user_question",
174
+ label: "Ask User Question",
175
+ description: "Ask the user a question with optional choices.",
176
+ parameters: Type.Object({
177
+ question: Type.String({ description: "Question shown to the user" }),
178
+ options: Type.Optional(Type.Array(Type.String(), { description: "Selectable options" })),
179
+ allowCustom: Type.Optional(Type.Boolean({ default: true })),
180
+ }),
181
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
182
+ if (!ctx.hasUI) {
183
+ return {
184
+ isError: true,
185
+ content: [{ type: "text", text: "UI is unavailable in this mode." }],
186
+ details: {},
187
+ }
188
+ }
189
+
190
+ const options = params.options ?? []
191
+ const allowCustom = params.allowCustom ?? true
192
+
193
+ if (options.length === 0) {
194
+ const answer = await ctx.ui.input(params.question)
195
+ if (!answer) {
196
+ return {
197
+ content: [{ type: "text", text: "User cancelled." }],
198
+ details: { answer: null },
199
+ }
200
+ }
201
+
202
+ return {
203
+ content: [{ type: "text", text: "User answered: " + answer }],
204
+ details: { answer, mode: "input" },
205
+ }
206
+ }
207
+
208
+ const customLabel = "Other (type custom answer)"
209
+ const selectable = allowCustom ? [...options, customLabel] : options
210
+ const selected = await ctx.ui.select(params.question, selectable)
211
+
212
+ if (!selected) {
213
+ return {
214
+ content: [{ type: "text", text: "User cancelled." }],
215
+ details: { answer: null },
216
+ }
217
+ }
218
+
219
+ if (selected === customLabel) {
220
+ const custom = await ctx.ui.input("Your answer")
221
+ if (!custom) {
222
+ return {
223
+ content: [{ type: "text", text: "User cancelled." }],
224
+ details: { answer: null },
225
+ }
226
+ }
227
+
228
+ return {
229
+ content: [{ type: "text", text: "User answered: " + custom }],
230
+ details: { answer: custom, mode: "custom" },
231
+ }
232
+ }
233
+
234
+ return {
235
+ content: [{ type: "text", text: "User selected: " + selected }],
236
+ details: { answer: selected, mode: "select" },
237
+ }
238
+ },
239
+ })
240
+
241
+ const subagentTaskSchema = Type.Object({
242
+ agent: Type.String({ description: "Skill/agent name to invoke" }),
243
+ task: Type.String({ description: "Task instructions for that skill" }),
244
+ cwd: Type.Optional(Type.String({ description: "Optional working directory for this task" })),
245
+ })
246
+
247
+ pi.registerTool({
248
+ name: "subagent",
249
+ label: "Subagent",
250
+ description: "Run one or more skill-based subagent tasks. Supports single, parallel, and chained execution.",
251
+ parameters: Type.Object({
252
+ agent: Type.Optional(Type.String({ description: "Single subagent name" })),
253
+ task: Type.Optional(Type.String({ description: "Single subagent task" })),
254
+ cwd: Type.Optional(Type.String({ description: "Working directory for single mode" })),
255
+ tasks: Type.Optional(Type.Array(subagentTaskSchema, { description: "Parallel subagent tasks" })),
256
+ chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Sequential tasks; supports {previous} placeholder" })),
257
+ maxConcurrency: Type.Optional(Type.Number({ default: 4 })),
258
+ timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_SUBAGENT_TIMEOUT_MS })),
259
+ }),
260
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
261
+ const hasSingle = Boolean(params.agent && params.task)
262
+ const hasTasks = Boolean(params.tasks && params.tasks.length > 0)
263
+ const hasChain = Boolean(params.chain && params.chain.length > 0)
264
+ const modeCount = Number(hasSingle) + Number(hasTasks) + Number(hasChain)
265
+
266
+ if (modeCount !== 1) {
267
+ return {
268
+ isError: true,
269
+ content: [{ type: "text", text: "Provide exactly one mode: single (agent+task), tasks, or chain." }],
270
+ details: {},
271
+ }
272
+ }
273
+
274
+ const timeoutMs = Number(params.timeoutMs || DEFAULT_SUBAGENT_TIMEOUT_MS)
275
+
276
+ try {
277
+ if (hasSingle) {
278
+ const result = await runSingleSubagent(
279
+ pi,
280
+ ctx.cwd,
281
+ { agent: params.agent!, task: params.task!, cwd: params.cwd },
282
+ signal,
283
+ timeoutMs,
284
+ )
285
+
286
+ const body = formatSubagentSummary([result])
287
+ return {
288
+ isError: result.exitCode !== 0,
289
+ content: [{ type: "text", text: body }],
290
+ details: { mode: "single", results: [result] },
291
+ }
292
+ }
293
+
294
+ if (hasTasks) {
295
+ const tasks = params.tasks as SubagentTask[]
296
+ const maxConcurrency = Number(params.maxConcurrency || 4)
297
+
298
+ const results = await runParallelSubagents(
299
+ pi,
300
+ ctx.cwd,
301
+ tasks,
302
+ signal,
303
+ timeoutMs,
304
+ maxConcurrency,
305
+ (completed, total) => {
306
+ onUpdate?.({
307
+ content: [{ type: "text", text: "Subagent progress: " + completed + "/" + total }],
308
+ details: { mode: "parallel", completed, total },
309
+ })
310
+ },
311
+ )
312
+
313
+ const body = formatSubagentSummary(results)
314
+ const hasFailure = results.some((result) => result.exitCode !== 0)
315
+
316
+ return {
317
+ isError: hasFailure,
318
+ content: [{ type: "text", text: body }],
319
+ details: { mode: "parallel", results },
320
+ }
321
+ }
322
+
323
+ const chain = params.chain as SubagentTask[]
324
+ const results: SubagentResult[] = []
325
+ let previous = ""
326
+
327
+ for (const step of chain) {
328
+ const resolvedTask = step.task.replace(/\\{previous\\}/g, previous)
329
+ const result = await runSingleSubagent(
330
+ pi,
331
+ ctx.cwd,
332
+ { agent: step.agent, task: resolvedTask, cwd: step.cwd },
333
+ signal,
334
+ timeoutMs,
335
+ )
336
+ results.push(result)
337
+ previous = result.output || result.stderr
338
+
339
+ onUpdate?.({
340
+ content: [{ type: "text", text: "Subagent chain progress: " + results.length + "/" + chain.length }],
341
+ details: { mode: "chain", completed: results.length, total: chain.length },
342
+ })
343
+
344
+ if (result.exitCode !== 0) break
345
+ }
346
+
347
+ const body = formatSubagentSummary(results)
348
+ const hasFailure = results.some((result) => result.exitCode !== 0)
349
+
350
+ return {
351
+ isError: hasFailure,
352
+ content: [{ type: "text", text: body }],
353
+ details: { mode: "chain", results },
354
+ }
355
+ } catch (error) {
356
+ return {
357
+ isError: true,
358
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
359
+ details: {},
360
+ }
361
+ }
362
+ },
363
+ })
364
+
365
+ pi.registerTool({
366
+ name: "mcporter_list",
367
+ label: "MCPorter List",
368
+ description: "List tools on an MCP server through MCPorter.",
369
+ parameters: Type.Object({
370
+ server: Type.String({ description: "Configured MCP server name" }),
371
+ allParameters: Type.Optional(Type.Boolean({ default: false })),
372
+ json: Type.Optional(Type.Boolean({ default: true })),
373
+ configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
374
+ }),
375
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
376
+ const args = ["list", params.server]
377
+ if (params.allParameters) args.push("--all-parameters")
378
+ if (params.json ?? true) args.push("--json")
379
+
380
+ const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
381
+ if (configPath) {
382
+ args.push("--config", configPath)
383
+ }
384
+
385
+ const result = await pi.exec("mcporter", args, { signal })
386
+ const output = truncate(result.stdout || result.stderr || "")
387
+
388
+ return {
389
+ isError: result.code !== 0,
390
+ content: [{ type: "text", text: output || "(no output)" }],
391
+ details: {
392
+ exitCode: result.code,
393
+ command: "mcporter " + args.join(" "),
394
+ configPath,
395
+ },
396
+ }
397
+ },
398
+ })
399
+
400
+ pi.registerTool({
401
+ name: "mcporter_call",
402
+ label: "MCPorter Call",
403
+ description: "Call a specific MCP tool through MCPorter.",
404
+ parameters: Type.Object({
405
+ call: Type.Optional(Type.String({ description: "Function-style call, e.g. linear.list_issues(limit: 5)" })),
406
+ server: Type.Optional(Type.String({ description: "Server name (if call is omitted)" })),
407
+ tool: Type.Optional(Type.String({ description: "Tool name (if call is omitted)" })),
408
+ args: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "JSON arguments object" })),
409
+ configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
410
+ }),
411
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
412
+ const args = ["call"]
413
+
414
+ if (params.call && params.call.trim()) {
415
+ args.push(params.call.trim())
416
+ } else {
417
+ if (!params.server || !params.tool) {
418
+ return {
419
+ isError: true,
420
+ content: [{ type: "text", text: "Provide either call, or server + tool." }],
421
+ details: {},
422
+ }
423
+ }
424
+ args.push(params.server + "." + params.tool)
425
+ if (params.args) {
426
+ args.push("--args", JSON.stringify(params.args))
427
+ }
428
+ }
429
+
430
+ args.push("--output", "json")
431
+
432
+ const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
433
+ if (configPath) {
434
+ args.push("--config", configPath)
435
+ }
436
+
437
+ const result = await pi.exec("mcporter", args, { signal })
438
+ const output = truncate(result.stdout || result.stderr || "")
439
+
440
+ return {
441
+ isError: result.code !== 0,
442
+ content: [{ type: "text", text: output || "(no output)" }],
443
+ details: {
444
+ exitCode: result.code,
445
+ command: "mcporter " + args.join(" "),
446
+ configPath,
447
+ },
448
+ }
449
+ },
450
+ })
451
+ }
452
+ `
@@ -0,0 +1,29 @@
1
+ export type GeminiSkill = {
2
+ name: string
3
+ content: string // Full SKILL.md with YAML frontmatter
4
+ }
5
+
6
+ export type GeminiSkillDir = {
7
+ name: string
8
+ sourceDir: string
9
+ }
10
+
11
+ export type GeminiCommand = {
12
+ name: string // e.g. "plan" or "workflows/plan"
13
+ content: string // Full TOML content
14
+ }
15
+
16
+ export type GeminiMcpServer = {
17
+ command?: string
18
+ args?: string[]
19
+ env?: Record<string, string>
20
+ url?: string
21
+ headers?: Record<string, string>
22
+ }
23
+
24
+ export type GeminiBundle = {
25
+ generatedSkills: GeminiSkill[] // From agents
26
+ skillDirs: GeminiSkillDir[] // From skills (pass-through)
27
+ commands: GeminiCommand[]
28
+ mcpServers?: Record<string, GeminiMcpServer>
29
+ }
@@ -0,0 +1,40 @@
1
+ export type PiPrompt = {
2
+ name: string
3
+ content: string
4
+ }
5
+
6
+ export type PiSkillDir = {
7
+ name: string
8
+ sourceDir: string
9
+ }
10
+
11
+ export type PiGeneratedSkill = {
12
+ name: string
13
+ content: string
14
+ }
15
+
16
+ export type PiExtensionFile = {
17
+ name: string
18
+ content: string
19
+ }
20
+
21
+ export type PiMcporterServer = {
22
+ description?: string
23
+ baseUrl?: string
24
+ command?: string
25
+ args?: string[]
26
+ env?: Record<string, string>
27
+ headers?: Record<string, string>
28
+ }
29
+
30
+ export type PiMcporterConfig = {
31
+ mcpServers: Record<string, PiMcporterServer>
32
+ }
33
+
34
+ export type PiBundle = {
35
+ prompts: PiPrompt[]
36
+ skillDirs: PiSkillDir[]
37
+ generatedSkills: PiGeneratedSkill[]
38
+ extensions: PiExtensionFile[]
39
+ mcporterConfig?: PiMcporterConfig
40
+ }
@@ -0,0 +1,17 @@
1
+ import os from "os"
2
+ import path from "path"
3
+
4
+ export function expandHome(value: string): string {
5
+ if (value === "~") return os.homedir()
6
+ if (value.startsWith(`~${path.sep}`)) {
7
+ return path.join(os.homedir(), value.slice(2))
8
+ }
9
+ return value
10
+ }
11
+
12
+ export function resolveTargetHome(value: unknown, defaultPath: string): string {
13
+ if (!value) return defaultPath
14
+ const raw = String(value).trim()
15
+ if (!raw) return defaultPath
16
+ return path.resolve(expandHome(raw))
17
+ }
package/tests/cli.test.ts CHANGED
@@ -350,4 +350,80 @@ describe("CLI", () => {
350
350
  expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
351
351
  expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
352
352
  })
353
+
354
+ test("convert supports --pi-home for pi output", async () => {
355
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-pi-home-"))
356
+ const piRoot = path.join(tempRoot, ".pi")
357
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
358
+
359
+ const proc = Bun.spawn([
360
+ "bun",
361
+ "run",
362
+ "src/index.ts",
363
+ "convert",
364
+ fixtureRoot,
365
+ "--to",
366
+ "pi",
367
+ "--pi-home",
368
+ piRoot,
369
+ ], {
370
+ cwd: path.join(import.meta.dir, ".."),
371
+ stdout: "pipe",
372
+ stderr: "pipe",
373
+ })
374
+
375
+ const exitCode = await proc.exited
376
+ const stdout = await new Response(proc.stdout).text()
377
+ const stderr = await new Response(proc.stderr).text()
378
+
379
+ if (exitCode !== 0) {
380
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
381
+ }
382
+
383
+ expect(stdout).toContain("Converted compound-engineering")
384
+ expect(stdout).toContain(piRoot)
385
+ expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
386
+ expect(await exists(path.join(piRoot, "skills", "repo-research-analyst", "SKILL.md"))).toBe(true)
387
+ expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
388
+ expect(await exists(path.join(piRoot, "compound-engineering", "mcporter.json"))).toBe(true)
389
+ })
390
+
391
+ test("install supports --also with pi output", async () => {
392
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-also-pi-"))
393
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
394
+ const piRoot = path.join(tempRoot, ".pi")
395
+
396
+ const proc = Bun.spawn([
397
+ "bun",
398
+ "run",
399
+ "src/index.ts",
400
+ "install",
401
+ fixtureRoot,
402
+ "--to",
403
+ "opencode",
404
+ "--also",
405
+ "pi",
406
+ "--pi-home",
407
+ piRoot,
408
+ "--output",
409
+ tempRoot,
410
+ ], {
411
+ cwd: path.join(import.meta.dir, ".."),
412
+ stdout: "pipe",
413
+ stderr: "pipe",
414
+ })
415
+
416
+ const exitCode = await proc.exited
417
+ const stdout = await new Response(proc.stdout).text()
418
+ const stderr = await new Response(proc.stderr).text()
419
+
420
+ if (exitCode !== 0) {
421
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
422
+ }
423
+
424
+ expect(stdout).toContain("Installed compound-engineering")
425
+ expect(stdout).toContain(piRoot)
426
+ expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
427
+ expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
428
+ })
353
429
  })
@@ -75,6 +75,35 @@ describe("convertClaudeToOpenCode", () => {
75
75
  expect(modelCommand?.model).toBe("openai/gpt-4o")
76
76
  })
77
77
 
78
+ test("resolves bare Claude model aliases to full IDs", () => {
79
+ const plugin: ClaudePlugin = {
80
+ root: "/tmp/plugin",
81
+ manifest: { name: "fixture", version: "1.0.0" },
82
+ agents: [
83
+ {
84
+ name: "cheap-agent",
85
+ description: "Agent using bare alias",
86
+ body: "Test agent.",
87
+ sourcePath: "/tmp/plugin/agents/cheap-agent.md",
88
+ model: "haiku",
89
+ },
90
+ ],
91
+ commands: [],
92
+ skills: [],
93
+ }
94
+
95
+ const bundle = convertClaudeToOpenCode(plugin, {
96
+ agentMode: "subagent",
97
+ inferTemperature: false,
98
+ permissions: "none",
99
+ })
100
+
101
+ const agent = bundle.agents.find((a) => a.name === "cheap-agent")
102
+ expect(agent).toBeDefined()
103
+ const parsed = parseFrontmatter(agent!.content)
104
+ expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5")
105
+ })
106
+
78
107
  test("converts hooks into plugin file", async () => {
79
108
  const plugin = await loadClaudePlugin(fixtureRoot)
80
109
  const bundle = convertClaudeToOpenCode(plugin, {