@every-env/compound-plugin 0.12.0 → 2.34.2

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.github/workflows/publish.yml +20 -10
  3. package/.releaserc.json +31 -0
  4. package/AGENTS.md +1 -0
  5. package/CHANGELOG.md +34 -0
  6. package/CLAUDE.md +13 -0
  7. package/README.md +35 -2
  8. package/bun.lock +977 -0
  9. package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
  10. package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
  11. package/docs/solutions/adding-converter-target-providers.md +2 -1
  12. package/docs/solutions/plugin-versioning-requirements.md +4 -0
  13. package/package.json +10 -4
  14. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  15. package/plugins/compound-engineering/CHANGELOG.md +10 -0
  16. package/plugins/compound-engineering/CLAUDE.md +5 -0
  17. package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
  18. package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
  19. package/plugins/compound-engineering/skills/setup/SKILL.md +6 -0
  20. package/src/commands/sync.ts +21 -60
  21. package/src/index.ts +2 -1
  22. package/src/parsers/claude-home.ts +55 -3
  23. package/src/sync/codex.ts +38 -62
  24. package/src/sync/commands.ts +198 -0
  25. package/src/sync/copilot.ts +14 -36
  26. package/src/sync/droid.ts +50 -9
  27. package/src/sync/gemini.ts +87 -28
  28. package/src/sync/json-config.ts +47 -0
  29. package/src/sync/kiro.ts +49 -0
  30. package/src/sync/mcp-transports.ts +19 -0
  31. package/src/sync/openclaw.ts +18 -0
  32. package/src/sync/opencode.ts +10 -30
  33. package/src/sync/pi.ts +12 -36
  34. package/src/sync/qwen.ts +66 -0
  35. package/src/sync/registry.ts +141 -0
  36. package/src/sync/skills.ts +21 -0
  37. package/src/sync/windsurf.ts +59 -0
  38. package/src/types/kiro.ts +3 -1
  39. package/src/types/qwen.ts +3 -0
  40. package/src/types/windsurf.ts +1 -0
  41. package/src/utils/codex-agents.ts +1 -1
  42. package/src/utils/detect-tools.ts +4 -13
  43. package/src/utils/files.ts +7 -0
  44. package/src/utils/symlink.ts +4 -6
  45. package/tests/claude-home.test.ts +46 -0
  46. package/tests/cli.test.ts +102 -0
  47. package/tests/detect-tools.test.ts +30 -7
  48. package/tests/sync-codex.test.ts +64 -0
  49. package/tests/sync-copilot.test.ts +60 -4
  50. package/tests/sync-droid.test.ts +44 -4
  51. package/tests/sync-gemini.test.ts +54 -0
  52. package/tests/sync-kiro.test.ts +83 -0
  53. package/tests/sync-openclaw.test.ts +51 -0
  54. package/tests/sync-qwen.test.ts +75 -0
  55. package/tests/sync-windsurf.test.ts +89 -0
@@ -0,0 +1,141 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
+ import { syncToCodex } from "./codex"
5
+ import { syncToCopilot } from "./copilot"
6
+ import { syncToDroid } from "./droid"
7
+ import { syncToGemini } from "./gemini"
8
+ import { syncToKiro } from "./kiro"
9
+ import { syncToOpenClaw } from "./openclaw"
10
+ import { syncToOpenCode } from "./opencode"
11
+ import { syncToPi } from "./pi"
12
+ import { syncToQwen } from "./qwen"
13
+ import { syncToWindsurf } from "./windsurf"
14
+
15
+ function getCopilotHomeRoot(home: string): string {
16
+ return path.join(home, ".copilot")
17
+ }
18
+
19
+ function getGeminiHomeRoot(home: string): string {
20
+ return path.join(home, ".gemini")
21
+ }
22
+
23
+ export type SyncTargetName =
24
+ | "opencode"
25
+ | "codex"
26
+ | "pi"
27
+ | "droid"
28
+ | "copilot"
29
+ | "gemini"
30
+ | "windsurf"
31
+ | "kiro"
32
+ | "qwen"
33
+ | "openclaw"
34
+
35
+ export type SyncTargetDefinition = {
36
+ name: SyncTargetName
37
+ detectPaths: (home: string, cwd: string) => string[]
38
+ resolveOutputRoot: (home: string, cwd: string) => string
39
+ sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
40
+ }
41
+
42
+ export const syncTargets: SyncTargetDefinition[] = [
43
+ {
44
+ name: "opencode",
45
+ detectPaths: (home, cwd) => [
46
+ path.join(home, ".config", "opencode"),
47
+ path.join(cwd, ".opencode"),
48
+ ],
49
+ resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
50
+ sync: syncToOpenCode,
51
+ },
52
+ {
53
+ name: "codex",
54
+ detectPaths: (home) => [path.join(home, ".codex")],
55
+ resolveOutputRoot: (home) => path.join(home, ".codex"),
56
+ sync: syncToCodex,
57
+ },
58
+ {
59
+ name: "pi",
60
+ detectPaths: (home) => [path.join(home, ".pi")],
61
+ resolveOutputRoot: (home) => path.join(home, ".pi", "agent"),
62
+ sync: syncToPi,
63
+ },
64
+ {
65
+ name: "droid",
66
+ detectPaths: (home) => [path.join(home, ".factory")],
67
+ resolveOutputRoot: (home) => path.join(home, ".factory"),
68
+ sync: syncToDroid,
69
+ },
70
+ {
71
+ name: "copilot",
72
+ detectPaths: (home, cwd) => [
73
+ getCopilotHomeRoot(home),
74
+ path.join(cwd, ".github", "skills"),
75
+ path.join(cwd, ".github", "agents"),
76
+ path.join(cwd, ".github", "copilot-instructions.md"),
77
+ ],
78
+ resolveOutputRoot: (home) => getCopilotHomeRoot(home),
79
+ sync: syncToCopilot,
80
+ },
81
+ {
82
+ name: "gemini",
83
+ detectPaths: (home, cwd) => [
84
+ path.join(cwd, ".gemini"),
85
+ getGeminiHomeRoot(home),
86
+ ],
87
+ resolveOutputRoot: (home) => getGeminiHomeRoot(home),
88
+ sync: syncToGemini,
89
+ },
90
+ {
91
+ name: "windsurf",
92
+ detectPaths: (home, cwd) => [
93
+ path.join(home, ".codeium", "windsurf"),
94
+ path.join(cwd, ".windsurf"),
95
+ ],
96
+ resolveOutputRoot: (home) => path.join(home, ".codeium", "windsurf"),
97
+ sync: syncToWindsurf,
98
+ },
99
+ {
100
+ name: "kiro",
101
+ detectPaths: (home, cwd) => [
102
+ path.join(home, ".kiro"),
103
+ path.join(cwd, ".kiro"),
104
+ ],
105
+ resolveOutputRoot: (home) => path.join(home, ".kiro"),
106
+ sync: syncToKiro,
107
+ },
108
+ {
109
+ name: "qwen",
110
+ detectPaths: (home, cwd) => [
111
+ path.join(home, ".qwen"),
112
+ path.join(cwd, ".qwen"),
113
+ ],
114
+ resolveOutputRoot: (home) => path.join(home, ".qwen"),
115
+ sync: syncToQwen,
116
+ },
117
+ {
118
+ name: "openclaw",
119
+ detectPaths: (home) => [path.join(home, ".openclaw")],
120
+ resolveOutputRoot: (home) => path.join(home, ".openclaw"),
121
+ sync: syncToOpenClaw,
122
+ },
123
+ ]
124
+
125
+ export const syncTargetNames = syncTargets.map((target) => target.name)
126
+
127
+ export function isSyncTargetName(value: string): value is SyncTargetName {
128
+ return syncTargetNames.includes(value as SyncTargetName)
129
+ }
130
+
131
+ export function getSyncTarget(name: SyncTargetName): SyncTargetDefinition {
132
+ const target = syncTargets.find((entry) => entry.name === name)
133
+ if (!target) {
134
+ throw new Error(`Unknown sync target: ${name}`)
135
+ }
136
+ return target
137
+ }
138
+
139
+ export function getDefaultSyncRegistryContext(): { home: string; cwd: string } {
140
+ return { home: os.homedir(), cwd: process.cwd() }
141
+ }
@@ -0,0 +1,21 @@
1
+ import path from "path"
2
+ import type { ClaudeSkill } from "../types/claude"
3
+ import { ensureDir } from "../utils/files"
4
+ import { forceSymlink, isValidSkillName } from "../utils/symlink"
5
+
6
+ export async function syncSkills(
7
+ skills: ClaudeSkill[],
8
+ skillsDir: string,
9
+ ): Promise<void> {
10
+ await ensureDir(skillsDir)
11
+
12
+ for (const skill of skills) {
13
+ if (!isValidSkillName(skill.name)) {
14
+ console.warn(`Skipping skill with invalid name: ${skill.name}`)
15
+ continue
16
+ }
17
+
18
+ const target = path.join(skillsDir, skill.name)
19
+ await forceSymlink(skill.sourceDir, target)
20
+ }
21
+ }
@@ -0,0 +1,59 @@
1
+ import path from "path"
2
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
3
+ import type { ClaudeMcpServer } from "../types/claude"
4
+ import type { WindsurfMcpServerEntry } from "../types/windsurf"
5
+ import { syncWindsurfCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { hasExplicitSseTransport } from "./mcp-transports"
8
+ import { syncSkills } from "./skills"
9
+
10
+ export async function syncToWindsurf(
11
+ config: ClaudeHomeConfig,
12
+ outputRoot: string,
13
+ ): Promise<void> {
14
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
15
+ await syncWindsurfCommands(config, outputRoot, "global")
16
+
17
+ if (Object.keys(config.mcpServers).length > 0) {
18
+ await mergeJsonConfigAtKey({
19
+ configPath: path.join(outputRoot, "mcp_config.json"),
20
+ key: "mcpServers",
21
+ incoming: convertMcpForWindsurf(config.mcpServers),
22
+ })
23
+ }
24
+ }
25
+
26
+ function convertMcpForWindsurf(
27
+ servers: Record<string, ClaudeMcpServer>,
28
+ ): Record<string, WindsurfMcpServerEntry> {
29
+ const result: Record<string, WindsurfMcpServerEntry> = {}
30
+
31
+ for (const [name, server] of Object.entries(servers)) {
32
+ if (server.command) {
33
+ result[name] = {
34
+ command: server.command,
35
+ args: server.args,
36
+ env: server.env,
37
+ }
38
+ continue
39
+ }
40
+
41
+ if (!server.url) {
42
+ continue
43
+ }
44
+
45
+ const entry: WindsurfMcpServerEntry = {
46
+ headers: server.headers,
47
+ }
48
+
49
+ if (hasExplicitSseTransport(server)) {
50
+ entry.url = server.url
51
+ } else {
52
+ entry.serverUrl = server.url
53
+ }
54
+
55
+ result[name] = entry
56
+ }
57
+
58
+ return result
59
+ }
package/src/types/kiro.ts CHANGED
@@ -30,9 +30,11 @@ export type KiroSteeringFile = {
30
30
  }
31
31
 
32
32
  export type KiroMcpServer = {
33
- command: string
33
+ command?: string
34
34
  args?: string[]
35
35
  env?: Record<string, string>
36
+ url?: string
37
+ headers?: Record<string, string>
36
38
  }
37
39
 
38
40
  export type KiroBundle = {
package/src/types/qwen.ts CHANGED
@@ -14,6 +14,9 @@ export type QwenMcpServer = {
14
14
  args?: string[]
15
15
  env?: Record<string, string>
16
16
  cwd?: string
17
+ httpUrl?: string
18
+ url?: string
19
+ headers?: Record<string, string>
17
20
  }
18
21
 
19
22
  export type QwenSetting = {
@@ -19,6 +19,7 @@ export type WindsurfMcpServerEntry = {
19
19
  args?: string[]
20
20
  env?: Record<string, string>
21
21
  serverUrl?: string
22
+ url?: string
22
23
  headers?: Record<string, string>
23
24
  }
24
25
 
@@ -18,7 +18,7 @@ Tool mapping:
18
18
  - Glob: use rg --files or find
19
19
  - LS: use ls via shell_command
20
20
  - WebFetch/WebSearch: use curl or Context7 for library docs
21
- - AskUserQuestion/Question: ask the user in chat
21
+ - AskUserQuestion/Question: present choices as a numbered list in chat and wait for a reply number. For multi-select (multiSelect: true), accept comma-separated numbers. Never skip or auto-configure — always wait for the user's response before proceeding.
22
22
  - Task/Subagent/Parallel: run sequentially in main thread; use multi_tool_use.parallel for tool calls
23
23
  - TodoWrite/TodoRead: use file-based todos in todos/ with file-todos skill
24
24
  - Skill: open the referenced SKILL.md and follow it
@@ -1,6 +1,6 @@
1
1
  import os from "os"
2
- import path from "path"
3
2
  import { pathExists } from "./files"
3
+ import { syncTargets } from "../sync/registry"
4
4
 
5
5
  export type DetectedTool = {
6
6
  name: string
@@ -12,27 +12,18 @@ export async function detectInstalledTools(
12
12
  home: string = os.homedir(),
13
13
  cwd: string = process.cwd(),
14
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
15
  const results: DetectedTool[] = []
25
- for (const check of checks) {
16
+ for (const target of syncTargets) {
26
17
  let detected = false
27
18
  let reason = "not found"
28
- for (const p of check.paths) {
19
+ for (const p of target.detectPaths(home, cwd)) {
29
20
  if (await pathExists(p)) {
30
21
  detected = true
31
22
  reason = `found ${p}`
32
23
  break
33
24
  }
34
25
  }
35
- results.push({ name: check.name, detected, reason })
26
+ results.push({ name: target.name, detected, reason })
36
27
  }
37
28
  return results
38
29
  }
@@ -41,6 +41,12 @@ export async function writeText(filePath: string, content: string): Promise<void
41
41
  await fs.writeFile(filePath, content, "utf8")
42
42
  }
43
43
 
44
+ export async function writeTextSecure(filePath: string, content: string): Promise<void> {
45
+ await ensureDir(path.dirname(filePath))
46
+ await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 })
47
+ await fs.chmod(filePath, 0o600)
48
+ }
49
+
44
50
  export async function writeJson(filePath: string, data: unknown): Promise<void> {
45
51
  const content = JSON.stringify(data, null, 2)
46
52
  await writeText(filePath, content + "\n")
@@ -51,6 +57,7 @@ export async function writeJsonSecure(filePath: string, data: unknown): Promise<
51
57
  const content = JSON.stringify(data, null, 2)
52
58
  await ensureDir(path.dirname(filePath))
53
59
  await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
60
+ await fs.chmod(filePath, 0o600)
54
61
  }
55
62
 
56
63
  export async function walkFiles(root: string): Promise<string[]> {
@@ -2,7 +2,7 @@ import fs from "fs/promises"
2
2
 
3
3
  /**
4
4
  * Create a symlink, safely replacing any existing symlink at target.
5
- * Only removes existing symlinks - refuses to delete real directories.
5
+ * Only removes existing symlinks - skips real directories with a warning.
6
6
  */
7
7
  export async function forceSymlink(source: string, target: string): Promise<void> {
8
8
  try {
@@ -11,11 +11,9 @@ export async function forceSymlink(source: string, target: string): Promise<void
11
11
  // Safe to remove existing symlink
12
12
  await fs.unlink(target)
13
13
  } else if (stat.isDirectory()) {
14
- // Refuse to delete real directories
15
- throw new Error(
16
- `Cannot create symlink at ${target}: a real directory exists there. ` +
17
- `Remove it manually if you want to replace it with a symlink.`
18
- )
14
+ // Skip real directories rather than deleting them
15
+ console.warn(`Skipping ${target}: a real directory exists there (remove it manually to replace with a symlink).`)
16
+ return
19
17
  } else {
20
18
  // Regular file - remove it
21
19
  await fs.unlink(target)
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { promises as fs } from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import { loadClaudeHome } from "../src/parsers/claude-home"
6
+
7
+ describe("loadClaudeHome", () => {
8
+ test("loads personal skills, commands, and MCP servers", async () => {
9
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-"))
10
+ const skillDir = path.join(tempHome, "skills", "reviewer")
11
+ const commandsDir = path.join(tempHome, "commands")
12
+
13
+ await fs.mkdir(skillDir, { recursive: true })
14
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: reviewer\n---\nReview things.\n")
15
+
16
+ await fs.mkdir(path.join(commandsDir, "workflows"), { recursive: true })
17
+ await fs.writeFile(
18
+ path.join(commandsDir, "workflows", "plan.md"),
19
+ "---\ndescription: Planning command\nargument-hint: \"[feature]\"\n---\nPlan the work.\n",
20
+ )
21
+ await fs.writeFile(
22
+ path.join(commandsDir, "custom.md"),
23
+ "---\nname: custom-command\ndescription: Custom command\nallowed-tools: Bash, Read\n---\nDo custom work.\n",
24
+ )
25
+
26
+ await fs.writeFile(
27
+ path.join(tempHome, "settings.json"),
28
+ JSON.stringify({
29
+ mcpServers: {
30
+ context7: { url: "https://mcp.context7.com/mcp" },
31
+ },
32
+ }),
33
+ )
34
+
35
+ const config = await loadClaudeHome(tempHome)
36
+
37
+ expect(config.skills.map((skill) => skill.name)).toEqual(["reviewer"])
38
+ expect(config.commands?.map((command) => command.name)).toEqual([
39
+ "custom-command",
40
+ "workflows:plan",
41
+ ])
42
+ expect(config.commands?.find((command) => command.name === "workflows:plan")?.argumentHint).toBe("[feature]")
43
+ expect(config.commands?.find((command) => command.name === "custom-command")?.allowedTools).toEqual(["Bash", "Read"])
44
+ expect(config.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
45
+ })
46
+ })
package/tests/cli.test.ts CHANGED
@@ -504,4 +504,106 @@ describe("CLI", () => {
504
504
  expect(json).toHaveProperty("permission")
505
505
  expect(json.permission).not.toBeNull()
506
506
  })
507
+
508
+ test("sync --target all detects new sync targets and ignores stale cursor directories", async () => {
509
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-home-"))
510
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-cwd-"))
511
+ const repoRoot = path.join(import.meta.dir, "..")
512
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
513
+ const claudeSkillsDir = path.join(tempHome, ".claude", "skills", "skill-one")
514
+ const claudeCommandsDir = path.join(tempHome, ".claude", "commands", "workflows")
515
+
516
+ await fs.mkdir(path.dirname(claudeSkillsDir), { recursive: true })
517
+ await fs.cp(fixtureSkillDir, claudeSkillsDir, { recursive: true })
518
+ await fs.mkdir(claudeCommandsDir, { recursive: true })
519
+ await fs.writeFile(
520
+ path.join(claudeCommandsDir, "plan.md"),
521
+ [
522
+ "---",
523
+ "name: workflows:plan",
524
+ "description: Plan work",
525
+ "argument-hint: \"[goal]\"",
526
+ "---",
527
+ "",
528
+ "Plan the work.",
529
+ ].join("\n"),
530
+ )
531
+ await fs.writeFile(
532
+ path.join(tempHome, ".claude", "settings.json"),
533
+ JSON.stringify({
534
+ mcpServers: {
535
+ local: { command: "echo", args: ["hello"] },
536
+ remote: { url: "https://example.com/mcp" },
537
+ legacy: { type: "sse", url: "https://example.com/sse" },
538
+ },
539
+ }, null, 2),
540
+ )
541
+
542
+ await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
543
+ await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
544
+ await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
545
+ await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
546
+ await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
547
+ await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
548
+ await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
549
+ await fs.mkdir(path.join(tempHome, ".kiro"), { recursive: true })
550
+ await fs.mkdir(path.join(tempHome, ".qwen"), { recursive: true })
551
+ await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
552
+ await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
553
+
554
+ const proc = Bun.spawn([
555
+ "bun",
556
+ "run",
557
+ path.join(repoRoot, "src", "index.ts"),
558
+ "sync",
559
+ "--target",
560
+ "all",
561
+ ], {
562
+ cwd: tempCwd,
563
+ stdout: "pipe",
564
+ stderr: "pipe",
565
+ env: {
566
+ ...process.env,
567
+ HOME: tempHome,
568
+ },
569
+ })
570
+
571
+ const exitCode = await proc.exited
572
+ const stdout = await new Response(proc.stdout).text()
573
+ const stderr = await new Response(proc.stderr).text()
574
+
575
+ if (exitCode !== 0) {
576
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
577
+ }
578
+
579
+ expect(stdout).toContain("Synced to codex")
580
+ expect(stdout).toContain("Synced to opencode")
581
+ expect(stdout).toContain("Synced to pi")
582
+ expect(stdout).toContain("Synced to droid")
583
+ expect(stdout).toContain("Synced to windsurf")
584
+ expect(stdout).toContain("Synced to kiro")
585
+ expect(stdout).toContain("Synced to qwen")
586
+ expect(stdout).toContain("Synced to openclaw")
587
+ expect(stdout).toContain("Synced to copilot")
588
+ expect(stdout).toContain("Synced to gemini")
589
+ expect(stdout).not.toContain("cursor")
590
+
591
+ expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true)
592
+ expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true)
593
+ expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true)
594
+ expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
595
+ expect(await exists(path.join(tempHome, ".pi", "agent", "prompts", "workflows-plan.md"))).toBe(true)
596
+ expect(await exists(path.join(tempHome, ".factory", "commands", "plan.md"))).toBe(true)
597
+ expect(await exists(path.join(tempHome, ".codeium", "windsurf", "mcp_config.json"))).toBe(true)
598
+ expect(await exists(path.join(tempHome, ".codeium", "windsurf", "global_workflows", "workflows-plan.md"))).toBe(true)
599
+ expect(await exists(path.join(tempHome, ".kiro", "settings", "mcp.json"))).toBe(true)
600
+ expect(await exists(path.join(tempHome, ".kiro", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
601
+ expect(await exists(path.join(tempHome, ".qwen", "settings.json"))).toBe(true)
602
+ expect(await exists(path.join(tempHome, ".qwen", "commands", "workflows", "plan.md"))).toBe(true)
603
+ expect(await exists(path.join(tempHome, ".copilot", "mcp-config.json"))).toBe(true)
604
+ expect(await exists(path.join(tempHome, ".copilot", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
605
+ expect(await exists(path.join(tempHome, ".gemini", "settings.json"))).toBe(true)
606
+ expect(await exists(path.join(tempHome, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
607
+ expect(await exists(path.join(tempHome, ".openclaw", "skills", "skill-one"))).toBe(true)
608
+ })
507
609
  })
@@ -11,8 +11,9 @@ describe("detectInstalledTools", () => {
11
11
 
12
12
  // Create directories for some tools
13
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 })
14
+ await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
15
+ await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
16
+ await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
16
17
 
17
18
  const results = await detectInstalledTools(tempHome, tempCwd)
18
19
 
@@ -20,14 +21,18 @@ describe("detectInstalledTools", () => {
20
21
  expect(codex?.detected).toBe(true)
21
22
  expect(codex?.reason).toContain(".codex")
22
23
 
23
- const cursor = results.find((t) => t.name === "cursor")
24
- expect(cursor?.detected).toBe(true)
25
- expect(cursor?.reason).toContain(".cursor")
24
+ const windsurf = results.find((t) => t.name === "windsurf")
25
+ expect(windsurf?.detected).toBe(true)
26
+ expect(windsurf?.reason).toContain(".codeium/windsurf")
26
27
 
27
28
  const gemini = results.find((t) => t.name === "gemini")
28
29
  expect(gemini?.detected).toBe(true)
29
30
  expect(gemini?.reason).toContain(".gemini")
30
31
 
32
+ const copilot = results.find((t) => t.name === "copilot")
33
+ expect(copilot?.detected).toBe(true)
34
+ expect(copilot?.reason).toContain(".copilot")
35
+
31
36
  // Tools without directories should not be detected
32
37
  const opencode = results.find((t) => t.name === "opencode")
33
38
  expect(opencode?.detected).toBe(false)
@@ -45,7 +50,7 @@ describe("detectInstalledTools", () => {
45
50
 
46
51
  const results = await detectInstalledTools(tempHome, tempCwd)
47
52
 
48
- expect(results.length).toBe(6)
53
+ expect(results.length).toBe(10)
49
54
  for (const tool of results) {
50
55
  expect(tool.detected).toBe(false)
51
56
  expect(tool.reason).toBe("not found")
@@ -59,12 +64,30 @@ describe("detectInstalledTools", () => {
59
64
  await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
60
65
  await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
61
66
  await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
67
+ await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
62
68
 
63
69
  const results = await detectInstalledTools(tempHome, tempCwd)
64
70
 
65
71
  expect(results.find((t) => t.name === "opencode")?.detected).toBe(true)
66
72
  expect(results.find((t) => t.name === "droid")?.detected).toBe(true)
67
73
  expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
74
+ expect(results.find((t) => t.name === "openclaw")?.detected).toBe(true)
75
+ })
76
+
77
+ test("detects copilot from project-specific skills without generic .github false positives", async () => {
78
+ const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-home-"))
79
+ const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-cwd-"))
80
+
81
+ await fs.mkdir(path.join(tempCwd, ".github"), { recursive: true })
82
+
83
+ let results = await detectInstalledTools(tempHome, tempCwd)
84
+ expect(results.find((t) => t.name === "copilot")?.detected).toBe(false)
85
+
86
+ await fs.mkdir(path.join(tempCwd, ".github", "skills"), { recursive: true })
87
+
88
+ results = await detectInstalledTools(tempHome, tempCwd)
89
+ expect(results.find((t) => t.name === "copilot")?.detected).toBe(true)
90
+ expect(results.find((t) => t.name === "copilot")?.reason).toContain(".github/skills")
68
91
  })
69
92
  })
70
93
 
@@ -74,7 +97,7 @@ describe("getDetectedTargetNames", () => {
74
97
  const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-"))
75
98
 
76
99
  await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
77
- await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true })
100
+ await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
78
101
 
79
102
  const names = await getDetectedTargetNames(tempHome, tempCwd)
80
103
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { promises as fs } from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
6
+ import { syncToCodex } from "../src/sync/codex"
7
+
8
+ describe("syncToCodex", () => {
9
+ test("writes stdio and remote MCP servers into a managed block without clobbering user config", async () => {
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-"))
11
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
12
+ const configPath = path.join(tempRoot, "config.toml")
13
+
14
+ await fs.writeFile(
15
+ configPath,
16
+ [
17
+ "[custom]",
18
+ "enabled = true",
19
+ "",
20
+ "# BEGIN compound-plugin Claude Code MCP",
21
+ "[mcp_servers.old]",
22
+ "command = \"old\"",
23
+ "# END compound-plugin Claude Code MCP",
24
+ "",
25
+ "[post]",
26
+ "value = 2",
27
+ "",
28
+ ].join("\n"),
29
+ )
30
+
31
+ const config: ClaudeHomeConfig = {
32
+ skills: [
33
+ {
34
+ name: "skill-one",
35
+ sourceDir: fixtureSkillDir,
36
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
37
+ },
38
+ ],
39
+ mcpServers: {
40
+ local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
41
+ remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
42
+ },
43
+ }
44
+
45
+ await syncToCodex(config, tempRoot)
46
+
47
+ const skillPath = path.join(tempRoot, "skills", "skill-one")
48
+ expect((await fs.lstat(skillPath)).isSymbolicLink()).toBe(true)
49
+
50
+ const content = await fs.readFile(configPath, "utf8")
51
+ expect(content).toContain("[custom]")
52
+ expect(content).toContain("[post]")
53
+ expect(content).not.toContain("[mcp_servers.old]")
54
+ expect(content).toContain("[mcp_servers.local]")
55
+ expect(content).toContain("command = \"echo\"")
56
+ expect(content).toContain("[mcp_servers.remote]")
57
+ expect(content).toContain("url = \"https://example.com/mcp\"")
58
+ expect(content).toContain("http_headers")
59
+ expect(content.match(/# BEGIN compound-plugin Claude Code MCP/g)?.length).toBe(1)
60
+
61
+ const perms = (await fs.stat(configPath)).mode & 0o777
62
+ expect(perms).toBe(0o600)
63
+ })
64
+ })