@every-env/compound-plugin 0.8.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +5 -1
  3. package/CHANGELOG.md +50 -0
  4. package/CLAUDE.md +3 -3
  5. package/README.md +52 -14
  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/kiro.md +171 -0
  13. package/docs/specs/windsurf.md +477 -0
  14. package/package.json +1 -1
  15. package/plans/landing-page-launchkit-refresh.md +2 -2
  16. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  17. package/plugins/compound-engineering/CHANGELOG.md +72 -1
  18. package/plugins/compound-engineering/CLAUDE.md +9 -7
  19. package/plugins/compound-engineering/README.md +10 -7
  20. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  21. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  22. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  23. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  24. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  25. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  26. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  27. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  28. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  29. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  30. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  31. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  32. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  33. package/plugins/compound-engineering/commands/lfg.md +3 -3
  34. package/plugins/compound-engineering/commands/slfg.md +3 -3
  35. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  36. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  37. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  38. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  39. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  40. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  41. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  42. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  43. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  44. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  45. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  46. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  47. package/plugins/compound-engineering/skills/setup/SKILL.md +2 -2
  48. package/src/commands/convert.ts +101 -23
  49. package/src/commands/install.ts +102 -41
  50. package/src/commands/sync.ts +58 -38
  51. package/src/converters/claude-to-kiro.ts +262 -0
  52. package/src/converters/claude-to-openclaw.ts +240 -0
  53. package/src/converters/claude-to-opencode.ts +12 -10
  54. package/src/converters/claude-to-qwen.ts +238 -0
  55. package/src/converters/claude-to-windsurf.ts +205 -0
  56. package/src/sync/gemini.ts +76 -0
  57. package/src/targets/index.ts +69 -1
  58. package/src/targets/kiro.ts +122 -0
  59. package/src/targets/openclaw.ts +96 -0
  60. package/src/targets/opencode.ts +76 -10
  61. package/src/targets/qwen.ts +64 -0
  62. package/src/targets/windsurf.ts +104 -0
  63. package/src/types/kiro.ts +44 -0
  64. package/src/types/openclaw.ts +52 -0
  65. package/src/types/opencode.ts +7 -8
  66. package/src/types/qwen.ts +48 -0
  67. package/src/types/windsurf.ts +34 -0
  68. package/src/utils/detect-tools.ts +46 -0
  69. package/src/utils/files.ts +7 -0
  70. package/src/utils/resolve-output.ts +50 -0
  71. package/src/utils/secrets.ts +24 -0
  72. package/tests/cli.test.ts +78 -0
  73. package/tests/converter.test.ts +43 -10
  74. package/tests/detect-tools.test.ts +96 -0
  75. package/tests/kiro-converter.test.ts +381 -0
  76. package/tests/kiro-writer.test.ts +273 -0
  77. package/tests/openclaw-converter.test.ts +200 -0
  78. package/tests/opencode-writer.test.ts +142 -5
  79. package/tests/qwen-converter.test.ts +238 -0
  80. package/tests/resolve-output.test.ts +131 -0
  81. package/tests/sync-gemini.test.ts +106 -0
  82. package/tests/windsurf-converter.test.ts +573 -0
  83. package/tests/windsurf-writer.test.ts +359 -0
  84. package/docs/css/docs.css +0 -675
  85. package/docs/css/style.css +0 -2886
  86. package/docs/index.html +0 -1046
  87. package/docs/js/main.js +0 -225
  88. package/docs/pages/agents.html +0 -649
  89. package/docs/pages/changelog.html +0 -534
  90. package/docs/pages/commands.html +0 -523
  91. package/docs/pages/getting-started.html +0 -582
  92. package/docs/pages/mcp-servers.html +0 -409
  93. package/docs/pages/skills.html +0 -611
@@ -7,30 +7,19 @@ import { syncToCodex } from "../sync/codex"
7
7
  import { syncToPi } from "../sync/pi"
8
8
  import { syncToDroid } from "../sync/droid"
9
9
  import { syncToCopilot } from "../sync/copilot"
10
+ import { syncToGemini } from "../sync/gemini"
10
11
  import { expandHome } from "../utils/resolve-home"
12
+ import { hasPotentialSecrets } from "../utils/secrets"
13
+ import { detectInstalledTools } from "../utils/detect-tools"
11
14
 
12
- const validTargets = ["opencode", "codex", "pi", "droid", "copilot"] as const
15
+ const validTargets = ["opencode", "codex", "pi", "droid", "copilot", "gemini", "all"] as const
13
16
  type SyncTarget = (typeof validTargets)[number]
14
17
 
15
18
  function isValidTarget(value: string): value is SyncTarget {
16
19
  return (validTargets as readonly string[]).includes(value)
17
20
  }
18
21
 
19
- /** Check if any MCP servers have env vars that might contain secrets */
20
- function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
21
- const sensitivePatterns = /key|token|secret|password|credential|api_key/i
22
- for (const server of Object.values(mcpServers)) {
23
- const env = (server as { env?: Record<string, string> }).env
24
- if (env) {
25
- for (const key of Object.keys(env)) {
26
- if (sensitivePatterns.test(key)) return true
27
- }
28
- }
29
- }
30
- return false
31
- }
32
-
33
- function resolveOutputRoot(target: SyncTarget): string {
22
+ function resolveOutputRoot(target: string): string {
34
23
  switch (target) {
35
24
  case "opencode":
36
25
  return path.join(os.homedir(), ".config", "opencode")
@@ -42,19 +31,46 @@ function resolveOutputRoot(target: SyncTarget): string {
42
31
  return path.join(os.homedir(), ".factory")
43
32
  case "copilot":
44
33
  return path.join(process.cwd(), ".github")
34
+ case "gemini":
35
+ return path.join(process.cwd(), ".gemini")
36
+ default:
37
+ throw new Error(`No output root for target: ${target}`)
38
+ }
39
+ }
40
+
41
+ async function syncTarget(target: string, config: Awaited<ReturnType<typeof loadClaudeHome>>, outputRoot: string): Promise<void> {
42
+ switch (target) {
43
+ case "opencode":
44
+ await syncToOpenCode(config, outputRoot)
45
+ break
46
+ case "codex":
47
+ await syncToCodex(config, outputRoot)
48
+ break
49
+ case "pi":
50
+ await syncToPi(config, outputRoot)
51
+ break
52
+ case "droid":
53
+ await syncToDroid(config, outputRoot)
54
+ break
55
+ case "copilot":
56
+ await syncToCopilot(config, outputRoot)
57
+ break
58
+ case "gemini":
59
+ await syncToGemini(config, outputRoot)
60
+ break
45
61
  }
46
62
  }
47
63
 
48
64
  export default defineCommand({
49
65
  meta: {
50
66
  name: "sync",
51
- description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Copilot",
67
+ description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Copilot, or Gemini",
52
68
  },
53
69
  args: {
54
70
  target: {
55
71
  type: "string",
56
- required: true,
57
- description: "Target: opencode | codex | pi | droid | copilot",
72
+ default: "all",
73
+ description: "Target: opencode | codex | pi | droid | copilot | gemini | all (default: all)",
58
74
  },
59
75
  claudeHome: {
60
76
  type: "string",
@@ -78,30 +94,34 @@ export default defineCommand({
78
94
  )
79
95
  }
80
96
 
97
+ if (args.target === "all") {
98
+ const detected = await detectInstalledTools()
99
+ const activeTargets = detected.filter((t) => t.detected).map((t) => t.name)
100
+
101
+ if (activeTargets.length === 0) {
102
+ console.log("No AI coding tools detected.")
103
+ return
104
+ }
105
+
106
+ console.log(`Syncing to ${activeTargets.length} detected tool(s)...`)
107
+ for (const tool of detected) {
108
+ console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name} — ${tool.reason}`)
109
+ }
110
+
111
+ for (const name of activeTargets) {
112
+ const outputRoot = resolveOutputRoot(name)
113
+ await syncTarget(name, config, outputRoot)
114
+ console.log(`✓ Synced to ${name}: ${outputRoot}`)
115
+ }
116
+ return
117
+ }
118
+
81
119
  console.log(
82
120
  `Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
83
121
  )
84
122
 
85
123
  const outputRoot = resolveOutputRoot(args.target)
86
-
87
- switch (args.target) {
88
- case "opencode":
89
- await syncToOpenCode(config, outputRoot)
90
- break
91
- case "codex":
92
- await syncToCodex(config, outputRoot)
93
- break
94
- case "pi":
95
- await syncToPi(config, outputRoot)
96
- break
97
- case "droid":
98
- await syncToDroid(config, outputRoot)
99
- break
100
- case "copilot":
101
- await syncToCopilot(config, outputRoot)
102
- break
103
- }
104
-
124
+ await syncTarget(args.target, config, outputRoot)
105
125
  console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
106
126
  },
107
127
  })
@@ -0,0 +1,262 @@
1
+ import { readFileSync, existsSync } from "fs"
2
+ import path from "path"
3
+ import { formatFrontmatter } from "../utils/frontmatter"
4
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
5
+ import type {
6
+ KiroAgent,
7
+ KiroAgentConfig,
8
+ KiroBundle,
9
+ KiroMcpServer,
10
+ KiroSkill,
11
+ KiroSteeringFile,
12
+ } from "../types/kiro"
13
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
14
+
15
+ export type ClaudeToKiroOptions = ClaudeToOpenCodeOptions
16
+
17
+ const KIRO_SKILL_NAME_MAX_LENGTH = 64
18
+ const KIRO_SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
19
+ const KIRO_DESCRIPTION_MAX_LENGTH = 1024
20
+
21
+ const CLAUDE_TO_KIRO_TOOLS: Record<string, string> = {
22
+ Bash: "shell",
23
+ Write: "write",
24
+ Read: "read",
25
+ Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping.
26
+ Glob: "glob",
27
+ Grep: "grep",
28
+ WebFetch: "web_fetch",
29
+ Task: "use_subagent",
30
+ }
31
+
32
+ export function convertClaudeToKiro(
33
+ plugin: ClaudePlugin,
34
+ _options: ClaudeToKiroOptions,
35
+ ): KiroBundle {
36
+ const usedSkillNames = new Set<string>()
37
+
38
+ // Pass-through skills are processed first — they're the source of truth
39
+ const skillDirs = plugin.skills.map((skill) => ({
40
+ name: skill.name,
41
+ sourceDir: skill.sourceDir,
42
+ }))
43
+ for (const skill of skillDirs) {
44
+ usedSkillNames.add(normalizeName(skill.name))
45
+ }
46
+
47
+ // Convert agents to Kiro custom agents
48
+ const agentNames = plugin.agents.map((a) => normalizeName(a.name))
49
+ const agents = plugin.agents.map((agent) => convertAgentToKiroAgent(agent, agentNames))
50
+
51
+ // Convert commands to skills (generated)
52
+ const generatedSkills = plugin.commands.map((command) =>
53
+ convertCommandToSkill(command, usedSkillNames, agentNames),
54
+ )
55
+
56
+ // Convert MCP servers (stdio only)
57
+ const mcpServers = convertMcpServers(plugin.mcpServers)
58
+
59
+ // Build steering files from CLAUDE.md
60
+ const steeringFiles = buildSteeringFiles(plugin, agentNames)
61
+
62
+ // Warn about hooks
63
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
64
+ console.warn(
65
+ "Warning: Kiro CLI hooks use a different format (preToolUse/postToolUse inside agent configs). Hooks were skipped during conversion.",
66
+ )
67
+ }
68
+
69
+ return { agents, generatedSkills, skillDirs, steeringFiles, mcpServers }
70
+ }
71
+
72
+ function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent {
73
+ const name = normalizeName(agent.name)
74
+ const description = sanitizeDescription(
75
+ agent.description ?? `Use this agent for ${agent.name} tasks`,
76
+ )
77
+
78
+ const config: KiroAgentConfig = {
79
+ name,
80
+ description,
81
+ prompt: `file://./prompts/${name}.md`,
82
+ tools: ["*"],
83
+ resources: [
84
+ "file://.kiro/steering/**/*.md",
85
+ "skill://.kiro/skills/**/SKILL.md",
86
+ ],
87
+ includeMcpJson: true,
88
+ welcomeMessage: `Switching to the ${name} agent. ${description}`,
89
+ }
90
+
91
+ let body = transformContentForKiro(agent.body.trim(), knownAgentNames)
92
+ if (agent.capabilities && agent.capabilities.length > 0) {
93
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
94
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
95
+ }
96
+ if (body.length === 0) {
97
+ body = `Instructions converted from the ${agent.name} agent.`
98
+ }
99
+
100
+ return { name, config, promptContent: body }
101
+ }
102
+
103
+ function convertCommandToSkill(
104
+ command: ClaudeCommand,
105
+ usedNames: Set<string>,
106
+ knownAgentNames: string[],
107
+ ): KiroSkill {
108
+ const rawName = normalizeName(command.name)
109
+ const name = uniqueName(rawName, usedNames)
110
+
111
+ const description = sanitizeDescription(
112
+ command.description ?? `Converted from Claude command ${command.name}`,
113
+ )
114
+
115
+ const frontmatter: Record<string, unknown> = { name, description }
116
+
117
+ let body = transformContentForKiro(command.body.trim(), knownAgentNames)
118
+ if (body.length === 0) {
119
+ body = `Instructions converted from the ${command.name} command.`
120
+ }
121
+
122
+ const content = formatFrontmatter(frontmatter, body)
123
+ return { name, content }
124
+ }
125
+
126
+ /**
127
+ * Transform Claude Code content to Kiro-compatible content.
128
+ *
129
+ * 1. Task agent calls: Task agent-name(args) -> Use the use_subagent tool ...
130
+ * 2. Path rewriting: .claude/ -> .kiro/, ~/.claude/ -> ~/.kiro/
131
+ * 3. Slash command refs: /workflows:plan -> use the workflows-plan skill
132
+ * 4. Claude tool names: Bash -> shell, Read -> read, etc.
133
+ * 5. Agent refs: @agent-name -> the agent-name agent (only for known agent names)
134
+ */
135
+ export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string {
136
+ let result = body
137
+
138
+ // 1. Transform Task agent calls
139
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
140
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
141
+ return `${prefix}Use the use_subagent tool to delegate to the ${normalizeName(agentName)} agent: ${args.trim()}`
142
+ })
143
+
144
+ // 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind)
145
+ result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.kiro/")
146
+ result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".kiro/")
147
+
148
+ // 3. Slash command refs: /command-name -> skill activation language
149
+ result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => {
150
+ const skillName = normalizeName(cmdName)
151
+ return `the ${skillName} skill`
152
+ })
153
+
154
+ // 4. Claude tool names -> Kiro tool names
155
+ for (const [claudeTool, kiroTool] of Object.entries(CLAUDE_TO_KIRO_TOOLS)) {
156
+ // Match tool name references: "the X tool", "using X", "use X to"
157
+ const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "g")
158
+ result = result.replace(toolPattern, kiroTool)
159
+ }
160
+
161
+ // 5. Transform @agent-name references (only for known agent names)
162
+ if (knownAgentNames.length > 0) {
163
+ const escapedNames = knownAgentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
164
+ const agentRefPattern = new RegExp(`@(${escapedNames.join("|")})\\b`, "g")
165
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
166
+ return `the ${normalizeName(agentName)} agent`
167
+ })
168
+ }
169
+
170
+ return result
171
+ }
172
+
173
+ function convertMcpServers(
174
+ servers?: Record<string, ClaudeMcpServer>,
175
+ ): Record<string, KiroMcpServer> {
176
+ if (!servers || Object.keys(servers).length === 0) return {}
177
+
178
+ const result: Record<string, KiroMcpServer> = {}
179
+ for (const [name, server] of Object.entries(servers)) {
180
+ if (!server.command) {
181
+ console.warn(
182
+ `Warning: MCP server "${name}" has no command (HTTP/SSE transport). Kiro only supports stdio. Skipping.`,
183
+ )
184
+ continue
185
+ }
186
+
187
+ const entry: KiroMcpServer = { command: server.command }
188
+ if (server.args && server.args.length > 0) entry.args = server.args
189
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
190
+
191
+ console.log(`MCP server "${name}" will execute: ${server.command}${server.args ? " " + server.args.join(" ") : ""}`)
192
+ result[name] = entry
193
+ }
194
+ return result
195
+ }
196
+
197
+ function buildSteeringFiles(plugin: ClaudePlugin, knownAgentNames: string[]): KiroSteeringFile[] {
198
+ const claudeMdPath = path.join(plugin.root, "CLAUDE.md")
199
+ if (!existsSync(claudeMdPath)) return []
200
+
201
+ let content: string
202
+ try {
203
+ content = readFileSync(claudeMdPath, "utf8")
204
+ } catch {
205
+ return []
206
+ }
207
+
208
+ if (!content || content.trim().length === 0) return []
209
+
210
+ const transformed = transformContentForKiro(content, knownAgentNames)
211
+ return [{ name: "compound-engineering", content: transformed }]
212
+ }
213
+
214
+ function normalizeName(value: string): string {
215
+ const trimmed = value.trim()
216
+ if (!trimmed) return "item"
217
+ let normalized = trimmed
218
+ .toLowerCase()
219
+ .replace(/[\\/]+/g, "-")
220
+ .replace(/[:\s]+/g, "-")
221
+ .replace(/[^a-z0-9_-]+/g, "-")
222
+ .replace(/-+/g, "-") // Collapse consecutive hyphens (Agent Skills standard)
223
+ .replace(/^-+|-+$/g, "")
224
+
225
+ // Enforce max length (truncate at last hyphen boundary)
226
+ if (normalized.length > KIRO_SKILL_NAME_MAX_LENGTH) {
227
+ normalized = normalized.slice(0, KIRO_SKILL_NAME_MAX_LENGTH)
228
+ const lastHyphen = normalized.lastIndexOf("-")
229
+ if (lastHyphen > 0) {
230
+ normalized = normalized.slice(0, lastHyphen)
231
+ }
232
+ normalized = normalized.replace(/-+$/g, "")
233
+ }
234
+
235
+ // Ensure name starts with a letter
236
+ if (normalized.length === 0 || !/^[a-z]/.test(normalized)) {
237
+ return "item"
238
+ }
239
+
240
+ return normalized
241
+ }
242
+
243
+ function sanitizeDescription(value: string, maxLength = KIRO_DESCRIPTION_MAX_LENGTH): string {
244
+ const normalized = value.replace(/\s+/g, " ").trim()
245
+ if (normalized.length <= maxLength) return normalized
246
+ const ellipsis = "..."
247
+ return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
248
+ }
249
+
250
+ function uniqueName(base: string, used: Set<string>): string {
251
+ if (!used.has(base)) {
252
+ used.add(base)
253
+ return base
254
+ }
255
+ let index = 2
256
+ while (used.has(`${base}-${index}`)) {
257
+ index += 1
258
+ }
259
+ const name = `${base}-${index}`
260
+ used.add(name)
261
+ return name
262
+ }
@@ -0,0 +1,240 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type {
3
+ ClaudeAgent,
4
+ ClaudeCommand,
5
+ ClaudePlugin,
6
+ ClaudeMcpServer,
7
+ } from "../types/claude"
8
+ import type {
9
+ OpenClawBundle,
10
+ OpenClawCommandRegistration,
11
+ OpenClawPluginManifest,
12
+ OpenClawSkillFile,
13
+ } from "../types/openclaw"
14
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
15
+
16
+ export type ClaudeToOpenClawOptions = ClaudeToOpenCodeOptions
17
+
18
+ export function convertClaudeToOpenClaw(
19
+ plugin: ClaudePlugin,
20
+ _options: ClaudeToOpenClawOptions,
21
+ ): OpenClawBundle {
22
+ const enabledCommands = plugin.commands.filter((cmd) => !cmd.disableModelInvocation)
23
+
24
+ const agentSkills = plugin.agents.map(convertAgentToSkill)
25
+ const commandSkills = enabledCommands.map(convertCommandToSkill)
26
+ const commands = enabledCommands.map(convertCommand)
27
+
28
+ const skills: OpenClawSkillFile[] = [...agentSkills, ...commandSkills]
29
+
30
+ const skillDirCopies = plugin.skills.map((skill) => ({
31
+ sourceDir: skill.sourceDir,
32
+ name: skill.name,
33
+ }))
34
+
35
+ const allSkillDirs = [
36
+ ...agentSkills.map((s) => s.dir),
37
+ ...commandSkills.map((s) => s.dir),
38
+ ...plugin.skills.map((s) => s.name),
39
+ ]
40
+
41
+ const manifest = buildManifest(plugin, allSkillDirs)
42
+
43
+ const packageJson = buildPackageJson(plugin)
44
+
45
+ const openclawConfig = plugin.mcpServers
46
+ ? buildOpenClawConfig(plugin.mcpServers)
47
+ : undefined
48
+
49
+ const entryPoint = generateEntryPoint(commands)
50
+
51
+ return {
52
+ manifest,
53
+ packageJson,
54
+ entryPoint,
55
+ skills,
56
+ skillDirCopies,
57
+ commands,
58
+ openclawConfig,
59
+ }
60
+ }
61
+
62
+ function buildManifest(plugin: ClaudePlugin, skillDirs: string[]): OpenClawPluginManifest {
63
+ return {
64
+ id: plugin.manifest.name,
65
+ name: formatDisplayName(plugin.manifest.name),
66
+ kind: "tool",
67
+ skills: skillDirs.map((dir) => `skills/${dir}`),
68
+ }
69
+ }
70
+
71
+ function buildPackageJson(plugin: ClaudePlugin): Record<string, unknown> {
72
+ return {
73
+ name: `openclaw-${plugin.manifest.name}`,
74
+ version: plugin.manifest.version,
75
+ type: "module",
76
+ private: true,
77
+ description: plugin.manifest.description,
78
+ main: "index.ts",
79
+ openclaw: {
80
+ extensions: [
81
+ {
82
+ id: plugin.manifest.name,
83
+ entry: "./index.ts",
84
+ },
85
+ ],
86
+ },
87
+ keywords: [
88
+ "openclaw",
89
+ "openclaw-plugin",
90
+ ...(plugin.manifest.keywords ?? []),
91
+ ],
92
+ }
93
+ }
94
+
95
+ function convertAgentToSkill(agent: ClaudeAgent): OpenClawSkillFile {
96
+ const frontmatter: Record<string, unknown> = {
97
+ name: agent.name,
98
+ description: agent.description,
99
+ }
100
+
101
+ if (agent.model && agent.model !== "inherit") {
102
+ frontmatter.model = agent.model
103
+ }
104
+
105
+ const body = rewritePaths(agent.body)
106
+ const content = formatFrontmatter(frontmatter, body)
107
+
108
+ return {
109
+ name: agent.name,
110
+ content,
111
+ dir: `agent-${agent.name}`,
112
+ }
113
+ }
114
+
115
+ function convertCommandToSkill(command: ClaudeCommand): OpenClawSkillFile {
116
+ const frontmatter: Record<string, unknown> = {
117
+ name: `cmd-${command.name}`,
118
+ description: command.description,
119
+ }
120
+
121
+ if (command.model && command.model !== "inherit") {
122
+ frontmatter.model = command.model
123
+ }
124
+
125
+ const body = rewritePaths(command.body)
126
+ const content = formatFrontmatter(frontmatter, body)
127
+
128
+ return {
129
+ name: command.name,
130
+ content,
131
+ dir: `cmd-${command.name}`,
132
+ }
133
+ }
134
+
135
+ function convertCommand(command: ClaudeCommand): OpenClawCommandRegistration {
136
+ return {
137
+ name: command.name.replace(/:/g, "-"),
138
+ description: command.description ?? `Run ${command.name}`,
139
+ acceptsArgs: Boolean(command.argumentHint),
140
+ body: rewritePaths(command.body),
141
+ }
142
+ }
143
+
144
+ function buildOpenClawConfig(
145
+ servers: Record<string, ClaudeMcpServer>,
146
+ ): Record<string, unknown> {
147
+ const mcpServers: Record<string, unknown> = {}
148
+
149
+ for (const [name, server] of Object.entries(servers)) {
150
+ if (server.command) {
151
+ mcpServers[name] = {
152
+ type: "stdio",
153
+ command: server.command,
154
+ args: server.args ?? [],
155
+ env: server.env,
156
+ }
157
+ } else if (server.url) {
158
+ mcpServers[name] = {
159
+ type: "http",
160
+ url: server.url,
161
+ headers: server.headers,
162
+ }
163
+ }
164
+ }
165
+
166
+ return { mcpServers }
167
+ }
168
+
169
+ function generateEntryPoint(commands: OpenClawCommandRegistration[]): string {
170
+ const commandRegistrations = commands
171
+ .map((cmd) => {
172
+ // JSON.stringify produces a fully-escaped string literal safe for JS/TS source embedding
173
+ const safeName = JSON.stringify(cmd.name)
174
+ const safeDesc = JSON.stringify(cmd.description ?? "")
175
+ const safeNotFound = JSON.stringify(`Command ${cmd.name} not found. Check skills directory.`)
176
+ return ` api.registerCommand({
177
+ name: ${safeName},
178
+ description: ${safeDesc},
179
+ acceptsArgs: ${cmd.acceptsArgs},
180
+ requireAuth: false,
181
+ handler: (ctx) => ({
182
+ text: skills[${safeName}] ?? ${safeNotFound},
183
+ }),
184
+ });`
185
+ })
186
+ .join("\n\n")
187
+
188
+ return `// Auto-generated OpenClaw plugin entry point
189
+ // Converted from Claude Code plugin format by compound-plugin CLI
190
+ import { promises as fs } from "fs";
191
+ import path from "path";
192
+ import { fileURLToPath } from "url";
193
+
194
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
195
+
196
+ // Pre-load skill bodies for command responses
197
+ const skills: Record<string, string> = {};
198
+
199
+ async function loadSkills() {
200
+ const skillsDir = path.join(__dirname, "skills");
201
+ try {
202
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
203
+ for (const entry of entries) {
204
+ if (!entry.isDirectory()) continue;
205
+ const skillPath = path.join(skillsDir, entry.name, "SKILL.md");
206
+ try {
207
+ const content = await fs.readFile(skillPath, "utf8");
208
+ // Strip frontmatter
209
+ const body = content.replace(/^---[\\s\\S]*?---\\n*/, "");
210
+ skills[entry.name.replace(/^cmd-/, "")] = body.trim();
211
+ } catch {
212
+ // Skill file not found, skip
213
+ }
214
+ }
215
+ } catch {
216
+ // Skills directory not found
217
+ }
218
+ }
219
+
220
+ export default async function register(api) {
221
+ await loadSkills();
222
+
223
+ ${commandRegistrations}
224
+ }
225
+ `
226
+ }
227
+
228
+ function rewritePaths(body: string): string {
229
+ return body
230
+ .replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.openclaw/")
231
+ .replace(/(?<=^|\s|["'`])\.claude\//gm, ".openclaw/")
232
+ .replace(/\.claude-plugin\//g, "openclaw-plugin/")
233
+ }
234
+
235
+ function formatDisplayName(name: string): string {
236
+ return name
237
+ .split("-")
238
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
239
+ .join(" ")
240
+ }
@@ -8,7 +8,7 @@ import type {
8
8
  } from "../types/claude"
9
9
  import type {
10
10
  OpenCodeBundle,
11
- OpenCodeCommandConfig,
11
+ OpenCodeCommandFile,
12
12
  OpenCodeConfig,
13
13
  OpenCodeMcpServer,
14
14
  } from "../types/opencode"
@@ -66,13 +66,12 @@ export function convertClaudeToOpenCode(
66
66
  options: ClaudeToOpenCodeOptions,
67
67
  ): OpenCodeBundle {
68
68
  const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
69
- const commandMap = convertCommands(plugin.commands)
69
+ const cmdFiles = convertCommands(plugin.commands)
70
70
  const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
71
71
  const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
72
72
 
73
73
  const config: OpenCodeConfig = {
74
74
  $schema: "https://opencode.ai/config.json",
75
- command: Object.keys(commandMap).length > 0 ? commandMap : undefined,
76
75
  mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
77
76
  }
78
77
 
@@ -81,6 +80,7 @@ export function convertClaudeToOpenCode(
81
80
  return {
82
81
  config,
83
82
  agents: agentFiles,
83
+ commandFiles: cmdFiles,
84
84
  plugins,
85
85
  skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
86
86
  }
@@ -111,20 +111,22 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
111
111
  }
112
112
  }
113
113
 
114
- function convertCommands(commands: ClaudeCommand[]): Record<string, OpenCodeCommandConfig> {
115
- const result: Record<string, OpenCodeCommandConfig> = {}
114
+ // Commands are written as individual .md files rather than entries in opencode.json.
115
+ // Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001).
116
+ function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
117
+ const files: OpenCodeCommandFile[] = []
116
118
  for (const command of commands) {
117
119
  if (command.disableModelInvocation) continue
118
- const entry: OpenCodeCommandConfig = {
120
+ const frontmatter: Record<string, unknown> = {
119
121
  description: command.description,
120
- template: rewriteClaudePaths(command.body),
121
122
  }
122
123
  if (command.model && command.model !== "inherit") {
123
- entry.model = normalizeModel(command.model)
124
+ frontmatter.model = normalizeModel(command.model)
124
125
  }
125
- result[command.name] = entry
126
+ const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body))
127
+ files.push({ name: command.name, content })
126
128
  }
127
- return result
129
+ return files
128
130
  }
129
131
 
130
132
  function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {