@every-env/compound-plugin 0.12.0 → 2.34.3
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/.github/workflows/publish.yml +20 -10
- package/.releaserc.json +36 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +14 -0
- package/README.md +35 -2
- package/bun.lock +977 -0
- package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
- package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
- package/docs/solutions/adding-converter-target-providers.md +2 -1
- package/docs/solutions/plugin-versioning-requirements.md +4 -0
- package/package.json +10 -4
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/CHANGELOG.md +10 -0
- package/plugins/compound-engineering/CLAUDE.md +5 -0
- package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
- package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
- package/plugins/compound-engineering/skills/setup/SKILL.md +6 -0
- package/src/commands/sync.ts +21 -60
- package/src/index.ts +2 -1
- package/src/parsers/claude-home.ts +55 -3
- package/src/sync/codex.ts +38 -62
- package/src/sync/commands.ts +198 -0
- package/src/sync/copilot.ts +14 -36
- package/src/sync/droid.ts +50 -9
- package/src/sync/gemini.ts +87 -28
- package/src/sync/json-config.ts +47 -0
- package/src/sync/kiro.ts +49 -0
- package/src/sync/mcp-transports.ts +19 -0
- package/src/sync/openclaw.ts +18 -0
- package/src/sync/opencode.ts +10 -30
- package/src/sync/pi.ts +12 -36
- package/src/sync/qwen.ts +66 -0
- package/src/sync/registry.ts +141 -0
- package/src/sync/skills.ts +21 -0
- package/src/sync/windsurf.ts +59 -0
- package/src/types/kiro.ts +3 -1
- package/src/types/qwen.ts +3 -0
- package/src/types/windsurf.ts +1 -0
- package/src/utils/codex-agents.ts +1 -1
- package/src/utils/detect-tools.ts +4 -13
- package/src/utils/files.ts +7 -0
- package/src/utils/symlink.ts +4 -6
- package/tests/claude-home.test.ts +46 -0
- package/tests/cli.test.ts +102 -0
- package/tests/detect-tools.test.ts +30 -7
- package/tests/sync-codex.test.ts +64 -0
- package/tests/sync-copilot.test.ts +60 -4
- package/tests/sync-droid.test.ts +44 -4
- package/tests/sync-gemini.test.ts +54 -0
- package/tests/sync-kiro.test.ts +83 -0
- package/tests/sync-openclaw.test.ts +51 -0
- package/tests/sync-qwen.test.ts +75 -0
- package/tests/sync-windsurf.test.ts +89 -0
|
@@ -28,6 +28,34 @@ describe("syncToCopilot", () => {
|
|
|
28
28
|
expect(linkedStat.isSymbolicLink()).toBe(true)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
test("converts personal commands into Copilot skills", async () => {
|
|
32
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-cmd-"))
|
|
33
|
+
|
|
34
|
+
const config: ClaudeHomeConfig = {
|
|
35
|
+
skills: [],
|
|
36
|
+
commands: [
|
|
37
|
+
{
|
|
38
|
+
name: "workflows:plan",
|
|
39
|
+
description: "Planning command",
|
|
40
|
+
argumentHint: "[goal]",
|
|
41
|
+
body: "Plan the work carefully.",
|
|
42
|
+
sourcePath: "/tmp/workflows/plan.md",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
mcpServers: {},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await syncToCopilot(config, tempRoot)
|
|
49
|
+
|
|
50
|
+
const skillContent = await fs.readFile(
|
|
51
|
+
path.join(tempRoot, "skills", "workflows-plan", "SKILL.md"),
|
|
52
|
+
"utf8",
|
|
53
|
+
)
|
|
54
|
+
expect(skillContent).toContain("name: workflows-plan")
|
|
55
|
+
expect(skillContent).toContain("Planning command")
|
|
56
|
+
expect(skillContent).toContain("## Arguments")
|
|
57
|
+
})
|
|
58
|
+
|
|
31
59
|
test("skips skills with invalid names", async () => {
|
|
32
60
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
|
|
33
61
|
|
|
@@ -51,7 +79,7 @@ describe("syncToCopilot", () => {
|
|
|
51
79
|
|
|
52
80
|
test("merges MCP config with existing file", async () => {
|
|
53
81
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
|
|
54
|
-
const mcpPath = path.join(tempRoot, "
|
|
82
|
+
const mcpPath = path.join(tempRoot, "mcp-config.json")
|
|
55
83
|
|
|
56
84
|
await fs.writeFile(
|
|
57
85
|
mcpPath,
|
|
@@ -77,6 +105,7 @@ describe("syncToCopilot", () => {
|
|
|
77
105
|
|
|
78
106
|
expect(merged.mcpServers.existing?.command).toBe("node")
|
|
79
107
|
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
108
|
+
expect(merged.mcpServers.context7?.type).toBe("http")
|
|
80
109
|
})
|
|
81
110
|
|
|
82
111
|
test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
|
|
@@ -95,7 +124,7 @@ describe("syncToCopilot", () => {
|
|
|
95
124
|
|
|
96
125
|
await syncToCopilot(config, tempRoot)
|
|
97
126
|
|
|
98
|
-
const mcpPath = path.join(tempRoot, "
|
|
127
|
+
const mcpPath = path.join(tempRoot, "mcp-config.json")
|
|
99
128
|
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
|
|
100
129
|
mcpServers: Record<string, { env?: Record<string, string> }>
|
|
101
130
|
}
|
|
@@ -118,7 +147,7 @@ describe("syncToCopilot", () => {
|
|
|
118
147
|
|
|
119
148
|
await syncToCopilot(config, tempRoot)
|
|
120
149
|
|
|
121
|
-
const mcpPath = path.join(tempRoot, "
|
|
150
|
+
const mcpPath = path.join(tempRoot, "mcp-config.json")
|
|
122
151
|
const stat = await fs.stat(mcpPath)
|
|
123
152
|
// Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
|
|
124
153
|
const perms = stat.mode & 0o777
|
|
@@ -142,7 +171,34 @@ describe("syncToCopilot", () => {
|
|
|
142
171
|
|
|
143
172
|
await syncToCopilot(config, tempRoot)
|
|
144
173
|
|
|
145
|
-
const mcpExists = await fs.access(path.join(tempRoot, "
|
|
174
|
+
const mcpExists = await fs.access(path.join(tempRoot, "mcp-config.json")).then(() => true).catch(() => false)
|
|
146
175
|
expect(mcpExists).toBe(false)
|
|
147
176
|
})
|
|
177
|
+
|
|
178
|
+
test("preserves explicit SSE transport for legacy remote servers", async () => {
|
|
179
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-sse-"))
|
|
180
|
+
|
|
181
|
+
const config: ClaudeHomeConfig = {
|
|
182
|
+
skills: [],
|
|
183
|
+
mcpServers: {
|
|
184
|
+
legacy: {
|
|
185
|
+
type: "sse",
|
|
186
|
+
url: "https://example.com/sse",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await syncToCopilot(config, tempRoot)
|
|
192
|
+
|
|
193
|
+
const mcpPath = path.join(tempRoot, "mcp-config.json")
|
|
194
|
+
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
|
|
195
|
+
mcpServers: Record<string, { type?: string; url?: string }>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(mcpConfig.mcpServers.legacy).toEqual({
|
|
199
|
+
type: "sse",
|
|
200
|
+
tools: ["*"],
|
|
201
|
+
url: "https://example.com/sse",
|
|
202
|
+
})
|
|
203
|
+
})
|
|
148
204
|
})
|
package/tests/sync-droid.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { syncToDroid } from "../src/sync/droid"
|
|
|
6
6
|
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
|
|
7
7
|
|
|
8
8
|
describe("syncToDroid", () => {
|
|
9
|
-
test("symlinks skills to factory skills dir", async () => {
|
|
9
|
+
test("symlinks skills to factory skills dir and writes mcp.json", async () => {
|
|
10
10
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-"))
|
|
11
11
|
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
12
12
|
|
|
@@ -29,9 +29,49 @@ describe("syncToDroid", () => {
|
|
|
29
29
|
const linkedStat = await fs.lstat(linkedSkillPath)
|
|
30
30
|
expect(linkedStat.isSymbolicLink()).toBe(true)
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const mcpConfig = JSON.parse(
|
|
33
|
+
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
|
|
34
|
+
) as {
|
|
35
|
+
mcpServers: Record<string, { type: string; url?: string; disabled: boolean }>
|
|
36
|
+
}
|
|
37
|
+
expect(mcpConfig.mcpServers.context7?.type).toBe("http")
|
|
38
|
+
expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
39
|
+
expect(mcpConfig.mcpServers.context7?.disabled).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("merges existing mcp.json and overwrites same-named servers from Claude", async () => {
|
|
43
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-merge-"))
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
path.join(tempRoot, "mcp.json"),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
theme: "dark",
|
|
48
|
+
mcpServers: {
|
|
49
|
+
shared: { type: "http", url: "https://old.example.com", disabled: true },
|
|
50
|
+
existing: { type: "stdio", command: "node", disabled: false },
|
|
51
|
+
},
|
|
52
|
+
}, null, 2),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const config: ClaudeHomeConfig = {
|
|
56
|
+
skills: [],
|
|
57
|
+
mcpServers: {
|
|
58
|
+
shared: { url: "https://new.example.com" },
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await syncToDroid(config, tempRoot)
|
|
63
|
+
|
|
64
|
+
const mcpConfig = JSON.parse(
|
|
65
|
+
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
|
|
66
|
+
) as {
|
|
67
|
+
theme: string
|
|
68
|
+
mcpServers: Record<string, { type: string; url?: string; command?: string; disabled: boolean }>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expect(mcpConfig.theme).toBe("dark")
|
|
72
|
+
expect(mcpConfig.mcpServers.existing?.command).toBe("node")
|
|
73
|
+
expect(mcpConfig.mcpServers.shared?.url).toBe("https://new.example.com")
|
|
74
|
+
expect(mcpConfig.mcpServers.shared?.disabled).toBe(false)
|
|
35
75
|
})
|
|
36
76
|
|
|
37
77
|
test("skips skills with invalid names", async () => {
|
|
@@ -77,6 +77,33 @@ describe("syncToGemini", () => {
|
|
|
77
77
|
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
test("writes personal commands as Gemini TOML prompts", async () => {
|
|
81
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-cmd-"))
|
|
82
|
+
|
|
83
|
+
const config: ClaudeHomeConfig = {
|
|
84
|
+
skills: [],
|
|
85
|
+
commands: [
|
|
86
|
+
{
|
|
87
|
+
name: "workflows:plan",
|
|
88
|
+
description: "Planning command",
|
|
89
|
+
argumentHint: "[goal]",
|
|
90
|
+
body: "Plan the work carefully.",
|
|
91
|
+
sourcePath: "/tmp/workflows/plan.md",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
mcpServers: {},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await syncToGemini(config, tempRoot)
|
|
98
|
+
|
|
99
|
+
const content = await fs.readFile(
|
|
100
|
+
path.join(tempRoot, "commands", "workflows", "plan.toml"),
|
|
101
|
+
"utf8",
|
|
102
|
+
)
|
|
103
|
+
expect(content).toContain("Planning command")
|
|
104
|
+
expect(content).toContain("User request: {{args}}")
|
|
105
|
+
})
|
|
106
|
+
|
|
80
107
|
test("does not write settings.json when no MCP servers", async () => {
|
|
81
108
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-"))
|
|
82
109
|
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
@@ -103,4 +130,31 @@ describe("syncToGemini", () => {
|
|
|
103
130
|
const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false)
|
|
104
131
|
expect(settingsExists).toBe(false)
|
|
105
132
|
})
|
|
133
|
+
|
|
134
|
+
test("skips mirrored ~/.agents skills when syncing to ~/.gemini and removes stale duplicate symlinks", async () => {
|
|
135
|
+
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-home-"))
|
|
136
|
+
const geminiRoot = path.join(tempHome, ".gemini")
|
|
137
|
+
const agentsSkillDir = path.join(tempHome, ".agents", "skills", "skill-one")
|
|
138
|
+
|
|
139
|
+
await fs.mkdir(path.join(agentsSkillDir), { recursive: true })
|
|
140
|
+
await fs.writeFile(path.join(agentsSkillDir, "SKILL.md"), "# Skill One\n", "utf8")
|
|
141
|
+
await fs.mkdir(path.join(geminiRoot, "skills"), { recursive: true })
|
|
142
|
+
await fs.symlink(agentsSkillDir, path.join(geminiRoot, "skills", "skill-one"))
|
|
143
|
+
|
|
144
|
+
const config: ClaudeHomeConfig = {
|
|
145
|
+
skills: [
|
|
146
|
+
{
|
|
147
|
+
name: "skill-one",
|
|
148
|
+
sourceDir: agentsSkillDir,
|
|
149
|
+
skillPath: path.join(agentsSkillDir, "SKILL.md"),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
mcpServers: {},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await syncToGemini(config, geminiRoot)
|
|
156
|
+
|
|
157
|
+
const duplicateExists = await fs.access(path.join(geminiRoot, "skills", "skill-one")).then(() => true).catch(() => false)
|
|
158
|
+
expect(duplicateExists).toBe(false)
|
|
159
|
+
})
|
|
106
160
|
})
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { syncToKiro } from "../src/sync/kiro"
|
|
7
|
+
|
|
8
|
+
describe("syncToKiro", () => {
|
|
9
|
+
test("writes user-scope settings/mcp.json with local and remote servers", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-"))
|
|
11
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
12
|
+
|
|
13
|
+
const config: ClaudeHomeConfig = {
|
|
14
|
+
skills: [
|
|
15
|
+
{
|
|
16
|
+
name: "skill-one",
|
|
17
|
+
sourceDir: fixtureSkillDir,
|
|
18
|
+
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
mcpServers: {
|
|
22
|
+
local: { command: "echo", args: ["hello"], env: { TOKEN: "secret" } },
|
|
23
|
+
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await syncToKiro(config, tempRoot)
|
|
28
|
+
|
|
29
|
+
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
|
|
30
|
+
|
|
31
|
+
const content = JSON.parse(
|
|
32
|
+
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
|
|
33
|
+
) as {
|
|
34
|
+
mcpServers: Record<string, {
|
|
35
|
+
command?: string
|
|
36
|
+
args?: string[]
|
|
37
|
+
env?: Record<string, string>
|
|
38
|
+
url?: string
|
|
39
|
+
headers?: Record<string, string>
|
|
40
|
+
}>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(content.mcpServers.local?.command).toBe("echo")
|
|
44
|
+
expect(content.mcpServers.local?.args).toEqual(["hello"])
|
|
45
|
+
expect(content.mcpServers.local?.env).toEqual({ TOKEN: "secret" })
|
|
46
|
+
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
|
|
47
|
+
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("merges existing settings/mcp.json", async () => {
|
|
51
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-merge-"))
|
|
52
|
+
await fs.mkdir(path.join(tempRoot, "settings"), { recursive: true })
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(tempRoot, "settings", "mcp.json"),
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
note: "preserve",
|
|
57
|
+
mcpServers: {
|
|
58
|
+
existing: { command: "node" },
|
|
59
|
+
},
|
|
60
|
+
}, null, 2),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const config: ClaudeHomeConfig = {
|
|
64
|
+
skills: [],
|
|
65
|
+
mcpServers: {
|
|
66
|
+
remote: { url: "https://example.com/mcp" },
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await syncToKiro(config, tempRoot)
|
|
71
|
+
|
|
72
|
+
const content = JSON.parse(
|
|
73
|
+
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
|
|
74
|
+
) as {
|
|
75
|
+
note: string
|
|
76
|
+
mcpServers: Record<string, { command?: string; url?: string }>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
expect(content.note).toBe("preserve")
|
|
80
|
+
expect(content.mcpServers.existing?.command).toBe("node")
|
|
81
|
+
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { syncToOpenClaw } from "../src/sync/openclaw"
|
|
7
|
+
|
|
8
|
+
describe("syncToOpenClaw", () => {
|
|
9
|
+
test("symlinks skills and warns instead of writing unvalidated MCP config", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-openclaw-"))
|
|
11
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
12
|
+
const warnings: string[] = []
|
|
13
|
+
const originalWarn = console.warn
|
|
14
|
+
console.warn = (message?: unknown) => {
|
|
15
|
+
warnings.push(String(message))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const config: ClaudeHomeConfig = {
|
|
20
|
+
skills: [
|
|
21
|
+
{
|
|
22
|
+
name: "skill-one",
|
|
23
|
+
sourceDir: fixtureSkillDir,
|
|
24
|
+
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
commands: [
|
|
28
|
+
{
|
|
29
|
+
name: "workflows:plan",
|
|
30
|
+
description: "Planning command",
|
|
31
|
+
body: "Plan the work.",
|
|
32
|
+
sourcePath: "/tmp/workflows/plan.md",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
mcpServers: {
|
|
36
|
+
remote: { url: "https://example.com/mcp" },
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await syncToOpenClaw(config, tempRoot)
|
|
41
|
+
} finally {
|
|
42
|
+
console.warn = originalWarn
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
|
|
46
|
+
const openclawConfigExists = await fs.access(path.join(tempRoot, "openclaw.json")).then(() => true).catch(() => false)
|
|
47
|
+
expect(openclawConfigExists).toBe(false)
|
|
48
|
+
expect(warnings.some((warning) => warning.includes("OpenClaw personal command sync is skipped"))).toBe(true)
|
|
49
|
+
expect(warnings.some((warning) => warning.includes("OpenClaw MCP sync is skipped"))).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { syncToQwen } from "../src/sync/qwen"
|
|
7
|
+
|
|
8
|
+
describe("syncToQwen", () => {
|
|
9
|
+
test("defaults ambiguous remote URLs to httpUrl and warns", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-"))
|
|
11
|
+
const warnings: string[] = []
|
|
12
|
+
const originalWarn = console.warn
|
|
13
|
+
console.warn = (message?: unknown) => {
|
|
14
|
+
warnings.push(String(message))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const config: ClaudeHomeConfig = {
|
|
19
|
+
skills: [],
|
|
20
|
+
mcpServers: {
|
|
21
|
+
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await syncToQwen(config, tempRoot)
|
|
26
|
+
} finally {
|
|
27
|
+
console.warn = originalWarn
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const content = JSON.parse(
|
|
31
|
+
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
|
|
32
|
+
) as {
|
|
33
|
+
mcpServers: Record<string, { httpUrl?: string; url?: string; headers?: Record<string, string> }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
expect(content.mcpServers.remote?.httpUrl).toBe("https://example.com/mcp")
|
|
37
|
+
expect(content.mcpServers.remote?.url).toBeUndefined()
|
|
38
|
+
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
|
|
39
|
+
expect(warnings.some((warning) => warning.includes("ambiguous remote transport"))).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("uses legacy url only for explicit SSE servers and preserves existing settings", async () => {
|
|
43
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-sse-"))
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
path.join(tempRoot, "settings.json"),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
theme: "dark",
|
|
48
|
+
mcpServers: {
|
|
49
|
+
existing: { command: "node" },
|
|
50
|
+
},
|
|
51
|
+
}, null, 2),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const config: ClaudeHomeConfig = {
|
|
55
|
+
skills: [],
|
|
56
|
+
mcpServers: {
|
|
57
|
+
legacy: { type: "sse", url: "https://example.com/sse" },
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await syncToQwen(config, tempRoot)
|
|
62
|
+
|
|
63
|
+
const content = JSON.parse(
|
|
64
|
+
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
|
|
65
|
+
) as {
|
|
66
|
+
theme: string
|
|
67
|
+
mcpServers: Record<string, { command?: string; httpUrl?: string; url?: string }>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expect(content.theme).toBe("dark")
|
|
71
|
+
expect(content.mcpServers.existing?.command).toBe("node")
|
|
72
|
+
expect(content.mcpServers.legacy?.url).toBe("https://example.com/sse")
|
|
73
|
+
expect(content.mcpServers.legacy?.httpUrl).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
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 { syncToWindsurf } from "../src/sync/windsurf"
|
|
7
|
+
|
|
8
|
+
describe("syncToWindsurf", () => {
|
|
9
|
+
test("writes stdio, http, and sse MCP servers", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-"))
|
|
11
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
12
|
+
|
|
13
|
+
const config: ClaudeHomeConfig = {
|
|
14
|
+
skills: [
|
|
15
|
+
{
|
|
16
|
+
name: "skill-one",
|
|
17
|
+
sourceDir: fixtureSkillDir,
|
|
18
|
+
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
mcpServers: {
|
|
22
|
+
local: { command: "npx", args: ["serve"], env: { FOO: "bar" } },
|
|
23
|
+
remoteHttp: { url: "https://example.com/mcp", headers: { Authorization: "Bearer a" } },
|
|
24
|
+
remoteSse: { type: "sse", url: "https://example.com/sse" },
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await syncToWindsurf(config, tempRoot)
|
|
29
|
+
|
|
30
|
+
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
|
|
31
|
+
|
|
32
|
+
const content = JSON.parse(
|
|
33
|
+
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
|
|
34
|
+
) as {
|
|
35
|
+
mcpServers: Record<string, {
|
|
36
|
+
command?: string
|
|
37
|
+
args?: string[]
|
|
38
|
+
env?: Record<string, string>
|
|
39
|
+
serverUrl?: string
|
|
40
|
+
url?: string
|
|
41
|
+
}>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
expect(content.mcpServers.local).toEqual({
|
|
45
|
+
command: "npx",
|
|
46
|
+
args: ["serve"],
|
|
47
|
+
env: { FOO: "bar" },
|
|
48
|
+
})
|
|
49
|
+
expect(content.mcpServers.remoteHttp?.serverUrl).toBe("https://example.com/mcp")
|
|
50
|
+
expect(content.mcpServers.remoteSse?.url).toBe("https://example.com/sse")
|
|
51
|
+
|
|
52
|
+
const perms = (await fs.stat(path.join(tempRoot, "mcp_config.json"))).mode & 0o777
|
|
53
|
+
expect(perms).toBe(0o600)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("merges existing config and overwrites same-named servers", async () => {
|
|
57
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-merge-"))
|
|
58
|
+
await fs.writeFile(
|
|
59
|
+
path.join(tempRoot, "mcp_config.json"),
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
theme: "dark",
|
|
62
|
+
mcpServers: {
|
|
63
|
+
existing: { command: "node" },
|
|
64
|
+
shared: { serverUrl: "https://old.example.com" },
|
|
65
|
+
},
|
|
66
|
+
}, null, 2),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const config: ClaudeHomeConfig = {
|
|
70
|
+
skills: [],
|
|
71
|
+
mcpServers: {
|
|
72
|
+
shared: { url: "https://new.example.com" },
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await syncToWindsurf(config, tempRoot)
|
|
77
|
+
|
|
78
|
+
const content = JSON.parse(
|
|
79
|
+
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
|
|
80
|
+
) as {
|
|
81
|
+
theme: string
|
|
82
|
+
mcpServers: Record<string, { command?: string; serverUrl?: string }>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(content.theme).toBe("dark")
|
|
86
|
+
expect(content.mcpServers.existing?.command).toBe("node")
|
|
87
|
+
expect(content.mcpServers.shared?.serverUrl).toBe("https://new.example.com")
|
|
88
|
+
})
|
|
89
|
+
})
|