@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.
Files changed (87) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +5 -1
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +3 -3
  5. package/README.md +49 -15
  6. package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
  7. package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
  8. package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
  9. package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
  10. package/docs/solutions/adding-converter-target-providers.md +692 -0
  11. package/docs/solutions/plugin-versioning-requirements.md +3 -3
  12. package/docs/specs/windsurf.md +477 -0
  13. package/package.json +1 -1
  14. package/plans/landing-page-launchkit-refresh.md +2 -2
  15. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  16. package/plugins/compound-engineering/CHANGELOG.md +72 -1
  17. package/plugins/compound-engineering/CLAUDE.md +9 -7
  18. package/plugins/compound-engineering/README.md +10 -7
  19. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  20. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  21. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  22. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  23. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  24. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  25. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  26. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  27. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  28. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  29. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  30. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  31. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  32. package/plugins/compound-engineering/commands/lfg.md +3 -3
  33. package/plugins/compound-engineering/commands/slfg.md +3 -3
  34. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  35. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  36. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  37. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  38. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  39. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  40. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  41. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  42. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  43. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  44. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  45. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  46. package/plugins/compound-engineering/skills/setup/SKILL.md +2 -2
  47. package/src/commands/convert.ts +101 -24
  48. package/src/commands/install.ts +102 -45
  49. package/src/commands/sync.ts +58 -38
  50. package/src/converters/claude-to-openclaw.ts +240 -0
  51. package/src/converters/claude-to-opencode.ts +12 -10
  52. package/src/converters/claude-to-qwen.ts +238 -0
  53. package/src/converters/claude-to-windsurf.ts +205 -0
  54. package/src/sync/gemini.ts +76 -0
  55. package/src/targets/index.ts +60 -1
  56. package/src/targets/openclaw.ts +96 -0
  57. package/src/targets/opencode.ts +76 -10
  58. package/src/targets/qwen.ts +64 -0
  59. package/src/targets/windsurf.ts +104 -0
  60. package/src/types/openclaw.ts +52 -0
  61. package/src/types/opencode.ts +7 -8
  62. package/src/types/qwen.ts +48 -0
  63. package/src/types/windsurf.ts +34 -0
  64. package/src/utils/detect-tools.ts +46 -0
  65. package/src/utils/files.ts +7 -0
  66. package/src/utils/resolve-output.ts +50 -0
  67. package/src/utils/secrets.ts +24 -0
  68. package/tests/cli.test.ts +78 -0
  69. package/tests/converter.test.ts +43 -10
  70. package/tests/detect-tools.test.ts +96 -0
  71. package/tests/openclaw-converter.test.ts +200 -0
  72. package/tests/opencode-writer.test.ts +142 -5
  73. package/tests/qwen-converter.test.ts +238 -0
  74. package/tests/resolve-output.test.ts +131 -0
  75. package/tests/sync-gemini.test.ts +106 -0
  76. package/tests/windsurf-converter.test.ts +573 -0
  77. package/tests/windsurf-writer.test.ts +359 -0
  78. package/docs/css/docs.css +0 -675
  79. package/docs/css/style.css +0 -2886
  80. package/docs/index.html +0 -1046
  81. package/docs/js/main.js +0 -225
  82. package/docs/pages/agents.html +0 -649
  83. package/docs/pages/changelog.html +0 -534
  84. package/docs/pages/commands.html +0 -523
  85. package/docs/pages/getting-started.html +0 -582
  86. package/docs/pages/mcp-servers.html +0 -409
  87. package/docs/pages/skills.html +0 -611
@@ -0,0 +1,205 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import { findServersWithPotentialSecrets } from "../utils/secrets"
3
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
4
+ import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf"
5
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
6
+
7
+ export type ClaudeToWindsurfOptions = ClaudeToOpenCodeOptions
8
+
9
+ const WINDSURF_WORKFLOW_CHAR_LIMIT = 12_000
10
+
11
+ export function convertClaudeToWindsurf(
12
+ plugin: ClaudePlugin,
13
+ _options: ClaudeToWindsurfOptions,
14
+ ): WindsurfBundle {
15
+ const knownAgentNames = plugin.agents.map((a) => normalizeName(a.name))
16
+
17
+ // Pass-through skills (collected first so agent skill names can deduplicate against them)
18
+ const skillDirs = plugin.skills.map((skill) => ({
19
+ name: skill.name,
20
+ sourceDir: skill.sourceDir,
21
+ }))
22
+
23
+ // Convert agents to skills (seed usedNames with pass-through skill names)
24
+ const usedSkillNames = new Set<string>(skillDirs.map((s) => s.name))
25
+ const agentSkills = plugin.agents.map((agent) =>
26
+ convertAgentToSkill(agent, knownAgentNames, usedSkillNames),
27
+ )
28
+
29
+ // Convert commands to workflows
30
+ const usedCommandNames = new Set<string>()
31
+ const commandWorkflows = plugin.commands.map((command) =>
32
+ convertCommandToWorkflow(command, knownAgentNames, usedCommandNames),
33
+ )
34
+
35
+ // Build MCP config
36
+ const mcpConfig = buildMcpConfig(plugin.mcpServers)
37
+
38
+ // Warn about hooks
39
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
40
+ console.warn(
41
+ "Warning: Windsurf has no hooks equivalent. Hooks were skipped during conversion.",
42
+ )
43
+ }
44
+
45
+ return { agentSkills, commandWorkflows, skillDirs, mcpConfig }
46
+ }
47
+
48
+ function convertAgentToSkill(
49
+ agent: ClaudeAgent,
50
+ knownAgentNames: string[],
51
+ usedNames: Set<string>,
52
+ ): WindsurfGeneratedSkill {
53
+ const name = uniqueName(normalizeName(agent.name), usedNames)
54
+ const description = sanitizeDescription(
55
+ agent.description ?? `Converted from Claude agent ${agent.name}`,
56
+ )
57
+
58
+ let body = transformContentForWindsurf(agent.body.trim(), knownAgentNames)
59
+ if (agent.capabilities && agent.capabilities.length > 0) {
60
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
61
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
62
+ }
63
+ if (body.length === 0) {
64
+ body = `Instructions converted from the ${agent.name} agent.`
65
+ }
66
+
67
+ const content = formatFrontmatter({ name, description }, `# ${name}\n\n${body}`) + "\n"
68
+ return { name, content }
69
+ }
70
+
71
+ function convertCommandToWorkflow(
72
+ command: ClaudeCommand,
73
+ knownAgentNames: string[],
74
+ usedNames: Set<string>,
75
+ ): WindsurfWorkflow {
76
+ const name = uniqueName(normalizeName(command.name), usedNames)
77
+ const description = sanitizeDescription(
78
+ command.description ?? `Converted from Claude command ${command.name}`,
79
+ )
80
+
81
+ let body = transformContentForWindsurf(command.body.trim(), knownAgentNames)
82
+ if (command.argumentHint) {
83
+ body = `> Arguments: ${command.argumentHint}\n\n${body}`
84
+ }
85
+ if (body.length === 0) {
86
+ body = `Instructions converted from the ${command.name} command.`
87
+ }
88
+
89
+ const frontmatter: Record<string, unknown> = { description }
90
+ const fullContent = formatFrontmatter(frontmatter, `# ${name}\n\n${body}`)
91
+ if (fullContent.length > WINDSURF_WORKFLOW_CHAR_LIMIT) {
92
+ console.warn(
93
+ `Warning: Workflow "${name}" is ${fullContent.length} characters (limit: ${WINDSURF_WORKFLOW_CHAR_LIMIT}). It may be truncated by Windsurf.`,
94
+ )
95
+ }
96
+
97
+ return { name, description, body }
98
+ }
99
+
100
+ /**
101
+ * Transform Claude Code content to Windsurf-compatible content.
102
+ *
103
+ * 1. Path rewriting: .claude/ -> .windsurf/, ~/.claude/ -> ~/.codeium/windsurf/
104
+ * 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes workflows as /{name})
105
+ * 3. @agent-name refs: kept as @agent-name (already Windsurf skill invocation syntax)
106
+ * 4. Task agent calls: Task agent-name(args) -> Use the @agent-name skill: args
107
+ */
108
+ export function transformContentForWindsurf(body: string, knownAgentNames: string[] = []): string {
109
+ let result = body
110
+
111
+ // 1. Rewrite paths
112
+ result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.codeium/windsurf/")
113
+ result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".windsurf/")
114
+
115
+ // 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes as /{name})
116
+ result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => {
117
+ const workflowName = normalizeName(cmdName)
118
+ return `/${workflowName}`
119
+ })
120
+
121
+ // 3. @agent-name references: no transformation needed.
122
+ // In Windsurf, @skill-name is the native invocation syntax for skills.
123
+ // Since agents are now mapped to skills, @agent-name already works correctly.
124
+
125
+ // 4. Transform Task agent calls to skill references
126
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
127
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
128
+ return `${prefix}Use the @${normalizeName(agentName)} skill: ${args.trim()}`
129
+ })
130
+
131
+ return result
132
+ }
133
+
134
+ function buildMcpConfig(servers?: Record<string, ClaudeMcpServer>): WindsurfMcpConfig | null {
135
+ if (!servers || Object.keys(servers).length === 0) return null
136
+
137
+ const result: Record<string, WindsurfMcpServerEntry> = {}
138
+ for (const [name, server] of Object.entries(servers)) {
139
+ if (server.command) {
140
+ // stdio transport
141
+ const entry: WindsurfMcpServerEntry = { command: server.command }
142
+ if (server.args?.length) entry.args = server.args
143
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
144
+ result[name] = entry
145
+ } else if (server.url) {
146
+ // HTTP/SSE transport
147
+ const entry: WindsurfMcpServerEntry = { serverUrl: server.url }
148
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
149
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
150
+ result[name] = entry
151
+ } else {
152
+ console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`)
153
+ continue
154
+ }
155
+ }
156
+
157
+ if (Object.keys(result).length === 0) return null
158
+
159
+ // Warn about secrets (don't redact — they're needed for the config to work)
160
+ const flagged = findServersWithPotentialSecrets(result)
161
+ if (flagged.length > 0) {
162
+ console.warn(
163
+ `Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` +
164
+ " These will be written to mcp_config.json. Review before sharing the config file.",
165
+ )
166
+ }
167
+
168
+ return { mcpServers: result }
169
+ }
170
+
171
+ export function normalizeName(value: string): string {
172
+ const trimmed = value.trim()
173
+ if (!trimmed) return "item"
174
+ let normalized = trimmed
175
+ .toLowerCase()
176
+ .replace(/[\\/]+/g, "-")
177
+ .replace(/[:\s]+/g, "-")
178
+ .replace(/[^a-z0-9_-]+/g, "-")
179
+ .replace(/-+/g, "-")
180
+ .replace(/^-+|-+$/g, "")
181
+
182
+ if (normalized.length === 0 || !/^[a-z]/.test(normalized)) {
183
+ return "item"
184
+ }
185
+
186
+ return normalized
187
+ }
188
+
189
+ function sanitizeDescription(value: string): string {
190
+ return value.replace(/\s+/g, " ").trim()
191
+ }
192
+
193
+ function uniqueName(base: string, used: Set<string>): string {
194
+ if (!used.has(base)) {
195
+ used.add(base)
196
+ return base
197
+ }
198
+ let index = 2
199
+ while (used.has(`${base}-${index}`)) {
200
+ index += 1
201
+ }
202
+ const name = `${base}-${index}`
203
+ used.add(name)
204
+ return name
205
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
+ import type { ClaudeMcpServer } from "../types/claude"
5
+ import { forceSymlink, isValidSkillName } from "../utils/symlink"
6
+
7
+ type GeminiMcpServer = {
8
+ command?: string
9
+ args?: string[]
10
+ url?: string
11
+ env?: Record<string, string>
12
+ headers?: Record<string, string>
13
+ }
14
+
15
+ export async function syncToGemini(
16
+ config: ClaudeHomeConfig,
17
+ outputRoot: string,
18
+ ): Promise<void> {
19
+ const skillsDir = path.join(outputRoot, "skills")
20
+ await fs.mkdir(skillsDir, { recursive: true })
21
+
22
+ for (const skill of config.skills) {
23
+ if (!isValidSkillName(skill.name)) {
24
+ console.warn(`Skipping skill with invalid name: ${skill.name}`)
25
+ continue
26
+ }
27
+ const target = path.join(skillsDir, skill.name)
28
+ await forceSymlink(skill.sourceDir, target)
29
+ }
30
+
31
+ if (Object.keys(config.mcpServers).length > 0) {
32
+ const settingsPath = path.join(outputRoot, "settings.json")
33
+ const existing = await readJsonSafe(settingsPath)
34
+ const converted = convertMcpForGemini(config.mcpServers)
35
+ const existingMcp =
36
+ existing.mcpServers && typeof existing.mcpServers === "object"
37
+ ? (existing.mcpServers as Record<string, unknown>)
38
+ : {}
39
+ const merged = {
40
+ ...existing,
41
+ mcpServers: { ...existingMcp, ...converted },
42
+ }
43
+ await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
44
+ }
45
+ }
46
+
47
+ async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
48
+ try {
49
+ const content = await fs.readFile(filePath, "utf-8")
50
+ return JSON.parse(content) as Record<string, unknown>
51
+ } catch (err) {
52
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
53
+ return {}
54
+ }
55
+ throw err
56
+ }
57
+ }
58
+
59
+ function convertMcpForGemini(
60
+ servers: Record<string, ClaudeMcpServer>,
61
+ ): Record<string, GeminiMcpServer> {
62
+ const result: Record<string, GeminiMcpServer> = {}
63
+ for (const [name, server] of Object.entries(servers)) {
64
+ const entry: GeminiMcpServer = {}
65
+ if (server.command) {
66
+ entry.command = server.command
67
+ if (server.args && server.args.length > 0) entry.args = server.args
68
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
69
+ } else if (server.url) {
70
+ entry.url = server.url
71
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
72
+ }
73
+ result[name] = entry
74
+ }
75
+ return result
76
+ }
@@ -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
+ }