@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,34 @@
1
+ export type WindsurfWorkflow = {
2
+ name: string
3
+ description: string
4
+ body: string
5
+ }
6
+
7
+ export type WindsurfGeneratedSkill = {
8
+ name: string
9
+ content: string
10
+ }
11
+
12
+ export type WindsurfSkillDir = {
13
+ name: string
14
+ sourceDir: string
15
+ }
16
+
17
+ export type WindsurfMcpServerEntry = {
18
+ command?: string
19
+ args?: string[]
20
+ env?: Record<string, string>
21
+ serverUrl?: string
22
+ headers?: Record<string, string>
23
+ }
24
+
25
+ export type WindsurfMcpConfig = {
26
+ mcpServers: Record<string, WindsurfMcpServerEntry>
27
+ }
28
+
29
+ export type WindsurfBundle = {
30
+ agentSkills: WindsurfGeneratedSkill[]
31
+ commandWorkflows: WindsurfWorkflow[]
32
+ skillDirs: WindsurfSkillDir[]
33
+ mcpConfig: WindsurfMcpConfig | null
34
+ }
@@ -0,0 +1,46 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import { pathExists } from "./files"
4
+
5
+ export type DetectedTool = {
6
+ name: string
7
+ detected: boolean
8
+ reason: string
9
+ }
10
+
11
+ export async function detectInstalledTools(
12
+ home: string = os.homedir(),
13
+ cwd: string = process.cwd(),
14
+ ): Promise<DetectedTool[]> {
15
+ const checks: Array<{ name: string; paths: string[] }> = [
16
+ { name: "opencode", paths: [path.join(home, ".config", "opencode"), path.join(cwd, ".opencode")] },
17
+ { name: "codex", paths: [path.join(home, ".codex")] },
18
+ { name: "droid", paths: [path.join(home, ".factory")] },
19
+ { name: "cursor", paths: [path.join(cwd, ".cursor"), path.join(home, ".cursor")] },
20
+ { name: "pi", paths: [path.join(home, ".pi")] },
21
+ { name: "gemini", paths: [path.join(cwd, ".gemini"), path.join(home, ".gemini")] },
22
+ ]
23
+
24
+ const results: DetectedTool[] = []
25
+ for (const check of checks) {
26
+ let detected = false
27
+ let reason = "not found"
28
+ for (const p of check.paths) {
29
+ if (await pathExists(p)) {
30
+ detected = true
31
+ reason = `found ${p}`
32
+ break
33
+ }
34
+ }
35
+ results.push({ name: check.name, detected, reason })
36
+ }
37
+ return results
38
+ }
39
+
40
+ export async function getDetectedTargetNames(
41
+ home: string = os.homedir(),
42
+ cwd: string = process.cwd(),
43
+ ): Promise<string[]> {
44
+ const tools = await detectInstalledTools(home, cwd)
45
+ return tools.filter((t) => t.detected).map((t) => t.name)
46
+ }
@@ -46,6 +46,13 @@ export async function writeJson(filePath: string, data: unknown): Promise<void>
46
46
  await writeText(filePath, content + "\n")
47
47
  }
48
48
 
49
+ /** Write JSON with restrictive permissions (0o600) for files containing secrets */
50
+ export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
51
+ const content = JSON.stringify(data, null, 2)
52
+ await ensureDir(path.dirname(filePath))
53
+ await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
54
+ }
55
+
49
56
  export async function walkFiles(root: string): Promise<string[]> {
50
57
  const entries = await fs.readdir(root, { withFileTypes: true })
51
58
  const results: string[] = []
@@ -0,0 +1,50 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import type { TargetScope } from "../targets"
4
+
5
+ export function resolveTargetOutputRoot(options: {
6
+ targetName: string
7
+ outputRoot: string
8
+ codexHome: string
9
+ piHome: string
10
+ openclawHome?: string
11
+ qwenHome?: string
12
+ pluginName?: string
13
+ hasExplicitOutput: boolean
14
+ scope?: TargetScope
15
+ }): string {
16
+ const { targetName, outputRoot, codexHome, piHome, openclawHome, qwenHome, pluginName, hasExplicitOutput, scope } = options
17
+ if (targetName === "codex") return codexHome
18
+ if (targetName === "pi") return piHome
19
+ if (targetName === "droid") return path.join(os.homedir(), ".factory")
20
+ if (targetName === "cursor") {
21
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
22
+ return path.join(base, ".cursor")
23
+ }
24
+ if (targetName === "gemini") {
25
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
26
+ return path.join(base, ".gemini")
27
+ }
28
+ if (targetName === "copilot") {
29
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
30
+ return path.join(base, ".github")
31
+ }
32
+ if (targetName === "kiro") {
33
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
34
+ return path.join(base, ".kiro")
35
+ }
36
+ if (targetName === "windsurf") {
37
+ if (hasExplicitOutput) return outputRoot
38
+ if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf")
39
+ return path.join(process.cwd(), ".windsurf")
40
+ }
41
+ if (targetName === "openclaw") {
42
+ const home = openclawHome ?? path.join(os.homedir(), ".openclaw", "extensions")
43
+ return path.join(home, pluginName ?? "plugin")
44
+ }
45
+ if (targetName === "qwen") {
46
+ const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions")
47
+ return path.join(home, pluginName ?? "plugin")
48
+ }
49
+ return outputRoot
50
+ }
@@ -0,0 +1,24 @@
1
+ export const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i
2
+
3
+ /** Check if any MCP servers have env vars that might contain secrets */
4
+ export function hasPotentialSecrets(
5
+ servers: Record<string, { env?: Record<string, string> }>,
6
+ ): boolean {
7
+ for (const server of Object.values(servers)) {
8
+ if (server.env) {
9
+ for (const key of Object.keys(server.env)) {
10
+ if (SENSITIVE_PATTERN.test(key)) return true
11
+ }
12
+ }
13
+ }
14
+ return false
15
+ }
16
+
17
+ /** Return names of MCP servers whose env vars may contain secrets */
18
+ export function findServersWithPotentialSecrets(
19
+ servers: Record<string, { env?: Record<string, string> }>,
20
+ ): string[] {
21
+ return Object.entries(servers)
22
+ .filter(([, s]) => s.env && Object.keys(s.env).some((k) => SENSITIVE_PATTERN.test(k)))
23
+ .map(([name]) => name)
24
+ }
package/tests/cli.test.ts CHANGED
@@ -426,4 +426,82 @@ describe("CLI", () => {
426
426
  expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
427
427
  expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
428
428
  })
429
+
430
+ test("install --to opencode uses permissions:none by default", async () => {
431
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-none-"))
432
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
433
+
434
+ const proc = Bun.spawn([
435
+ "bun",
436
+ "run",
437
+ "src/index.ts",
438
+ "install",
439
+ fixtureRoot,
440
+ "--to",
441
+ "opencode",
442
+ "--output",
443
+ tempRoot,
444
+ ], {
445
+ cwd: path.join(import.meta.dir, ".."),
446
+ stdout: "pipe",
447
+ stderr: "pipe",
448
+ })
449
+
450
+ const exitCode = await proc.exited
451
+ const stdout = await new Response(proc.stdout).text()
452
+ const stderr = await new Response(proc.stderr).text()
453
+
454
+ if (exitCode !== 0) {
455
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
456
+ }
457
+
458
+ expect(stdout).toContain("Installed compound-engineering")
459
+
460
+ const opencodeJsonPath = path.join(tempRoot, "opencode.json")
461
+ const content = await fs.readFile(opencodeJsonPath, "utf-8")
462
+ const json = JSON.parse(content)
463
+
464
+ expect(json).not.toHaveProperty("permission")
465
+ expect(json).not.toHaveProperty("tools")
466
+ })
467
+
468
+ test("install --to opencode --permissions broad writes permission block", async () => {
469
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-broad-"))
470
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
471
+
472
+ const proc = Bun.spawn([
473
+ "bun",
474
+ "run",
475
+ "src/index.ts",
476
+ "install",
477
+ fixtureRoot,
478
+ "--to",
479
+ "opencode",
480
+ "--permissions",
481
+ "broad",
482
+ "--output",
483
+ tempRoot,
484
+ ], {
485
+ cwd: path.join(import.meta.dir, ".."),
486
+ stdout: "pipe",
487
+ stderr: "pipe",
488
+ })
489
+
490
+ const exitCode = await proc.exited
491
+ const stdout = await new Response(proc.stdout).text()
492
+ const stderr = await new Response(proc.stderr).text()
493
+
494
+ if (exitCode !== 0) {
495
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
496
+ }
497
+
498
+ expect(stdout).toContain("Installed compound-engineering")
499
+
500
+ const opencodeJsonPath = path.join(tempRoot, "opencode.json")
501
+ const content = await fs.readFile(opencodeJsonPath, "utf-8")
502
+ const json = JSON.parse(content)
503
+
504
+ expect(json).toHaveProperty("permission")
505
+ expect(json.permission).not.toBeNull()
506
+ })
429
507
  })
@@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude"
8
8
  const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
9
9
 
10
10
  describe("convertClaudeToOpenCode", () => {
11
- test("maps commands, permissions, and agents", async () => {
11
+ test("from-command mode: map allowedTools to global permission block", async () => {
12
12
  const plugin = await loadClaudePlugin(fixtureRoot)
13
13
  const bundle = convertClaudeToOpenCode(plugin, {
14
14
  agentMode: "subagent",
@@ -16,8 +16,9 @@ describe("convertClaudeToOpenCode", () => {
16
16
  permissions: "from-commands",
17
17
  })
18
18
 
19
- expect(bundle.config.command?.["workflows:review"]).toBeDefined()
20
- expect(bundle.config.command?.["plan_review"]).toBeDefined()
19
+ expect(bundle.config.command).toBeUndefined()
20
+ expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
21
+ expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
21
22
 
22
23
  const permission = bundle.config.permission as Record<string, string | Record<string, string>>
23
24
  expect(Object.keys(permission).sort()).toEqual([
@@ -71,8 +72,10 @@ describe("convertClaudeToOpenCode", () => {
71
72
  expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
72
73
  expect(parsed.data.temperature).toBe(0.1)
73
74
 
74
- const modelCommand = bundle.config.command?.["workflows:work"]
75
- expect(modelCommand?.model).toBe("openai/gpt-4o")
75
+ const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work")
76
+ expect(modelCommand).toBeDefined()
77
+ const commandParsed = parseFrontmatter(modelCommand!.content)
78
+ expect(commandParsed.data.model).toBe("openai/gpt-4o")
76
79
  })
77
80
 
78
81
  test("resolves bare Claude model aliases to full IDs", () => {
@@ -199,7 +202,7 @@ describe("convertClaudeToOpenCode", () => {
199
202
  expect(parsed.data.mode).toBe("primary")
200
203
  })
201
204
 
202
- test("excludes commands with disable-model-invocation from command map", async () => {
205
+ test("excludes commands with disable-model-invocation from commandFiles", async () => {
203
206
  const plugin = await loadClaudePlugin(fixtureRoot)
204
207
  const bundle = convertClaudeToOpenCode(plugin, {
205
208
  agentMode: "subagent",
@@ -208,10 +211,10 @@ describe("convertClaudeToOpenCode", () => {
208
211
  })
209
212
 
210
213
  // deploy-docs has disable-model-invocation: true, should be excluded
211
- expect(bundle.config.command?.["deploy-docs"]).toBeUndefined()
214
+ expect(bundle.commandFiles.find((f) => f.name === "deploy-docs")).toBeUndefined()
212
215
 
213
216
  // Normal commands should still be present
214
- expect(bundle.config.command?.["workflows:review"]).toBeDefined()
217
+ expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
215
218
  })
216
219
 
217
220
  test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
@@ -240,10 +243,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
240
243
  permissions: "none",
241
244
  })
242
245
 
243
- const template = bundle.config.command?.["review"]?.template ?? ""
246
+ const commandFile = bundle.commandFiles.find((f) => f.name === "review")
247
+ expect(commandFile).toBeDefined()
244
248
 
245
249
  // Tool-agnostic path in project root — no rewriting needed
246
- expect(template).toContain("compound-engineering.local.md")
250
+ expect(commandFile!.content).toContain("compound-engineering.local.md")
247
251
  })
248
252
 
249
253
  test("rewrites .claude/ paths in agent bodies", () => {
@@ -273,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
273
277
  // Tool-agnostic path in project root — no rewriting needed
274
278
  expect(agentFile!.content).toContain("compound-engineering.local.md")
275
279
  })
280
+
281
+ test("command .md files include description in frontmatter", () => {
282
+ const plugin: ClaudePlugin = {
283
+ root: "/tmp/plugin",
284
+ manifest: { name: "fixture", version: "1.0.0" },
285
+ agents: [],
286
+ commands: [
287
+ {
288
+ name: "test-cmd",
289
+ description: "Test description",
290
+ body: "Do the thing",
291
+ sourcePath: "/tmp/plugin/commands/test-cmd.md",
292
+ },
293
+ ],
294
+ skills: [],
295
+ }
296
+
297
+ const bundle = convertClaudeToOpenCode(plugin, {
298
+ agentMode: "subagent",
299
+ inferTemperature: false,
300
+ permissions: "none",
301
+ })
302
+
303
+ const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd")
304
+ expect(commandFile).toBeDefined()
305
+ const parsed = parseFrontmatter(commandFile!.content)
306
+ expect(parsed.data.description).toBe("Test description")
307
+ expect(parsed.body).toContain("Do the thing")
308
+ })
276
309
  })
@@ -0,0 +1,96 @@
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 { detectInstalledTools, getDetectedTargetNames } from "../src/utils/detect-tools"
6
+
7
+ describe("detectInstalledTools", () => {
8
+ test("detects tools when config directories exist", async () => {
9
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-tools-"))
10
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-tools-cwd-"))
11
+
12
+ // Create directories for some tools
13
+ await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
14
+ await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
15
+ await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true })
16
+
17
+ const results = await detectInstalledTools(tempHome, tempCwd)
18
+
19
+ const codex = results.find((t) => t.name === "codex")
20
+ expect(codex?.detected).toBe(true)
21
+ expect(codex?.reason).toContain(".codex")
22
+
23
+ const cursor = results.find((t) => t.name === "cursor")
24
+ expect(cursor?.detected).toBe(true)
25
+ expect(cursor?.reason).toContain(".cursor")
26
+
27
+ const gemini = results.find((t) => t.name === "gemini")
28
+ expect(gemini?.detected).toBe(true)
29
+ expect(gemini?.reason).toContain(".gemini")
30
+
31
+ // Tools without directories should not be detected
32
+ const opencode = results.find((t) => t.name === "opencode")
33
+ expect(opencode?.detected).toBe(false)
34
+
35
+ const droid = results.find((t) => t.name === "droid")
36
+ expect(droid?.detected).toBe(false)
37
+
38
+ const pi = results.find((t) => t.name === "pi")
39
+ expect(pi?.detected).toBe(false)
40
+ })
41
+
42
+ test("returns all tools with detected=false when no directories exist", async () => {
43
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-empty-"))
44
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-empty-cwd-"))
45
+
46
+ const results = await detectInstalledTools(tempHome, tempCwd)
47
+
48
+ expect(results.length).toBe(6)
49
+ for (const tool of results) {
50
+ expect(tool.detected).toBe(false)
51
+ expect(tool.reason).toBe("not found")
52
+ }
53
+ })
54
+
55
+ test("detects home-based tools", async () => {
56
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-home-"))
57
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-home-cwd-"))
58
+
59
+ await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
60
+ await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
61
+ await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
62
+
63
+ const results = await detectInstalledTools(tempHome, tempCwd)
64
+
65
+ expect(results.find((t) => t.name === "opencode")?.detected).toBe(true)
66
+ expect(results.find((t) => t.name === "droid")?.detected).toBe(true)
67
+ expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
68
+ })
69
+ })
70
+
71
+ describe("getDetectedTargetNames", () => {
72
+ test("returns only names of detected tools", async () => {
73
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-"))
74
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-"))
75
+
76
+ await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
77
+ await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true })
78
+
79
+ const names = await getDetectedTargetNames(tempHome, tempCwd)
80
+
81
+ expect(names).toContain("codex")
82
+ expect(names).toContain("gemini")
83
+ expect(names).not.toContain("opencode")
84
+ expect(names).not.toContain("droid")
85
+ expect(names).not.toContain("pi")
86
+ expect(names).not.toContain("cursor")
87
+ })
88
+
89
+ test("returns empty array when nothing detected", async () => {
90
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-none-"))
91
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-none-cwd-"))
92
+
93
+ const names = await getDetectedTargetNames(tempHome, tempCwd)
94
+ expect(names).toEqual([])
95
+ })
96
+ })