@every-env/compound-plugin 0.9.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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +5 -1
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +3 -3
- package/README.md +49 -15
- package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
- package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
- package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
- package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
- package/docs/solutions/adding-converter-target-providers.md +692 -0
- package/docs/solutions/plugin-versioning-requirements.md +3 -3
- package/docs/specs/windsurf.md +477 -0
- package/package.json +1 -1
- package/plans/landing-page-launchkit-refresh.md +2 -2
- package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
- package/plugins/compound-engineering/CHANGELOG.md +72 -1
- package/plugins/compound-engineering/CLAUDE.md +9 -7
- package/plugins/compound-engineering/README.md +10 -7
- package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
- package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
- package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
- package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
- package/plugins/compound-engineering/commands/ce/compound.md +240 -0
- package/plugins/compound-engineering/commands/ce/plan.md +636 -0
- package/plugins/compound-engineering/commands/ce/review.md +525 -0
- package/plugins/compound-engineering/commands/ce/work.md +470 -0
- package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
- package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
- package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
- package/plugins/compound-engineering/commands/feature-video.md +15 -6
- package/plugins/compound-engineering/commands/heal-skill.md +1 -1
- package/plugins/compound-engineering/commands/lfg.md +3 -3
- package/plugins/compound-engineering/commands/slfg.md +3 -3
- package/plugins/compound-engineering/commands/test-xcode.md +2 -2
- package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
- package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
- package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
- package/plugins/compound-engineering/commands/workflows/review.md +4 -522
- package/plugins/compound-engineering/commands/workflows/work.md +4 -448
- package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
- package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
- package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
- package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/setup/SKILL.md +2 -2
- package/src/commands/convert.ts +101 -24
- package/src/commands/install.ts +102 -45
- package/src/commands/sync.ts +58 -38
- package/src/converters/claude-to-openclaw.ts +240 -0
- package/src/converters/claude-to-opencode.ts +12 -10
- package/src/converters/claude-to-qwen.ts +238 -0
- package/src/converters/claude-to-windsurf.ts +205 -0
- package/src/sync/gemini.ts +76 -0
- package/src/targets/index.ts +60 -1
- package/src/targets/openclaw.ts +96 -0
- package/src/targets/opencode.ts +76 -10
- package/src/targets/qwen.ts +64 -0
- package/src/targets/windsurf.ts +104 -0
- package/src/types/openclaw.ts +52 -0
- package/src/types/opencode.ts +7 -8
- package/src/types/qwen.ts +48 -0
- package/src/types/windsurf.ts +34 -0
- package/src/utils/detect-tools.ts +46 -0
- package/src/utils/files.ts +7 -0
- package/src/utils/resolve-output.ts +50 -0
- package/src/utils/secrets.ts +24 -0
- package/tests/cli.test.ts +78 -0
- package/tests/converter.test.ts +43 -10
- package/tests/detect-tools.test.ts +96 -0
- package/tests/openclaw-converter.test.ts +200 -0
- package/tests/opencode-writer.test.ts +142 -5
- package/tests/qwen-converter.test.ts +238 -0
- package/tests/resolve-output.test.ts +131 -0
- package/tests/sync-gemini.test.ts +106 -0
- package/tests/windsurf-converter.test.ts +573 -0
- package/tests/windsurf-writer.test.ts +359 -0
- package/docs/css/docs.css +0 -675
- package/docs/css/style.css +0 -2886
- package/docs/index.html +0 -1046
- package/docs/js/main.js +0 -225
- package/docs/pages/agents.html +0 -649
- package/docs/pages/changelog.html +0 -534
- package/docs/pages/commands.html +0 -523
- package/docs/pages/getting-started.html +0 -582
- package/docs/pages/mcp-servers.html +0 -409
- package/docs/pages/skills.html +0 -611
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { convertClaudeToQwen } from "../src/converters/claude-to-qwen"
|
|
3
|
+
import { parseFrontmatter } from "../src/utils/frontmatter"
|
|
4
|
+
import type { ClaudePlugin } from "../src/types/claude"
|
|
5
|
+
|
|
6
|
+
const fixturePlugin: ClaudePlugin = {
|
|
7
|
+
root: "/tmp/plugin",
|
|
8
|
+
manifest: { name: "compound-engineering", version: "1.2.0", description: "A plugin for engineers" },
|
|
9
|
+
agents: [
|
|
10
|
+
{
|
|
11
|
+
name: "security-sentinel",
|
|
12
|
+
description: "Security-focused agent",
|
|
13
|
+
capabilities: ["Threat modeling", "OWASP"],
|
|
14
|
+
model: "claude-sonnet-4-20250514",
|
|
15
|
+
body: "Focus on vulnerabilities in ~/.claude/settings.",
|
|
16
|
+
sourcePath: "/tmp/plugin/agents/security-sentinel.md",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "brainstorm-agent",
|
|
20
|
+
description: "Creative brainstormer",
|
|
21
|
+
model: "inherit",
|
|
22
|
+
body: "Generate ideas.",
|
|
23
|
+
sourcePath: "/tmp/plugin/agents/brainstorm-agent.md",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
commands: [
|
|
27
|
+
{
|
|
28
|
+
name: "workflows:plan",
|
|
29
|
+
description: "Planning command",
|
|
30
|
+
argumentHint: "[FOCUS]",
|
|
31
|
+
model: "inherit",
|
|
32
|
+
allowedTools: ["Read"],
|
|
33
|
+
body: "Plan the work. Config at ~/.claude/settings.",
|
|
34
|
+
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "disabled-cmd",
|
|
38
|
+
description: "Disabled",
|
|
39
|
+
model: "inherit",
|
|
40
|
+
allowedTools: [],
|
|
41
|
+
body: "Should be excluded.",
|
|
42
|
+
disableModelInvocation: true,
|
|
43
|
+
sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
skills: [
|
|
47
|
+
{
|
|
48
|
+
name: "existing-skill",
|
|
49
|
+
description: "Existing skill",
|
|
50
|
+
sourceDir: "/tmp/plugin/skills/existing-skill",
|
|
51
|
+
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
hooks: undefined,
|
|
55
|
+
mcpServers: {
|
|
56
|
+
local: { command: "npx", args: ["-y", "some-mcp"], env: { API_KEY: "${YOUR_API_KEY}" } },
|
|
57
|
+
remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultOptions = {
|
|
62
|
+
agentMode: "subagent" as const,
|
|
63
|
+
inferTemperature: false,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("convertClaudeToQwen", () => {
|
|
67
|
+
test("converts agents to yaml format with frontmatter", () => {
|
|
68
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
69
|
+
|
|
70
|
+
const agent = bundle.agents.find((a) => a.name === "security-sentinel")
|
|
71
|
+
expect(agent).toBeDefined()
|
|
72
|
+
expect(agent!.format).toBe("yaml")
|
|
73
|
+
const parsed = parseFrontmatter(agent!.content)
|
|
74
|
+
expect(parsed.data.name).toBe("security-sentinel")
|
|
75
|
+
expect(parsed.data.description).toBe("Security-focused agent")
|
|
76
|
+
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
|
|
77
|
+
expect(parsed.body).toContain("Focus on vulnerabilities")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("agent with inherit model has no model field in frontmatter", () => {
|
|
81
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
82
|
+
const agent = bundle.agents.find((a) => a.name === "brainstorm-agent")
|
|
83
|
+
expect(agent).toBeDefined()
|
|
84
|
+
const parsed = parseFrontmatter(agent!.content)
|
|
85
|
+
expect(parsed.data.model).toBeUndefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("inferTemperature injects temperature based on agent name/description", () => {
|
|
89
|
+
const bundle = convertClaudeToQwen(fixturePlugin, { ...defaultOptions, inferTemperature: true })
|
|
90
|
+
|
|
91
|
+
const sentinel = bundle.agents.find((a) => a.name === "security-sentinel")
|
|
92
|
+
const parsed = parseFrontmatter(sentinel!.content)
|
|
93
|
+
expect(parsed.data.temperature).toBe(0.1) // review/security → 0.1
|
|
94
|
+
|
|
95
|
+
const brainstorm = bundle.agents.find((a) => a.name === "brainstorm-agent")
|
|
96
|
+
const bParsed = parseFrontmatter(brainstorm!.content)
|
|
97
|
+
expect(bParsed.data.temperature).toBe(0.6) // brainstorm → 0.6
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("inferTemperature returns undefined for unrecognized agents (no temperature set)", () => {
|
|
101
|
+
const plugin: ClaudePlugin = {
|
|
102
|
+
...fixturePlugin,
|
|
103
|
+
agents: [{ name: "my-helper", description: "Generic helper", model: "inherit", body: "help", sourcePath: "/tmp/a.md" }],
|
|
104
|
+
}
|
|
105
|
+
const bundle = convertClaudeToQwen(plugin, { ...defaultOptions, inferTemperature: true })
|
|
106
|
+
const agent = bundle.agents[0]
|
|
107
|
+
const parsed = parseFrontmatter(agent.content)
|
|
108
|
+
expect(parsed.data.temperature).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("converts commands to command files excluding disableModelInvocation", () => {
|
|
112
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
113
|
+
|
|
114
|
+
const planCmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
|
|
115
|
+
expect(planCmd).toBeDefined()
|
|
116
|
+
const parsed = parseFrontmatter(planCmd!.content)
|
|
117
|
+
expect(parsed.data.description).toBe("Planning command")
|
|
118
|
+
expect(parsed.data.allowedTools).toEqual(["Read"])
|
|
119
|
+
|
|
120
|
+
const disabled = bundle.commandFiles.find((c) => c.name === "disabled-cmd")
|
|
121
|
+
expect(disabled).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("config uses plugin manifest name and version", () => {
|
|
125
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
126
|
+
expect(bundle.config.name).toBe("compound-engineering")
|
|
127
|
+
expect(bundle.config.version).toBe("1.2.0")
|
|
128
|
+
expect(bundle.config.commands).toBe("commands")
|
|
129
|
+
expect(bundle.config.skills).toBe("skills")
|
|
130
|
+
expect(bundle.config.agents).toBe("agents")
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("stdio MCP servers are included in config", () => {
|
|
134
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
135
|
+
expect(bundle.config.mcpServers).toBeDefined()
|
|
136
|
+
const local = bundle.config.mcpServers!.local
|
|
137
|
+
expect(local.command).toBe("npx")
|
|
138
|
+
expect(local.args).toEqual(["-y", "some-mcp"])
|
|
139
|
+
// No cwd field
|
|
140
|
+
expect((local as any).cwd).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("remote MCP servers are skipped with a warning (not converted to curl)", () => {
|
|
144
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
145
|
+
// Only local (stdio) server should be present
|
|
146
|
+
expect(bundle.config.mcpServers).toBeDefined()
|
|
147
|
+
expect(bundle.config.mcpServers!.remote).toBeUndefined()
|
|
148
|
+
expect(bundle.config.mcpServers!.local).toBeDefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("placeholder env vars are extracted as settings", () => {
|
|
152
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
153
|
+
expect(bundle.config.settings).toBeDefined()
|
|
154
|
+
const apiKeySetting = bundle.config.settings!.find((s) => s.envVar === "API_KEY")
|
|
155
|
+
expect(apiKeySetting).toBeDefined()
|
|
156
|
+
expect(apiKeySetting!.sensitive).toBe(true)
|
|
157
|
+
expect(apiKeySetting!.name).toBe("Api Key")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("plugin with no MCP servers has no mcpServers in config", () => {
|
|
161
|
+
const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
|
|
162
|
+
const bundle = convertClaudeToQwen(plugin, defaultOptions)
|
|
163
|
+
expect(bundle.config.mcpServers).toBeUndefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("context file uses plugin.manifest.name and manifest.description", () => {
|
|
167
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
168
|
+
expect(bundle.contextFile).toContain("# compound-engineering")
|
|
169
|
+
expect(bundle.contextFile).toContain("A plugin for engineers")
|
|
170
|
+
expect(bundle.contextFile).toContain("## Agents")
|
|
171
|
+
expect(bundle.contextFile).toContain("security-sentinel")
|
|
172
|
+
expect(bundle.contextFile).toContain("## Commands")
|
|
173
|
+
expect(bundle.contextFile).toContain("/workflows:plan")
|
|
174
|
+
// Disabled commands excluded
|
|
175
|
+
expect(bundle.contextFile).not.toContain("disabled-cmd")
|
|
176
|
+
expect(bundle.contextFile).toContain("## Skills")
|
|
177
|
+
expect(bundle.contextFile).toContain("existing-skill")
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("paths are rewritten from .claude/ to .qwen/ in agent and command content", () => {
|
|
181
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
182
|
+
|
|
183
|
+
const agent = bundle.agents.find((a) => a.name === "security-sentinel")
|
|
184
|
+
expect(agent!.content).toContain("~/.qwen/settings")
|
|
185
|
+
expect(agent!.content).not.toContain("~/.claude/settings")
|
|
186
|
+
|
|
187
|
+
const cmd = bundle.commandFiles.find((c) => c.name === "workflows:plan")
|
|
188
|
+
expect(cmd!.content).toContain("~/.qwen/settings")
|
|
189
|
+
expect(cmd!.content).not.toContain("~/.claude/settings")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("opencode paths are NOT rewritten (only claude paths)", () => {
|
|
193
|
+
const plugin: ClaudePlugin = {
|
|
194
|
+
...fixturePlugin,
|
|
195
|
+
agents: [
|
|
196
|
+
{
|
|
197
|
+
name: "test-agent",
|
|
198
|
+
description: "test",
|
|
199
|
+
model: "inherit",
|
|
200
|
+
body: "See .opencode/config and ~/.config/opencode/settings",
|
|
201
|
+
sourcePath: "/tmp/a.md",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
const bundle = convertClaudeToQwen(plugin, defaultOptions)
|
|
206
|
+
const agent = bundle.agents[0]
|
|
207
|
+
// opencode paths should NOT be rewritten
|
|
208
|
+
expect(agent.content).toContain(".opencode/config")
|
|
209
|
+
expect(agent.content).not.toContain(".qwen/config")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("skillDirs passes through original skills", () => {
|
|
213
|
+
const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions)
|
|
214
|
+
const skill = bundle.skillDirs.find((s) => s.name === "existing-skill")
|
|
215
|
+
expect(skill).toBeDefined()
|
|
216
|
+
expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test("normalizeModel prefixes claude models with anthropic/", () => {
|
|
220
|
+
const plugin: ClaudePlugin = {
|
|
221
|
+
...fixturePlugin,
|
|
222
|
+
agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }],
|
|
223
|
+
}
|
|
224
|
+
const bundle = convertClaudeToQwen(plugin, defaultOptions)
|
|
225
|
+
const parsed = parseFrontmatter(bundle.agents[0].content)
|
|
226
|
+
expect(parsed.data.model).toBe("anthropic/claude-opus-4-5")
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("normalizeModel passes through already-namespaced models unchanged", () => {
|
|
230
|
+
const plugin: ClaudePlugin = {
|
|
231
|
+
...fixturePlugin,
|
|
232
|
+
agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }],
|
|
233
|
+
}
|
|
234
|
+
const bundle = convertClaudeToQwen(plugin, defaultOptions)
|
|
235
|
+
const parsed = parseFrontmatter(bundle.agents[0].content)
|
|
236
|
+
expect(parsed.data.model).toBe("google/gemini-2.0")
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { resolveTargetOutputRoot } from "../src/utils/resolve-output"
|
|
5
|
+
|
|
6
|
+
const baseOptions = {
|
|
7
|
+
outputRoot: "/tmp/output",
|
|
8
|
+
codexHome: path.join(os.homedir(), ".codex"),
|
|
9
|
+
piHome: path.join(os.homedir(), ".pi", "agent"),
|
|
10
|
+
hasExplicitOutput: false,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("resolveTargetOutputRoot", () => {
|
|
14
|
+
test("codex returns codexHome", () => {
|
|
15
|
+
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "codex" })
|
|
16
|
+
expect(result).toBe(baseOptions.codexHome)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("pi returns piHome", () => {
|
|
20
|
+
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "pi" })
|
|
21
|
+
expect(result).toBe(baseOptions.piHome)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("droid returns ~/.factory", () => {
|
|
25
|
+
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "droid" })
|
|
26
|
+
expect(result).toBe(path.join(os.homedir(), ".factory"))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("cursor with no explicit output uses cwd", () => {
|
|
30
|
+
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "cursor" })
|
|
31
|
+
expect(result).toBe(path.join(process.cwd(), ".cursor"))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("cursor with explicit output uses outputRoot", () => {
|
|
35
|
+
const result = resolveTargetOutputRoot({
|
|
36
|
+
...baseOptions,
|
|
37
|
+
targetName: "cursor",
|
|
38
|
+
hasExplicitOutput: true,
|
|
39
|
+
})
|
|
40
|
+
expect(result).toBe(path.join("/tmp/output", ".cursor"))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("windsurf default scope (global) resolves to ~/.codeium/windsurf/", () => {
|
|
44
|
+
const result = resolveTargetOutputRoot({
|
|
45
|
+
...baseOptions,
|
|
46
|
+
targetName: "windsurf",
|
|
47
|
+
scope: "global",
|
|
48
|
+
})
|
|
49
|
+
expect(result).toBe(path.join(os.homedir(), ".codeium", "windsurf"))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("windsurf workspace scope resolves to cwd/.windsurf/", () => {
|
|
53
|
+
const result = resolveTargetOutputRoot({
|
|
54
|
+
...baseOptions,
|
|
55
|
+
targetName: "windsurf",
|
|
56
|
+
scope: "workspace",
|
|
57
|
+
})
|
|
58
|
+
expect(result).toBe(path.join(process.cwd(), ".windsurf"))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("windsurf with explicit output overrides global scope", () => {
|
|
62
|
+
const result = resolveTargetOutputRoot({
|
|
63
|
+
...baseOptions,
|
|
64
|
+
targetName: "windsurf",
|
|
65
|
+
hasExplicitOutput: true,
|
|
66
|
+
scope: "global",
|
|
67
|
+
})
|
|
68
|
+
expect(result).toBe("/tmp/output")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("windsurf with explicit output overrides workspace scope", () => {
|
|
72
|
+
const result = resolveTargetOutputRoot({
|
|
73
|
+
...baseOptions,
|
|
74
|
+
targetName: "windsurf",
|
|
75
|
+
hasExplicitOutput: true,
|
|
76
|
+
scope: "workspace",
|
|
77
|
+
})
|
|
78
|
+
expect(result).toBe("/tmp/output")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("windsurf with no scope and no explicit output uses cwd/.windsurf/", () => {
|
|
82
|
+
const result = resolveTargetOutputRoot({
|
|
83
|
+
...baseOptions,
|
|
84
|
+
targetName: "windsurf",
|
|
85
|
+
})
|
|
86
|
+
expect(result).toBe(path.join(process.cwd(), ".windsurf"))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("opencode returns outputRoot as-is", () => {
|
|
90
|
+
const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "opencode" })
|
|
91
|
+
expect(result).toBe("/tmp/output")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("openclaw uses openclawHome + pluginName", () => {
|
|
95
|
+
const result = resolveTargetOutputRoot({
|
|
96
|
+
...baseOptions,
|
|
97
|
+
targetName: "openclaw",
|
|
98
|
+
openclawHome: "/custom/openclaw/extensions",
|
|
99
|
+
pluginName: "my-plugin",
|
|
100
|
+
})
|
|
101
|
+
expect(result).toBe("/custom/openclaw/extensions/my-plugin")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("openclaw falls back to default home when not provided", () => {
|
|
105
|
+
const result = resolveTargetOutputRoot({
|
|
106
|
+
...baseOptions,
|
|
107
|
+
targetName: "openclaw",
|
|
108
|
+
pluginName: "my-plugin",
|
|
109
|
+
})
|
|
110
|
+
expect(result).toBe(path.join(os.homedir(), ".openclaw", "extensions", "my-plugin"))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("qwen uses qwenHome + pluginName", () => {
|
|
114
|
+
const result = resolveTargetOutputRoot({
|
|
115
|
+
...baseOptions,
|
|
116
|
+
targetName: "qwen",
|
|
117
|
+
qwenHome: "/custom/qwen/extensions",
|
|
118
|
+
pluginName: "my-plugin",
|
|
119
|
+
})
|
|
120
|
+
expect(result).toBe("/custom/qwen/extensions/my-plugin")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("qwen falls back to default home when not provided", () => {
|
|
124
|
+
const result = resolveTargetOutputRoot({
|
|
125
|
+
...baseOptions,
|
|
126
|
+
targetName: "qwen",
|
|
127
|
+
pluginName: "my-plugin",
|
|
128
|
+
})
|
|
129
|
+
expect(result).toBe(path.join(os.homedir(), ".qwen", "extensions", "my-plugin"))
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { syncToGemini } from "../src/sync/gemini"
|
|
6
|
+
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
|
|
7
|
+
|
|
8
|
+
describe("syncToGemini", () => {
|
|
9
|
+
test("symlinks skills and writes settings.json", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-"))
|
|
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
|
+
context7: { url: "https://mcp.context7.com/mcp" },
|
|
23
|
+
local: { command: "echo", args: ["hello"], env: { FOO: "bar" } },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await syncToGemini(config, tempRoot)
|
|
28
|
+
|
|
29
|
+
// Check skill symlink
|
|
30
|
+
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
|
|
31
|
+
const linkedStat = await fs.lstat(linkedSkillPath)
|
|
32
|
+
expect(linkedStat.isSymbolicLink()).toBe(true)
|
|
33
|
+
|
|
34
|
+
// Check settings.json
|
|
35
|
+
const settingsPath = path.join(tempRoot, "settings.json")
|
|
36
|
+
const settings = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
|
|
37
|
+
mcpServers: Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string> }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(settings.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
41
|
+
expect(settings.mcpServers.local?.command).toBe("echo")
|
|
42
|
+
expect(settings.mcpServers.local?.args).toEqual(["hello"])
|
|
43
|
+
expect(settings.mcpServers.local?.env).toEqual({ FOO: "bar" })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("merges existing settings.json", async () => {
|
|
47
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-merge-"))
|
|
48
|
+
const settingsPath = path.join(tempRoot, "settings.json")
|
|
49
|
+
|
|
50
|
+
await fs.writeFile(
|
|
51
|
+
settingsPath,
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
theme: "dark",
|
|
54
|
+
mcpServers: { existing: { command: "node", args: ["server.js"] } },
|
|
55
|
+
}, null, 2),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const config: ClaudeHomeConfig = {
|
|
59
|
+
skills: [],
|
|
60
|
+
mcpServers: {
|
|
61
|
+
context7: { url: "https://mcp.context7.com/mcp" },
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await syncToGemini(config, tempRoot)
|
|
66
|
+
|
|
67
|
+
const merged = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
|
|
68
|
+
theme: string
|
|
69
|
+
mcpServers: Record<string, { command?: string; url?: string }>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Preserves existing settings
|
|
73
|
+
expect(merged.theme).toBe("dark")
|
|
74
|
+
// Preserves existing MCP servers
|
|
75
|
+
expect(merged.mcpServers.existing?.command).toBe("node")
|
|
76
|
+
// Adds new MCP servers
|
|
77
|
+
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("does not write settings.json when no MCP servers", async () => {
|
|
81
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-"))
|
|
82
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
83
|
+
|
|
84
|
+
const config: ClaudeHomeConfig = {
|
|
85
|
+
skills: [
|
|
86
|
+
{
|
|
87
|
+
name: "skill-one",
|
|
88
|
+
sourceDir: fixtureSkillDir,
|
|
89
|
+
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
mcpServers: {},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await syncToGemini(config, tempRoot)
|
|
96
|
+
|
|
97
|
+
// Skills should still be symlinked
|
|
98
|
+
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
|
|
99
|
+
const linkedStat = await fs.lstat(linkedSkillPath)
|
|
100
|
+
expect(linkedStat.isSymbolicLink()).toBe(true)
|
|
101
|
+
|
|
102
|
+
// But settings.json should not exist
|
|
103
|
+
const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false)
|
|
104
|
+
expect(settingsExists).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
})
|