@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,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
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { convertClaudeToOpenClaw } from "../src/converters/claude-to-openclaw"
|
|
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.0.0", description: "A plugin" },
|
|
9
|
+
agents: [
|
|
10
|
+
{
|
|
11
|
+
name: "security-reviewer",
|
|
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-reviewer.md",
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
commands: [
|
|
20
|
+
{
|
|
21
|
+
name: "workflows:plan",
|
|
22
|
+
description: "Planning command",
|
|
23
|
+
argumentHint: "[FOCUS]",
|
|
24
|
+
model: "inherit",
|
|
25
|
+
allowedTools: ["Read"],
|
|
26
|
+
body: "Plan the work. See ~/.claude/settings for config.",
|
|
27
|
+
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "disabled-cmd",
|
|
31
|
+
description: "Disabled command",
|
|
32
|
+
model: "inherit",
|
|
33
|
+
allowedTools: [],
|
|
34
|
+
body: "Should be excluded.",
|
|
35
|
+
disableModelInvocation: true,
|
|
36
|
+
sourcePath: "/tmp/plugin/commands/disabled-cmd.md",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
skills: [
|
|
40
|
+
{
|
|
41
|
+
name: "existing-skill",
|
|
42
|
+
description: "Existing skill",
|
|
43
|
+
sourceDir: "/tmp/plugin/skills/existing-skill",
|
|
44
|
+
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
hooks: undefined,
|
|
48
|
+
mcpServers: {
|
|
49
|
+
local: { command: "npx", args: ["-y", "some-mcp-server"] },
|
|
50
|
+
remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultOptions = {
|
|
55
|
+
agentMode: "subagent" as const,
|
|
56
|
+
inferTemperature: false,
|
|
57
|
+
permissions: "none" as const,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("convertClaudeToOpenClaw", () => {
|
|
61
|
+
test("converts agents to skill files with SKILL.md content", () => {
|
|
62
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
63
|
+
|
|
64
|
+
const skill = bundle.skills.find((s) => s.name === "security-reviewer")
|
|
65
|
+
expect(skill).toBeDefined()
|
|
66
|
+
expect(skill!.dir).toBe("agent-security-reviewer")
|
|
67
|
+
const parsed = parseFrontmatter(skill!.content)
|
|
68
|
+
expect(parsed.data.name).toBe("security-reviewer")
|
|
69
|
+
expect(parsed.data.description).toBe("Security-focused agent")
|
|
70
|
+
expect(parsed.data.model).toBe("claude-sonnet-4-20250514")
|
|
71
|
+
expect(parsed.body).toContain("Focus on vulnerabilities")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("converts commands to skill files (excluding disableModelInvocation)", () => {
|
|
75
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
76
|
+
|
|
77
|
+
const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
|
|
78
|
+
expect(cmdSkill).toBeDefined()
|
|
79
|
+
expect(cmdSkill!.dir).toBe("cmd-workflows:plan")
|
|
80
|
+
|
|
81
|
+
const disabledSkill = bundle.skills.find((s) => s.name === "disabled-cmd")
|
|
82
|
+
expect(disabledSkill).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("commands list excludes disableModelInvocation commands", () => {
|
|
86
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
87
|
+
|
|
88
|
+
const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
|
|
89
|
+
expect(cmd).toBeDefined()
|
|
90
|
+
expect(cmd!.description).toBe("Planning command")
|
|
91
|
+
expect(cmd!.acceptsArgs).toBe(true)
|
|
92
|
+
|
|
93
|
+
const disabled = bundle.commands.find((c) => c.name === "disabled-cmd")
|
|
94
|
+
expect(disabled).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("command colons are replaced with dashes in command registrations", () => {
|
|
98
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
99
|
+
|
|
100
|
+
const cmd = bundle.commands.find((c) => c.name === "workflows-plan")
|
|
101
|
+
expect(cmd).toBeDefined()
|
|
102
|
+
expect(cmd!.name).not.toContain(":")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("manifest includes plugin id, display name, and skills list", () => {
|
|
106
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
107
|
+
|
|
108
|
+
expect(bundle.manifest.id).toBe("compound-engineering")
|
|
109
|
+
expect(bundle.manifest.name).toBe("Compound Engineering")
|
|
110
|
+
expect(bundle.manifest.kind).toBe("tool")
|
|
111
|
+
expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer")
|
|
112
|
+
expect(bundle.manifest.skills).toContain("skills/cmd-workflows:plan")
|
|
113
|
+
expect(bundle.manifest.skills).toContain("skills/existing-skill")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("package.json uses plugin name and version", () => {
|
|
117
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
118
|
+
|
|
119
|
+
expect(bundle.packageJson.name).toBe("openclaw-compound-engineering")
|
|
120
|
+
expect(bundle.packageJson.version).toBe("1.0.0")
|
|
121
|
+
expect(bundle.packageJson.type).toBe("module")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("skillDirCopies includes original skill directories", () => {
|
|
125
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
126
|
+
|
|
127
|
+
const copy = bundle.skillDirCopies.find((s) => s.name === "existing-skill")
|
|
128
|
+
expect(copy).toBeDefined()
|
|
129
|
+
expect(copy!.sourceDir).toBe("/tmp/plugin/skills/existing-skill")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("stdio MCP servers included in openclaw config", () => {
|
|
133
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
134
|
+
|
|
135
|
+
expect(bundle.openclawConfig).toBeDefined()
|
|
136
|
+
const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
|
|
137
|
+
expect(mcp.local).toBeDefined()
|
|
138
|
+
expect((mcp.local as any).type).toBe("stdio")
|
|
139
|
+
expect((mcp.local as any).command).toBe("npx")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("HTTP MCP servers included as http type in openclaw config", () => {
|
|
143
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
144
|
+
|
|
145
|
+
const mcp = (bundle.openclawConfig!.mcpServers as Record<string, unknown>)
|
|
146
|
+
expect(mcp.remote).toBeDefined()
|
|
147
|
+
expect((mcp.remote as any).type).toBe("http")
|
|
148
|
+
expect((mcp.remote as any).url).toBe("https://mcp.example.com/api")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("paths are rewritten from .claude/ to .openclaw/ in skill content", () => {
|
|
152
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
153
|
+
|
|
154
|
+
const agentSkill = bundle.skills.find((s) => s.name === "security-reviewer")
|
|
155
|
+
expect(agentSkill!.content).toContain("~/.openclaw/settings")
|
|
156
|
+
expect(agentSkill!.content).not.toContain("~/.claude/settings")
|
|
157
|
+
|
|
158
|
+
const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan")
|
|
159
|
+
expect(cmdSkill!.content).toContain("~/.openclaw/settings")
|
|
160
|
+
expect(cmdSkill!.content).not.toContain("~/.claude/settings")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("generateEntryPoint uses JSON.stringify for safe string escaping", () => {
|
|
164
|
+
const plugin: ClaudePlugin = {
|
|
165
|
+
...fixturePlugin,
|
|
166
|
+
commands: [
|
|
167
|
+
{
|
|
168
|
+
name: "tricky-cmd",
|
|
169
|
+
description: 'Has "quotes" and \\backslashes\\ and\nnewlines',
|
|
170
|
+
model: "inherit",
|
|
171
|
+
allowedTools: [],
|
|
172
|
+
body: "body",
|
|
173
|
+
sourcePath: "/tmp/cmd.md",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
|
|
178
|
+
|
|
179
|
+
// Entry point must be valid JS/TS — JSON.stringify handles all special chars
|
|
180
|
+
expect(bundle.entryPoint).toContain('"tricky-cmd"')
|
|
181
|
+
expect(bundle.entryPoint).toContain('\\"quotes\\"')
|
|
182
|
+
expect(bundle.entryPoint).toContain("\\\\backslashes\\\\")
|
|
183
|
+
expect(bundle.entryPoint).toContain("\\n")
|
|
184
|
+
// No raw unescaped newline inside a string literal
|
|
185
|
+
const lines = bundle.entryPoint.split("\n")
|
|
186
|
+
const nameLine = lines.find((l) => l.includes("tricky-cmd") && l.includes("name:"))
|
|
187
|
+
expect(nameLine).toBeDefined()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("generateEntryPoint emits typed skills record", () => {
|
|
191
|
+
const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions)
|
|
192
|
+
expect(bundle.entryPoint).toContain("const skills: Record<string, string> = {}")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test("plugin without MCP servers has no openclawConfig", () => {
|
|
196
|
+
const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined }
|
|
197
|
+
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
|
|
198
|
+
expect(bundle.openclawConfig).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -21,6 +21,7 @@ describe("writeOpenCodeBundle", () => {
|
|
|
21
21
|
config: { $schema: "https://opencode.ai/config.json" },
|
|
22
22
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
|
23
23
|
plugins: [{ name: "hook.ts", content: "export {}" }],
|
|
24
|
+
commandFiles: [],
|
|
24
25
|
skillDirs: [
|
|
25
26
|
{
|
|
26
27
|
name: "skill-one",
|
|
@@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => {
|
|
|
44
45
|
config: { $schema: "https://opencode.ai/config.json" },
|
|
45
46
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
|
46
47
|
plugins: [],
|
|
48
|
+
commandFiles: [],
|
|
47
49
|
skillDirs: [
|
|
48
50
|
{
|
|
49
51
|
name: "skill-one",
|
|
@@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => {
|
|
|
68
70
|
config: { $schema: "https://opencode.ai/config.json" },
|
|
69
71
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
|
70
72
|
plugins: [],
|
|
73
|
+
commandFiles: [],
|
|
71
74
|
skillDirs: [
|
|
72
75
|
{
|
|
73
76
|
name: "skill-one",
|
|
@@ -85,28 +88,35 @@ describe("writeOpenCodeBundle", () => {
|
|
|
85
88
|
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
|
|
86
89
|
})
|
|
87
90
|
|
|
88
|
-
test("
|
|
91
|
+
test("merges plugin config into existing opencode.json without destroying user keys", async () => {
|
|
89
92
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-"))
|
|
90
93
|
const outputRoot = path.join(tempRoot, ".opencode")
|
|
91
94
|
const configPath = path.join(outputRoot, "opencode.json")
|
|
92
95
|
|
|
93
|
-
// Create existing config
|
|
96
|
+
// Create existing config with user keys
|
|
94
97
|
await fs.mkdir(outputRoot, { recursive: true })
|
|
95
98
|
const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
|
|
96
99
|
await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
|
|
97
100
|
|
|
101
|
+
// Bundle adds mcp server but keeps user's custom key
|
|
98
102
|
const bundle: OpenCodeBundle = {
|
|
99
|
-
config: {
|
|
103
|
+
config: {
|
|
104
|
+
$schema: "https://opencode.ai/config.json",
|
|
105
|
+
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } }
|
|
106
|
+
},
|
|
100
107
|
agents: [],
|
|
101
108
|
plugins: [],
|
|
109
|
+
commandFiles: [],
|
|
102
110
|
skillDirs: [],
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
await writeOpenCodeBundle(outputRoot, bundle)
|
|
106
114
|
|
|
107
|
-
//
|
|
115
|
+
// Merged config should have both user key and plugin key
|
|
108
116
|
const newConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
|
109
|
-
expect(newConfig.
|
|
117
|
+
expect(newConfig.custom).toBe("value") // user key preserved
|
|
118
|
+
expect(newConfig.mcp).toBeDefined()
|
|
119
|
+
expect(newConfig.mcp["plugin-server"]).toBeDefined()
|
|
110
120
|
|
|
111
121
|
// Backup should exist with original content
|
|
112
122
|
const files = await fs.readdir(outputRoot)
|
|
@@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => {
|
|
|
116
126
|
const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
|
|
117
127
|
expect(backupContent.custom).toBe("value")
|
|
118
128
|
})
|
|
129
|
+
|
|
130
|
+
test("merges mcp servers without overwriting user entry", async () => {
|
|
131
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-merge-mcp-"))
|
|
132
|
+
const outputRoot = path.join(tempRoot, ".opencode")
|
|
133
|
+
const configPath = path.join(outputRoot, "opencode.json")
|
|
134
|
+
|
|
135
|
+
// Create existing config with user's mcp server
|
|
136
|
+
await fs.mkdir(outputRoot, { recursive: true })
|
|
137
|
+
const existingConfig = {
|
|
138
|
+
mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } }
|
|
139
|
+
}
|
|
140
|
+
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
|
141
|
+
|
|
142
|
+
// Bundle adds plugin server AND has conflicting user-server with different args
|
|
143
|
+
const bundle: OpenCodeBundle = {
|
|
144
|
+
config: {
|
|
145
|
+
$schema: "https://opencode.ai/config.json",
|
|
146
|
+
mcp: {
|
|
147
|
+
"plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] },
|
|
148
|
+
"user-server": { type: "local", command: "uvx", args: ["plugin-override"] } // conflict
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
agents: [],
|
|
152
|
+
plugins: [],
|
|
153
|
+
commandFiles: [],
|
|
154
|
+
skillDirs: [],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await writeOpenCodeBundle(outputRoot, bundle)
|
|
158
|
+
|
|
159
|
+
// Merged config should have both servers, with user-server keeping user's original args
|
|
160
|
+
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
|
161
|
+
expect(mergedConfig.mcp).toBeDefined()
|
|
162
|
+
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
|
163
|
+
expect(mergedConfig.mcp["user-server"]).toBeDefined()
|
|
164
|
+
expect(mergedConfig.mcp["user-server"].args[0]).toBe("user-srv") // user wins on conflict
|
|
165
|
+
expect(mergedConfig.mcp["plugin-server"].args[0]).toBe("plugin-srv") // plugin entry present
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("preserves unrelated user keys when merging opencode.json", async () => {
|
|
169
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-preserve-"))
|
|
170
|
+
const outputRoot = path.join(tempRoot, ".opencode")
|
|
171
|
+
const configPath = path.join(outputRoot, "opencode.json")
|
|
172
|
+
|
|
173
|
+
// Create existing config with multiple user keys
|
|
174
|
+
await fs.mkdir(outputRoot, { recursive: true })
|
|
175
|
+
const existingConfig = {
|
|
176
|
+
model: "my-model",
|
|
177
|
+
theme: "dark",
|
|
178
|
+
mcp: {}
|
|
179
|
+
}
|
|
180
|
+
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
|
181
|
+
|
|
182
|
+
// Bundle adds plugin-specific keys
|
|
183
|
+
const bundle: OpenCodeBundle = {
|
|
184
|
+
config: {
|
|
185
|
+
$schema: "https://opencode.ai/config.json",
|
|
186
|
+
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } },
|
|
187
|
+
permission: { "bash": "allow" }
|
|
188
|
+
},
|
|
189
|
+
agents: [],
|
|
190
|
+
plugins: [],
|
|
191
|
+
commandFiles: [],
|
|
192
|
+
skillDirs: [],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await writeOpenCodeBundle(outputRoot, bundle)
|
|
196
|
+
|
|
197
|
+
// All user keys preserved
|
|
198
|
+
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
|
199
|
+
expect(mergedConfig.model).toBe("my-model")
|
|
200
|
+
expect(mergedConfig.theme).toBe("dark")
|
|
201
|
+
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
|
202
|
+
expect(mergedConfig.permission["bash"]).toBe("allow")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("writes command files as .md in commands/ directory", async () => {
|
|
206
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-"))
|
|
207
|
+
const outputRoot = path.join(tempRoot, ".config", "opencode")
|
|
208
|
+
const bundle: OpenCodeBundle = {
|
|
209
|
+
config: { $schema: "https://opencode.ai/config.json" },
|
|
210
|
+
agents: [],
|
|
211
|
+
plugins: [],
|
|
212
|
+
commandFiles: [{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }],
|
|
213
|
+
skillDirs: [],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await writeOpenCodeBundle(outputRoot, bundle)
|
|
217
|
+
|
|
218
|
+
const cmdPath = path.join(outputRoot, "commands", "my-cmd.md")
|
|
219
|
+
expect(await exists(cmdPath)).toBe(true)
|
|
220
|
+
|
|
221
|
+
const content = await fs.readFile(cmdPath, "utf8")
|
|
222
|
+
expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test("backs up existing command .md file before overwriting", async () => {
|
|
226
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
|
|
227
|
+
const outputRoot = path.join(tempRoot, ".opencode")
|
|
228
|
+
const commandsDir = path.join(outputRoot, "commands")
|
|
229
|
+
await fs.mkdir(commandsDir, { recursive: true })
|
|
230
|
+
|
|
231
|
+
const cmdPath = path.join(commandsDir, "my-cmd.md")
|
|
232
|
+
await fs.writeFile(cmdPath, "old content\n")
|
|
233
|
+
|
|
234
|
+
const bundle: OpenCodeBundle = {
|
|
235
|
+
config: { $schema: "https://opencode.ai/config.json" },
|
|
236
|
+
agents: [],
|
|
237
|
+
plugins: [],
|
|
238
|
+
commandFiles: [{ name: "my-cmd", content: "---\ndescription: New\n---\n\nNew content." }],
|
|
239
|
+
skillDirs: [],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await writeOpenCodeBundle(outputRoot, bundle)
|
|
243
|
+
|
|
244
|
+
// New content should be written
|
|
245
|
+
const content = await fs.readFile(cmdPath, "utf8")
|
|
246
|
+
expect(content).toBe("---\ndescription: New\n---\n\nNew content.\n")
|
|
247
|
+
|
|
248
|
+
// Backup should exist
|
|
249
|
+
const files = await fs.readdir(commandsDir)
|
|
250
|
+
const backupFileName = files.find((f) => f.startsWith("my-cmd.md.bak."))
|
|
251
|
+
expect(backupFileName).toBeDefined()
|
|
252
|
+
|
|
253
|
+
const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8")
|
|
254
|
+
expect(backupContent).toBe("old content\n")
|
|
255
|
+
})
|
|
119
256
|
})
|