@every-env/compound-plugin 0.9.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 (121) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.github/workflows/publish.yml +20 -10
  3. package/.releaserc.json +31 -0
  4. package/AGENTS.md +6 -1
  5. package/CHANGELOG.md +76 -0
  6. package/CLAUDE.md +16 -3
  7. package/README.md +83 -16
  8. package/bun.lock +977 -0
  9. package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
  10. package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
  11. package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
  12. package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
  13. package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
  14. package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
  15. package/docs/solutions/adding-converter-target-providers.md +693 -0
  16. package/docs/solutions/plugin-versioning-requirements.md +7 -3
  17. package/docs/specs/windsurf.md +477 -0
  18. package/package.json +10 -4
  19. package/plans/landing-page-launchkit-refresh.md +2 -2
  20. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  21. package/plugins/compound-engineering/CHANGELOG.md +82 -1
  22. package/plugins/compound-engineering/CLAUDE.md +14 -7
  23. package/plugins/compound-engineering/README.md +10 -7
  24. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  25. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  26. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  27. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  28. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  29. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  30. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  31. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  32. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  33. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  34. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  35. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  36. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  37. package/plugins/compound-engineering/commands/lfg.md +3 -3
  38. package/plugins/compound-engineering/commands/slfg.md +3 -3
  39. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  40. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  41. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  42. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  43. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  44. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  45. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  46. package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
  47. package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
  48. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  49. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  50. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  51. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  52. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  53. package/plugins/compound-engineering/skills/setup/SKILL.md +8 -2
  54. package/src/commands/convert.ts +101 -24
  55. package/src/commands/install.ts +102 -45
  56. package/src/commands/sync.ts +43 -62
  57. package/src/converters/claude-to-openclaw.ts +240 -0
  58. package/src/converters/claude-to-opencode.ts +12 -10
  59. package/src/converters/claude-to-qwen.ts +238 -0
  60. package/src/converters/claude-to-windsurf.ts +205 -0
  61. package/src/index.ts +2 -1
  62. package/src/parsers/claude-home.ts +55 -3
  63. package/src/sync/codex.ts +38 -62
  64. package/src/sync/commands.ts +198 -0
  65. package/src/sync/copilot.ts +14 -36
  66. package/src/sync/droid.ts +50 -9
  67. package/src/sync/gemini.ts +135 -0
  68. package/src/sync/json-config.ts +47 -0
  69. package/src/sync/kiro.ts +49 -0
  70. package/src/sync/mcp-transports.ts +19 -0
  71. package/src/sync/openclaw.ts +18 -0
  72. package/src/sync/opencode.ts +10 -30
  73. package/src/sync/pi.ts +12 -36
  74. package/src/sync/qwen.ts +66 -0
  75. package/src/sync/registry.ts +141 -0
  76. package/src/sync/skills.ts +21 -0
  77. package/src/sync/windsurf.ts +59 -0
  78. package/src/targets/index.ts +60 -1
  79. package/src/targets/openclaw.ts +96 -0
  80. package/src/targets/opencode.ts +76 -10
  81. package/src/targets/qwen.ts +64 -0
  82. package/src/targets/windsurf.ts +104 -0
  83. package/src/types/kiro.ts +3 -1
  84. package/src/types/openclaw.ts +52 -0
  85. package/src/types/opencode.ts +7 -8
  86. package/src/types/qwen.ts +51 -0
  87. package/src/types/windsurf.ts +35 -0
  88. package/src/utils/codex-agents.ts +1 -1
  89. package/src/utils/detect-tools.ts +37 -0
  90. package/src/utils/files.ts +14 -0
  91. package/src/utils/resolve-output.ts +50 -0
  92. package/src/utils/secrets.ts +24 -0
  93. package/src/utils/symlink.ts +4 -6
  94. package/tests/claude-home.test.ts +46 -0
  95. package/tests/cli.test.ts +180 -0
  96. package/tests/converter.test.ts +43 -10
  97. package/tests/detect-tools.test.ts +119 -0
  98. package/tests/openclaw-converter.test.ts +200 -0
  99. package/tests/opencode-writer.test.ts +142 -5
  100. package/tests/qwen-converter.test.ts +238 -0
  101. package/tests/resolve-output.test.ts +131 -0
  102. package/tests/sync-codex.test.ts +64 -0
  103. package/tests/sync-copilot.test.ts +60 -4
  104. package/tests/sync-droid.test.ts +44 -4
  105. package/tests/sync-gemini.test.ts +160 -0
  106. package/tests/sync-kiro.test.ts +83 -0
  107. package/tests/sync-openclaw.test.ts +51 -0
  108. package/tests/sync-qwen.test.ts +75 -0
  109. package/tests/sync-windsurf.test.ts +89 -0
  110. package/tests/windsurf-converter.test.ts +573 -0
  111. package/tests/windsurf-writer.test.ts +359 -0
  112. package/docs/css/docs.css +0 -675
  113. package/docs/css/style.css +0 -2886
  114. package/docs/index.html +0 -1046
  115. package/docs/js/main.js +0 -225
  116. package/docs/pages/agents.html +0 -649
  117. package/docs/pages/changelog.html +0 -534
  118. package/docs/pages/commands.html +0 -523
  119. package/docs/pages/getting-started.html +0 -582
  120. package/docs/pages/mcp-servers.html +0 -409
  121. package/docs/pages/skills.html +0 -611
@@ -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
+ }
@@ -6,6 +6,9 @@ import type { PiBundle } from "../types/pi"
6
6
  import type { CopilotBundle } from "../types/copilot"
7
7
  import type { GeminiBundle } from "../types/gemini"
8
8
  import type { KiroBundle } from "../types/kiro"
9
+ import type { WindsurfBundle } from "../types/windsurf"
10
+ import type { OpenClawBundle } from "../types/openclaw"
11
+ import type { QwenBundle } from "../types/qwen"
9
12
  import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
10
13
  import { convertClaudeToCodex } from "../converters/claude-to-codex"
11
14
  import { convertClaudeToDroid } from "../converters/claude-to-droid"
@@ -13,6 +16,9 @@ import { convertClaudeToPi } from "../converters/claude-to-pi"
13
16
  import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
14
17
  import { convertClaudeToGemini } from "../converters/claude-to-gemini"
15
18
  import { convertClaudeToKiro } from "../converters/claude-to-kiro"
19
+ import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
20
+ import { convertClaudeToOpenClaw } from "../converters/claude-to-openclaw"
21
+ import { convertClaudeToQwen } from "../converters/claude-to-qwen"
16
22
  import { writeOpenCodeBundle } from "./opencode"
17
23
  import { writeCodexBundle } from "./codex"
18
24
  import { writeDroidBundle } from "./droid"
@@ -20,12 +26,45 @@ import { writePiBundle } from "./pi"
20
26
  import { writeCopilotBundle } from "./copilot"
21
27
  import { writeGeminiBundle } from "./gemini"
22
28
  import { writeKiroBundle } from "./kiro"
29
+ import { writeWindsurfBundle } from "./windsurf"
30
+ import { writeOpenClawBundle } from "./openclaw"
31
+ import { writeQwenBundle } from "./qwen"
32
+
33
+ export type TargetScope = "global" | "workspace"
34
+
35
+ export function isTargetScope(value: string): value is TargetScope {
36
+ return value === "global" || value === "workspace"
37
+ }
38
+
39
+ /**
40
+ * Validate a --scope flag against a target's supported scopes.
41
+ * Returns the resolved scope (explicit or default) or throws on invalid input.
42
+ */
43
+ export function validateScope(
44
+ targetName: string,
45
+ target: TargetHandler,
46
+ scopeArg: string | undefined,
47
+ ): TargetScope | undefined {
48
+ if (scopeArg === undefined) return target.defaultScope
49
+
50
+ if (!target.supportedScopes) {
51
+ throw new Error(`Target "${targetName}" does not support the --scope flag.`)
52
+ }
53
+ if (!isTargetScope(scopeArg) || !target.supportedScopes.includes(scopeArg)) {
54
+ throw new Error(`Target "${targetName}" does not support --scope ${scopeArg}. Supported: ${target.supportedScopes.join(", ")}`)
55
+ }
56
+ return scopeArg
57
+ }
23
58
 
24
59
  export type TargetHandler<TBundle = unknown> = {
25
60
  name: string
26
61
  implemented: boolean
62
+ /** Default scope when --scope is not provided. Only meaningful when supportedScopes is defined. */
63
+ defaultScope?: TargetScope
64
+ /** Valid scope values. If absent, the --scope flag is rejected for this target. */
65
+ supportedScopes?: TargetScope[]
27
66
  convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
28
- write: (outputRoot: string, bundle: TBundle) => Promise<void>
67
+ write: (outputRoot: string, bundle: TBundle, scope?: TargetScope) => Promise<void>
29
68
  }
30
69
 
31
70
  export const targets: Record<string, TargetHandler> = {
@@ -71,4 +110,24 @@ export const targets: Record<string, TargetHandler> = {
71
110
  convert: convertClaudeToKiro as TargetHandler<KiroBundle>["convert"],
72
111
  write: writeKiroBundle as TargetHandler<KiroBundle>["write"],
73
112
  },
113
+ windsurf: {
114
+ name: "windsurf",
115
+ implemented: true,
116
+ defaultScope: "global",
117
+ supportedScopes: ["global", "workspace"],
118
+ convert: convertClaudeToWindsurf as TargetHandler<WindsurfBundle>["convert"],
119
+ write: writeWindsurfBundle as TargetHandler<WindsurfBundle>["write"],
120
+ },
121
+ openclaw: {
122
+ name: "openclaw",
123
+ implemented: true,
124
+ convert: convertClaudeToOpenClaw as TargetHandler<OpenClawBundle>["convert"],
125
+ write: writeOpenClawBundle as TargetHandler<OpenClawBundle>["write"],
126
+ },
127
+ qwen: {
128
+ name: "qwen",
129
+ implemented: true,
130
+ convert: convertClaudeToQwen as TargetHandler<QwenBundle>["convert"],
131
+ write: writeQwenBundle as TargetHandler<QwenBundle>["write"],
132
+ },
74
133
  }
@@ -0,0 +1,96 @@
1
+ import path from "path"
2
+ import { promises as fs } from "fs"
3
+ import { backupFile, copyDir, ensureDir, pathExists, readJson, walkFiles, writeJson, writeText } from "../utils/files"
4
+ import type { OpenClawBundle } from "../types/openclaw"
5
+
6
+ export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise<void> {
7
+ const paths = resolveOpenClawPaths(outputRoot)
8
+ await ensureDir(paths.root)
9
+
10
+ // Write openclaw.plugin.json
11
+ await writeJson(paths.manifestPath, bundle.manifest)
12
+
13
+ // Write package.json
14
+ await writeJson(paths.packageJsonPath, bundle.packageJson)
15
+
16
+ // Write index.ts entry point
17
+ await writeText(paths.entryPointPath, bundle.entryPoint)
18
+
19
+ // Write generated skills (agents + commands converted to SKILL.md)
20
+ for (const skill of bundle.skills) {
21
+ const skillDir = path.join(paths.skillsDir, skill.dir)
22
+ await ensureDir(skillDir)
23
+ await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n")
24
+ }
25
+
26
+ // Copy original skill directories (preserving references/, assets/, scripts/)
27
+ // and rewrite .claude/ paths to .openclaw/ in markdown files
28
+ for (const skill of bundle.skillDirCopies) {
29
+ const destDir = path.join(paths.skillsDir, skill.name)
30
+ await copyDir(skill.sourceDir, destDir)
31
+ await rewritePathsInDir(destDir)
32
+ }
33
+
34
+ // Write openclaw.json config fragment if MCP servers exist
35
+ if (bundle.openclawConfig) {
36
+ const configPath = path.join(paths.root, "openclaw.json")
37
+ const backupPath = await backupFile(configPath)
38
+ if (backupPath) {
39
+ console.log(`Backed up existing config to ${backupPath}`)
40
+ }
41
+ const merged = await mergeOpenClawConfig(configPath, bundle.openclawConfig)
42
+ await writeJson(configPath, merged)
43
+ }
44
+ }
45
+
46
+ function resolveOpenClawPaths(outputRoot: string) {
47
+ return {
48
+ root: outputRoot,
49
+ manifestPath: path.join(outputRoot, "openclaw.plugin.json"),
50
+ packageJsonPath: path.join(outputRoot, "package.json"),
51
+ entryPointPath: path.join(outputRoot, "index.ts"),
52
+ skillsDir: path.join(outputRoot, "skills"),
53
+ }
54
+ }
55
+
56
+ async function rewritePathsInDir(dir: string): Promise<void> {
57
+ const files = await walkFiles(dir)
58
+ for (const file of files) {
59
+ if (!file.endsWith(".md")) continue
60
+ const content = await fs.readFile(file, "utf8")
61
+ const rewritten = content
62
+ .replace(/~\/\.claude\//g, "~/.openclaw/")
63
+ .replace(/\.claude\//g, ".openclaw/")
64
+ .replace(/\.claude-plugin\//g, "openclaw-plugin/")
65
+ if (rewritten !== content) {
66
+ await fs.writeFile(file, rewritten, "utf8")
67
+ }
68
+ }
69
+ }
70
+
71
+ async function mergeOpenClawConfig(
72
+ configPath: string,
73
+ incoming: Record<string, unknown>,
74
+ ): Promise<Record<string, unknown>> {
75
+ if (!(await pathExists(configPath))) return incoming
76
+
77
+ let existing: Record<string, unknown>
78
+ try {
79
+ existing = await readJson<Record<string, unknown>>(configPath)
80
+ } catch {
81
+ console.warn(
82
+ `Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`,
83
+ )
84
+ return incoming
85
+ }
86
+
87
+ // Merge MCP servers: existing takes precedence on conflict
88
+ const incomingMcp = (incoming.mcpServers ?? {}) as Record<string, unknown>
89
+ const existingMcp = (existing.mcpServers ?? {}) as Record<string, unknown>
90
+ const mergedMcp = { ...incomingMcp, ...existingMcp }
91
+
92
+ return {
93
+ ...existing,
94
+ mcpServers: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
95
+ }
96
+ }
@@ -1,31 +1,93 @@
1
1
  import path from "path"
2
- import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
- import type { OpenCodeBundle } from "../types/opencode"
2
+ import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
3
+ import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
4
+
5
+ // Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
6
+ async function mergeOpenCodeConfig(
7
+ configPath: string,
8
+ incoming: OpenCodeConfig,
9
+ ): Promise<OpenCodeConfig> {
10
+ // If no existing config, write plugin config as-is
11
+ if (!(await pathExists(configPath))) return incoming
12
+
13
+ let existing: OpenCodeConfig
14
+ try {
15
+ existing = await readJson<OpenCodeConfig>(configPath)
16
+ } catch {
17
+ // Safety first per AGENTS.md -- do not destroy user data even if their config is malformed.
18
+ // Warn and fall back to plugin-only config rather than crashing.
19
+ console.warn(
20
+ `Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`
21
+ )
22
+ return incoming
23
+ }
24
+
25
+ // User config wins on conflict -- see ADR-002
26
+ // MCP servers: add plugin entry, skip keys already in user config.
27
+ const mergedMcp = {
28
+ ...(incoming.mcp ?? {}),
29
+ ...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entry)
30
+ }
31
+
32
+ // Permission: add plugin entry, skip keys already in user config.
33
+ const mergedPermission = incoming.permission
34
+ ? {
35
+ ...(incoming.permission),
36
+ ...(existing.permission ?? {}), // existing takes precedence
37
+ }
38
+ : existing.permission
39
+
40
+ // Tools: same pattern
41
+ const mergedTools = incoming.tools
42
+ ? {
43
+ ...(incoming.tools),
44
+ ...(existing.tools ?? {}),
45
+ }
46
+ : existing.tools
47
+
48
+ return {
49
+ ...existing, // all user keys preserved
50
+ $schema: incoming.$schema ?? existing.$schema,
51
+ mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
52
+ permission: mergedPermission,
53
+ tools: mergedTools,
54
+ }
55
+ }
4
56
 
5
57
  export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
6
- const paths = resolveOpenCodePaths(outputRoot)
7
- await ensureDir(paths.root)
58
+ const openCodePaths = resolveOpenCodePaths(outputRoot)
59
+ await ensureDir(openCodePaths.root)
8
60
 
9
- const backupPath = await backupFile(paths.configPath)
61
+ const backupPath = await backupFile(openCodePaths.configPath)
10
62
  if (backupPath) {
11
63
  console.log(`Backed up existing config to ${backupPath}`)
12
64
  }
13
- await writeJson(paths.configPath, bundle.config)
65
+ const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config)
66
+ await writeJson(openCodePaths.configPath, merged)
14
67
 
15
- const agentsDir = paths.agentsDir
68
+ const agentsDir = openCodePaths.agentsDir
16
69
  for (const agent of bundle.agents) {
17
70
  await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n")
18
71
  }
19
72
 
73
+ for (const commandFile of bundle.commandFiles) {
74
+ const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`)
75
+ const cmdBackupPath = await backupFile(dest)
76
+ if (cmdBackupPath) {
77
+ console.log(`Backed up existing command file to ${cmdBackupPath}`)
78
+ }
79
+ await writeText(dest, commandFile.content + "\n")
80
+ }
81
+
20
82
  if (bundle.plugins.length > 0) {
21
- const pluginsDir = paths.pluginsDir
83
+ const pluginsDir = openCodePaths.pluginsDir
22
84
  for (const plugin of bundle.plugins) {
23
85
  await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
24
86
  }
25
87
  }
26
88
 
27
89
  if (bundle.skillDirs.length > 0) {
28
- const skillsRoot = paths.skillsDir
90
+ const skillsRoot = openCodePaths.skillsDir
29
91
  for (const skill of bundle.skillDirs) {
30
92
  await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
31
93
  }
@@ -43,6 +105,8 @@ function resolveOpenCodePaths(outputRoot: string) {
43
105
  agentsDir: path.join(outputRoot, "agents"),
44
106
  pluginsDir: path.join(outputRoot, "plugins"),
45
107
  skillsDir: path.join(outputRoot, "skills"),
108
+ // .md command files; alternative to the command key in opencode.json
109
+ commandDir: path.join(outputRoot, "commands"),
46
110
  }
47
111
  }
48
112
 
@@ -53,5 +117,7 @@ function resolveOpenCodePaths(outputRoot: string) {
53
117
  agentsDir: path.join(outputRoot, ".opencode", "agents"),
54
118
  pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
55
119
  skillsDir: path.join(outputRoot, ".opencode", "skills"),
120
+ // .md command files; alternative to the command key in opencode.json
121
+ commandDir: path.join(outputRoot, ".opencode", "commands"),
56
122
  }
57
- }
123
+ }
@@ -0,0 +1,64 @@
1
+ import path from "path"
2
+ import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
+ import type { QwenBundle, QwenExtensionConfig } from "../types/qwen"
4
+
5
+ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
6
+ const qwenPaths = resolveQwenPaths(outputRoot)
7
+ await ensureDir(qwenPaths.root)
8
+
9
+ // Write qwen-extension.json config
10
+ const configPath = qwenPaths.configPath
11
+ const backupPath = await backupFile(configPath)
12
+ if (backupPath) {
13
+ console.log(`Backed up existing config to ${backupPath}`)
14
+ }
15
+ await writeJson(configPath, bundle.config)
16
+
17
+ // Write context file (QWEN.md)
18
+ if (bundle.contextFile) {
19
+ await writeText(qwenPaths.contextPath, bundle.contextFile + "\n")
20
+ }
21
+
22
+ // Write agents
23
+ const agentsDir = qwenPaths.agentsDir
24
+ await ensureDir(agentsDir)
25
+ for (const agent of bundle.agents) {
26
+ const ext = agent.format === "yaml" ? "yaml" : "md"
27
+ await writeText(path.join(agentsDir, `${agent.name}.${ext}`), agent.content + "\n")
28
+ }
29
+
30
+ // Write commands
31
+ const commandsDir = qwenPaths.commandsDir
32
+ await ensureDir(commandsDir)
33
+ for (const commandFile of bundle.commandFiles) {
34
+ // Support nested commands with colon separator
35
+ const parts = commandFile.name.split(":")
36
+ if (parts.length > 1) {
37
+ const nestedDir = path.join(commandsDir, ...parts.slice(0, -1))
38
+ await ensureDir(nestedDir)
39
+ await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n")
40
+ } else {
41
+ await writeText(path.join(commandsDir, `${commandFile.name}.md`), commandFile.content + "\n")
42
+ }
43
+ }
44
+
45
+ // Copy skills
46
+ if (bundle.skillDirs.length > 0) {
47
+ const skillsRoot = qwenPaths.skillsDir
48
+ await ensureDir(skillsRoot)
49
+ for (const skill of bundle.skillDirs) {
50
+ await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
51
+ }
52
+ }
53
+ }
54
+
55
+ function resolveQwenPaths(outputRoot: string) {
56
+ return {
57
+ root: outputRoot,
58
+ configPath: path.join(outputRoot, "qwen-extension.json"),
59
+ contextPath: path.join(outputRoot, "QWEN.md"),
60
+ agentsDir: path.join(outputRoot, "agents"),
61
+ commandsDir: path.join(outputRoot, "commands"),
62
+ skillsDir: path.join(outputRoot, "skills"),
63
+ }
64
+ }