@every-env/compound-plugin 0.3.0 → 0.5.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 (49) hide show
  1. package/{plugins/compound-engineering → .claude}/commands/release-docs.md +0 -1
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.github/workflows/ci.yml +1 -1
  4. package/.github/workflows/deploy-docs.yml +3 -3
  5. package/.github/workflows/publish.yml +37 -0
  6. package/README.md +12 -3
  7. package/docs/index.html +13 -13
  8. package/docs/pages/changelog.html +39 -0
  9. package/docs/plans/2026-02-08-feat-convert-local-md-settings-for-opencode-codex-plan.md +143 -0
  10. package/docs/plans/2026-02-08-feat-simplify-plugin-settings-plan.md +195 -0
  11. package/docs/plans/2026-02-09-refactor-dspy-ruby-skill-update-plan.md +104 -0
  12. package/docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md +306 -0
  13. package/docs/specs/cursor.md +85 -0
  14. package/package.json +1 -1
  15. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  16. package/plugins/compound-engineering/CHANGELOG.md +38 -0
  17. package/plugins/compound-engineering/README.md +5 -3
  18. package/plugins/compound-engineering/commands/workflows/brainstorm.md +6 -1
  19. package/plugins/compound-engineering/commands/workflows/compound.md +1 -0
  20. package/plugins/compound-engineering/commands/workflows/review.md +23 -21
  21. package/plugins/compound-engineering/commands/workflows/work.md +29 -15
  22. package/plugins/compound-engineering/skills/dspy-ruby/SKILL.md +539 -396
  23. package/plugins/compound-engineering/skills/dspy-ruby/assets/config-template.rb +159 -331
  24. package/plugins/compound-engineering/skills/dspy-ruby/assets/module-template.rb +210 -236
  25. package/plugins/compound-engineering/skills/dspy-ruby/assets/signature-template.rb +173 -95
  26. package/plugins/compound-engineering/skills/dspy-ruby/references/core-concepts.md +552 -143
  27. package/plugins/compound-engineering/skills/dspy-ruby/references/observability.md +366 -0
  28. package/plugins/compound-engineering/skills/dspy-ruby/references/optimization.md +440 -460
  29. package/plugins/compound-engineering/skills/dspy-ruby/references/providers.md +305 -225
  30. package/plugins/compound-engineering/skills/dspy-ruby/references/toolsets.md +502 -0
  31. package/plugins/compound-engineering/skills/setup/SKILL.md +168 -0
  32. package/src/commands/convert.ts +10 -5
  33. package/src/commands/install.ts +10 -5
  34. package/src/converters/claude-to-codex.ts +7 -2
  35. package/src/converters/claude-to-cursor.ts +166 -0
  36. package/src/converters/claude-to-droid.ts +174 -0
  37. package/src/converters/claude-to-opencode.ts +8 -2
  38. package/src/targets/cursor.ts +48 -0
  39. package/src/targets/droid.ts +50 -0
  40. package/src/targets/index.ts +18 -0
  41. package/src/types/cursor.ts +29 -0
  42. package/src/types/droid.ts +20 -0
  43. package/tests/codex-converter.test.ts +62 -0
  44. package/tests/converter.test.ts +61 -0
  45. package/tests/cursor-converter.test.ts +347 -0
  46. package/tests/cursor-writer.test.ts +137 -0
  47. package/tests/droid-converter.test.ts +277 -0
  48. package/tests/droid-writer.test.ts +100 -0
  49. package/plugins/compound-engineering/commands/technical_review.md +0 -8
@@ -24,7 +24,7 @@ export default defineCommand({
24
24
  to: {
25
25
  type: "string",
26
26
  default: "opencode",
27
- description: "Target format (opencode | codex)",
27
+ description: "Target format (opencode | codex | droid | cursor)",
28
28
  },
29
29
  output: {
30
30
  type: "string",
@@ -88,7 +88,7 @@ export default defineCommand({
88
88
  if (!bundle) {
89
89
  throw new Error(`Target ${targetName} did not return a bundle.`)
90
90
  }
91
- const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot
91
+ const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome)
92
92
  await target.write(primaryOutputRoot, bundle)
93
93
  console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
94
94
 
@@ -109,9 +109,7 @@ export default defineCommand({
109
109
  console.warn(`Skipping ${extra}: no output returned.`)
110
110
  continue
111
111
  }
112
- const extraRoot = extra === "codex" && codexHome
113
- ? codexHome
114
- : path.join(outputRoot, extra)
112
+ const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome)
115
113
  await handler.write(extraRoot, extraBundle)
116
114
  console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
117
115
  }
@@ -180,6 +178,13 @@ function resolveOutputRoot(value: unknown): string {
180
178
  return path.join(os.homedir(), ".config", "opencode")
181
179
  }
182
180
 
181
+ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string {
182
+ if (targetName === "codex") return codexHome
183
+ if (targetName === "droid") return path.join(os.homedir(), ".factory")
184
+ if (targetName === "cursor") return path.join(outputRoot, ".cursor")
185
+ return outputRoot
186
+ }
187
+
183
188
  async function resolveGitHubPluginPath(pluginName: string): Promise<ResolvedPluginPath> {
184
189
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
185
190
  const source = resolveGitHubSource()
@@ -46,7 +46,7 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CodexGenerate
46
46
  )
47
47
  const frontmatter: Record<string, unknown> = { name, description }
48
48
 
49
- let body = agent.body.trim()
49
+ let body = transformContentForCodex(agent.body.trim())
50
50
  if (agent.capabilities && agent.capabilities.length > 0) {
51
51
  const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n")
52
52
  body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
@@ -121,7 +121,12 @@ function transformContentForCodex(body: string): string {
121
121
  return `/prompts:${normalizedName}`
122
122
  })
123
123
 
124
- // 3. Transform @agent-name references
124
+ // 3. Rewrite .claude/ paths to .codex/
125
+ result = result
126
+ .replace(/~\/\.claude\//g, "~/.codex/")
127
+ .replace(/\.claude\//g, ".codex/")
128
+
129
+ // 4. Transform @agent-name references
125
130
  // Match: @agent-name in text (not emails)
126
131
  const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
127
132
  result = result.replace(agentRefPattern, (_match, agentName: string) => {
@@ -0,0 +1,166 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
+ import type { CursorBundle, CursorCommand, CursorMcpServer, CursorRule } from "../types/cursor"
4
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
+
6
+ export type ClaudeToCursorOptions = ClaudeToOpenCodeOptions
7
+
8
+ export function convertClaudeToCursor(
9
+ plugin: ClaudePlugin,
10
+ _options: ClaudeToCursorOptions,
11
+ ): CursorBundle {
12
+ const usedRuleNames = new Set<string>()
13
+ const usedCommandNames = new Set<string>()
14
+
15
+ const rules = plugin.agents.map((agent) => convertAgentToRule(agent, usedRuleNames))
16
+ const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
17
+ const skillDirs = plugin.skills.map((skill) => ({
18
+ name: skill.name,
19
+ sourceDir: skill.sourceDir,
20
+ }))
21
+
22
+ const mcpServers = convertMcpServers(plugin.mcpServers)
23
+
24
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
25
+ console.warn("Warning: Cursor does not support hooks. Hooks were skipped during conversion.")
26
+ }
27
+
28
+ return { rules, commands, skillDirs, mcpServers }
29
+ }
30
+
31
+ function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorRule {
32
+ const name = uniqueName(normalizeName(agent.name), usedNames)
33
+ const description = agent.description ?? `Converted from Claude agent ${agent.name}`
34
+
35
+ const frontmatter: Record<string, unknown> = {
36
+ description,
37
+ alwaysApply: false,
38
+ }
39
+
40
+ let body = transformContentForCursor(agent.body.trim())
41
+ if (agent.capabilities && agent.capabilities.length > 0) {
42
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
43
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
44
+ }
45
+ if (body.length === 0) {
46
+ body = `Instructions converted from the ${agent.name} agent.`
47
+ }
48
+
49
+ const content = formatFrontmatter(frontmatter, body)
50
+ return { name, content }
51
+ }
52
+
53
+ function convertCommand(command: ClaudeCommand, usedNames: Set<string>): CursorCommand {
54
+ const name = uniqueName(flattenCommandName(command.name), usedNames)
55
+
56
+ const sections: string[] = []
57
+
58
+ if (command.description) {
59
+ sections.push(`<!-- ${command.description} -->`)
60
+ }
61
+
62
+ if (command.argumentHint) {
63
+ sections.push(`## Arguments\n${command.argumentHint}`)
64
+ }
65
+
66
+ const transformedBody = transformContentForCursor(command.body.trim())
67
+ sections.push(transformedBody)
68
+
69
+ const content = sections.filter(Boolean).join("\n\n").trim()
70
+ return { name, content }
71
+ }
72
+
73
+ /**
74
+ * Transform Claude Code content to Cursor-compatible content.
75
+ *
76
+ * 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
77
+ * 2. Slash commands: /workflows:plan -> /plan (flatten namespace)
78
+ * 3. Path rewriting: .claude/ -> .cursor/
79
+ * 4. Agent references: @agent-name -> the agent-name rule
80
+ */
81
+ export function transformContentForCursor(body: string): string {
82
+ let result = body
83
+
84
+ // 1. Transform Task agent calls
85
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
86
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
87
+ const skillName = normalizeName(agentName)
88
+ return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
89
+ })
90
+
91
+ // 2. Transform slash command references (flatten namespaces)
92
+ const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
93
+ result = result.replace(slashCommandPattern, (match, commandName: string) => {
94
+ if (commandName.includes("/")) return match
95
+ if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
96
+ const flattened = flattenCommandName(commandName)
97
+ return `/${flattened}`
98
+ })
99
+
100
+ // 3. Rewrite .claude/ paths to .cursor/
101
+ result = result
102
+ .replace(/~\/\.claude\//g, "~/.cursor/")
103
+ .replace(/\.claude\//g, ".cursor/")
104
+
105
+ // 4. Transform @agent-name references
106
+ const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
107
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
108
+ return `the ${normalizeName(agentName)} rule`
109
+ })
110
+
111
+ return result
112
+ }
113
+
114
+ function convertMcpServers(
115
+ servers?: Record<string, ClaudeMcpServer>,
116
+ ): Record<string, CursorMcpServer> | undefined {
117
+ if (!servers || Object.keys(servers).length === 0) return undefined
118
+
119
+ const result: Record<string, CursorMcpServer> = {}
120
+ for (const [name, server] of Object.entries(servers)) {
121
+ const entry: CursorMcpServer = {}
122
+ if (server.command) {
123
+ entry.command = server.command
124
+ if (server.args && server.args.length > 0) entry.args = server.args
125
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
126
+ } else if (server.url) {
127
+ entry.url = server.url
128
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
129
+ }
130
+ result[name] = entry
131
+ }
132
+ return result
133
+ }
134
+
135
+ function flattenCommandName(name: string): string {
136
+ const colonIndex = name.lastIndexOf(":")
137
+ const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
138
+ return normalizeName(base)
139
+ }
140
+
141
+ function normalizeName(value: string): string {
142
+ const trimmed = value.trim()
143
+ if (!trimmed) return "item"
144
+ const normalized = trimmed
145
+ .toLowerCase()
146
+ .replace(/[\\/]+/g, "-")
147
+ .replace(/[:\s]+/g, "-")
148
+ .replace(/[^a-z0-9_-]+/g, "-")
149
+ .replace(/-+/g, "-")
150
+ .replace(/^-+|-+$/g, "")
151
+ return normalized || "item"
152
+ }
153
+
154
+ function uniqueName(base: string, used: Set<string>): string {
155
+ if (!used.has(base)) {
156
+ used.add(base)
157
+ return base
158
+ }
159
+ let index = 2
160
+ while (used.has(`${base}-${index}`)) {
161
+ index += 1
162
+ }
163
+ const name = `${base}-${index}`
164
+ used.add(name)
165
+ return name
166
+ }
@@ -0,0 +1,174 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type { ClaudeAgent, ClaudeCommand, ClaudePlugin } from "../types/claude"
3
+ import type { DroidBundle, DroidCommandFile, DroidAgentFile } from "../types/droid"
4
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
+
6
+ export type ClaudeToDroidOptions = ClaudeToOpenCodeOptions
7
+
8
+ const CLAUDE_TO_DROID_TOOLS: Record<string, string> = {
9
+ read: "Read",
10
+ write: "Create",
11
+ edit: "Edit",
12
+ multiedit: "Edit",
13
+ bash: "Execute",
14
+ grep: "Grep",
15
+ glob: "Glob",
16
+ list: "LS",
17
+ ls: "LS",
18
+ webfetch: "FetchUrl",
19
+ websearch: "WebSearch",
20
+ task: "Task",
21
+ todowrite: "TodoWrite",
22
+ todoread: "TodoWrite",
23
+ question: "AskUser",
24
+ }
25
+
26
+ const VALID_DROID_TOOLS = new Set([
27
+ "Read",
28
+ "LS",
29
+ "Grep",
30
+ "Glob",
31
+ "Create",
32
+ "Edit",
33
+ "ApplyPatch",
34
+ "Execute",
35
+ "WebSearch",
36
+ "FetchUrl",
37
+ "TodoWrite",
38
+ "Task",
39
+ "AskUser",
40
+ ])
41
+
42
+ export function convertClaudeToDroid(
43
+ plugin: ClaudePlugin,
44
+ _options: ClaudeToDroidOptions,
45
+ ): DroidBundle {
46
+ const commands = plugin.commands.map((command) => convertCommand(command))
47
+ const droids = plugin.agents.map((agent) => convertAgent(agent))
48
+ const skillDirs = plugin.skills.map((skill) => ({
49
+ name: skill.name,
50
+ sourceDir: skill.sourceDir,
51
+ }))
52
+
53
+ return { commands, droids, skillDirs }
54
+ }
55
+
56
+ function convertCommand(command: ClaudeCommand): DroidCommandFile {
57
+ const name = flattenCommandName(command.name)
58
+ const frontmatter: Record<string, unknown> = {
59
+ description: command.description,
60
+ }
61
+ if (command.argumentHint) {
62
+ frontmatter["argument-hint"] = command.argumentHint
63
+ }
64
+ if (command.disableModelInvocation) {
65
+ frontmatter["disable-model-invocation"] = true
66
+ }
67
+
68
+ const body = transformContentForDroid(command.body.trim())
69
+ const content = formatFrontmatter(frontmatter, body)
70
+ return { name, content }
71
+ }
72
+
73
+ function convertAgent(agent: ClaudeAgent): DroidAgentFile {
74
+ const name = normalizeName(agent.name)
75
+ const frontmatter: Record<string, unknown> = {
76
+ name,
77
+ description: agent.description,
78
+ model: agent.model && agent.model !== "inherit" ? agent.model : "inherit",
79
+ }
80
+
81
+ const tools = mapAgentTools(agent)
82
+ if (tools) {
83
+ frontmatter.tools = tools
84
+ }
85
+
86
+ let body = agent.body.trim()
87
+ if (agent.capabilities && agent.capabilities.length > 0) {
88
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
89
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
90
+ }
91
+ if (body.length === 0) {
92
+ body = `Instructions converted from the ${agent.name} agent.`
93
+ }
94
+
95
+ body = transformContentForDroid(body)
96
+
97
+ const content = formatFrontmatter(frontmatter, body)
98
+ return { name, content }
99
+ }
100
+
101
+ function mapAgentTools(agent: ClaudeAgent): string[] | undefined {
102
+ const bodyLower = `${agent.name} ${agent.description ?? ""} ${agent.body}`.toLowerCase()
103
+
104
+ const mentionedTools = new Set<string>()
105
+ for (const [claudeTool, droidTool] of Object.entries(CLAUDE_TO_DROID_TOOLS)) {
106
+ if (bodyLower.includes(claudeTool)) {
107
+ mentionedTools.add(droidTool)
108
+ }
109
+ }
110
+
111
+ if (mentionedTools.size === 0) return undefined
112
+ return [...mentionedTools].filter((t) => VALID_DROID_TOOLS.has(t)).sort()
113
+ }
114
+
115
+ /**
116
+ * Transform Claude Code content to Factory Droid-compatible content.
117
+ *
118
+ * 1. Slash commands: /workflows:plan → /plan, /command-name stays as-is
119
+ * 2. Task agent calls: Task agent-name(args) → Task agent-name: args
120
+ * 3. Agent references: @agent-name → the agent-name droid
121
+ */
122
+ function transformContentForDroid(body: string): string {
123
+ let result = body
124
+
125
+ // 1. Transform Task agent calls
126
+ // Match: Task repo-research-analyst(feature_description)
127
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
128
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
129
+ const name = normalizeName(agentName)
130
+ return `${prefix}Task ${name}: ${args.trim()}`
131
+ })
132
+
133
+ // 2. Transform slash command references
134
+ // /workflows:plan → /plan, /command-name stays as-is
135
+ const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
136
+ result = result.replace(slashCommandPattern, (match, commandName: string) => {
137
+ if (commandName.includes('/')) return match
138
+ if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match
139
+ const flattened = flattenCommandName(commandName)
140
+ return `/${flattened}`
141
+ })
142
+
143
+ // 3. Transform @agent-name references to droid references
144
+ const agentRefPattern = /@agent-([a-z][a-z0-9-]*)/gi
145
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
146
+ return `the ${normalizeName(agentName)} droid`
147
+ })
148
+
149
+ return result
150
+ }
151
+
152
+ /**
153
+ * Flatten a command name by stripping the namespace prefix.
154
+ * "workflows:plan" → "plan"
155
+ * "plan_review" → "plan_review"
156
+ */
157
+ function flattenCommandName(name: string): string {
158
+ const colonIndex = name.lastIndexOf(":")
159
+ const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
160
+ return normalizeName(base)
161
+ }
162
+
163
+ function normalizeName(value: string): string {
164
+ const trimmed = value.trim()
165
+ if (!trimmed) return "item"
166
+ const normalized = trimmed
167
+ .toLowerCase()
168
+ .replace(/[\\/]+/g, "-")
169
+ .replace(/[:\s]+/g, "-")
170
+ .replace(/[^a-z0-9_-]+/g, "-")
171
+ .replace(/-+/g, "-")
172
+ .replace(/^-+|-+$/g, "")
173
+ return normalized || "item"
174
+ }
@@ -103,7 +103,7 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
103
103
  }
104
104
  }
105
105
 
106
- const content = formatFrontmatter(frontmatter, agent.body)
106
+ const content = formatFrontmatter(frontmatter, rewriteClaudePaths(agent.body))
107
107
 
108
108
  return {
109
109
  name: agent.name,
@@ -117,7 +117,7 @@ function convertCommands(commands: ClaudeCommand[]): Record<string, OpenCodeComm
117
117
  if (command.disableModelInvocation) continue
118
118
  const entry: OpenCodeCommandConfig = {
119
119
  description: command.description,
120
- template: command.body,
120
+ template: rewriteClaudePaths(command.body),
121
121
  }
122
122
  if (command.model && command.model !== "inherit") {
123
123
  entry.model = normalizeModel(command.model)
@@ -244,6 +244,12 @@ function renderHookStatements(
244
244
  return statements
245
245
  }
246
246
 
247
+ function rewriteClaudePaths(body: string): string {
248
+ return body
249
+ .replace(/~\/\.claude\//g, "~/.config/opencode/")
250
+ .replace(/\.claude\//g, ".opencode/")
251
+ }
252
+
247
253
  function normalizeModel(model: string): string {
248
254
  if (model.includes("/")) return model
249
255
  if (/^claude-/.test(model)) return `anthropic/${model}`
@@ -0,0 +1,48 @@
1
+ import path from "path"
2
+ import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
+ import type { CursorBundle } from "../types/cursor"
4
+
5
+ export async function writeCursorBundle(outputRoot: string, bundle: CursorBundle): Promise<void> {
6
+ const paths = resolveCursorPaths(outputRoot)
7
+ await ensureDir(paths.cursorDir)
8
+
9
+ if (bundle.rules.length > 0) {
10
+ const rulesDir = path.join(paths.cursorDir, "rules")
11
+ for (const rule of bundle.rules) {
12
+ await writeText(path.join(rulesDir, `${rule.name}.mdc`), rule.content + "\n")
13
+ }
14
+ }
15
+
16
+ if (bundle.commands.length > 0) {
17
+ const commandsDir = path.join(paths.cursorDir, "commands")
18
+ for (const command of bundle.commands) {
19
+ await writeText(path.join(commandsDir, `${command.name}.md`), command.content + "\n")
20
+ }
21
+ }
22
+
23
+ if (bundle.skillDirs.length > 0) {
24
+ const skillsDir = path.join(paths.cursorDir, "skills")
25
+ for (const skill of bundle.skillDirs) {
26
+ await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
27
+ }
28
+ }
29
+
30
+ if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
31
+ const mcpPath = path.join(paths.cursorDir, "mcp.json")
32
+ const backupPath = await backupFile(mcpPath)
33
+ if (backupPath) {
34
+ console.log(`Backed up existing mcp.json to ${backupPath}`)
35
+ }
36
+ await writeJson(mcpPath, { mcpServers: bundle.mcpServers })
37
+ }
38
+ }
39
+
40
+ function resolveCursorPaths(outputRoot: string) {
41
+ const base = path.basename(outputRoot)
42
+ // If already pointing at .cursor, write directly into it
43
+ if (base === ".cursor") {
44
+ return { cursorDir: outputRoot }
45
+ }
46
+ // Otherwise nest under .cursor
47
+ return { cursorDir: path.join(outputRoot, ".cursor") }
48
+ }
@@ -0,0 +1,50 @@
1
+ import path from "path"
2
+ import { copyDir, ensureDir, writeText } from "../utils/files"
3
+ import type { DroidBundle } from "../types/droid"
4
+
5
+ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): Promise<void> {
6
+ const paths = resolveDroidPaths(outputRoot)
7
+ await ensureDir(paths.root)
8
+
9
+ if (bundle.commands.length > 0) {
10
+ await ensureDir(paths.commandsDir)
11
+ for (const command of bundle.commands) {
12
+ await writeText(path.join(paths.commandsDir, `${command.name}.md`), command.content + "\n")
13
+ }
14
+ }
15
+
16
+ if (bundle.droids.length > 0) {
17
+ await ensureDir(paths.droidsDir)
18
+ for (const droid of bundle.droids) {
19
+ await writeText(path.join(paths.droidsDir, `${droid.name}.md`), droid.content + "\n")
20
+ }
21
+ }
22
+
23
+ if (bundle.skillDirs.length > 0) {
24
+ await ensureDir(paths.skillsDir)
25
+ for (const skill of bundle.skillDirs) {
26
+ await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
27
+ }
28
+ }
29
+ }
30
+
31
+ function resolveDroidPaths(outputRoot: string) {
32
+ const base = path.basename(outputRoot)
33
+ // If pointing directly at ~/.factory or .factory, write into it
34
+ if (base === ".factory") {
35
+ return {
36
+ root: outputRoot,
37
+ commandsDir: path.join(outputRoot, "commands"),
38
+ droidsDir: path.join(outputRoot, "droids"),
39
+ skillsDir: path.join(outputRoot, "skills"),
40
+ }
41
+ }
42
+
43
+ // Otherwise nest under .factory
44
+ return {
45
+ root: outputRoot,
46
+ commandsDir: path.join(outputRoot, ".factory", "commands"),
47
+ droidsDir: path.join(outputRoot, ".factory", "droids"),
48
+ skillsDir: path.join(outputRoot, ".factory", "skills"),
49
+ }
50
+ }
@@ -1,10 +1,16 @@
1
1
  import type { ClaudePlugin } from "../types/claude"
2
2
  import type { OpenCodeBundle } from "../types/opencode"
3
3
  import type { CodexBundle } from "../types/codex"
4
+ import type { DroidBundle } from "../types/droid"
5
+ import type { CursorBundle } from "../types/cursor"
4
6
  import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
5
7
  import { convertClaudeToCodex } from "../converters/claude-to-codex"
8
+ import { convertClaudeToDroid } from "../converters/claude-to-droid"
9
+ import { convertClaudeToCursor } from "../converters/claude-to-cursor"
6
10
  import { writeOpenCodeBundle } from "./opencode"
7
11
  import { writeCodexBundle } from "./codex"
12
+ import { writeDroidBundle } from "./droid"
13
+ import { writeCursorBundle } from "./cursor"
8
14
 
9
15
  export type TargetHandler<TBundle = unknown> = {
10
16
  name: string
@@ -26,4 +32,16 @@ export const targets: Record<string, TargetHandler> = {
26
32
  convert: convertClaudeToCodex as TargetHandler<CodexBundle>["convert"],
27
33
  write: writeCodexBundle as TargetHandler<CodexBundle>["write"],
28
34
  },
35
+ droid: {
36
+ name: "droid",
37
+ implemented: true,
38
+ convert: convertClaudeToDroid as TargetHandler<DroidBundle>["convert"],
39
+ write: writeDroidBundle as TargetHandler<DroidBundle>["write"],
40
+ },
41
+ cursor: {
42
+ name: "cursor",
43
+ implemented: true,
44
+ convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
45
+ write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
46
+ },
29
47
  }
@@ -0,0 +1,29 @@
1
+ export type CursorRule = {
2
+ name: string
3
+ content: string
4
+ }
5
+
6
+ export type CursorCommand = {
7
+ name: string
8
+ content: string
9
+ }
10
+
11
+ export type CursorSkillDir = {
12
+ name: string
13
+ sourceDir: string
14
+ }
15
+
16
+ export type CursorMcpServer = {
17
+ command?: string
18
+ args?: string[]
19
+ env?: Record<string, string>
20
+ url?: string
21
+ headers?: Record<string, string>
22
+ }
23
+
24
+ export type CursorBundle = {
25
+ rules: CursorRule[]
26
+ commands: CursorCommand[]
27
+ skillDirs: CursorSkillDir[]
28
+ mcpServers?: Record<string, CursorMcpServer>
29
+ }
@@ -0,0 +1,20 @@
1
+ export type DroidCommandFile = {
2
+ name: string
3
+ content: string
4
+ }
5
+
6
+ export type DroidAgentFile = {
7
+ name: string
8
+ content: string
9
+ }
10
+
11
+ export type DroidSkillDir = {
12
+ name: string
13
+ sourceDir: string
14
+ }
15
+
16
+ export type DroidBundle = {
17
+ commands: DroidCommandFile[]
18
+ droids: DroidAgentFile[]
19
+ skillDirs: DroidSkillDir[]
20
+ }
@@ -210,6 +210,68 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`,
210
210
  expect(commandSkills[0].name).toBe("normal-command")
211
211
  })
212
212
 
213
+ test("rewrites .claude/ paths to .codex/ in command skill bodies", () => {
214
+ const plugin: ClaudePlugin = {
215
+ ...fixturePlugin,
216
+ commands: [
217
+ {
218
+ name: "review",
219
+ description: "Review command",
220
+ body: `Read \`compound-engineering.local.md\` in the project root.
221
+
222
+ If no settings file exists, auto-detect project type.
223
+
224
+ Run \`/compound-engineering-setup\` to create a settings file.`,
225
+ sourcePath: "/tmp/plugin/commands/review.md",
226
+ },
227
+ ],
228
+ agents: [],
229
+ skills: [],
230
+ }
231
+
232
+ const bundle = convertClaudeToCodex(plugin, {
233
+ agentMode: "subagent",
234
+ inferTemperature: false,
235
+ permissions: "none",
236
+ })
237
+
238
+ const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
239
+ expect(commandSkill).toBeDefined()
240
+ const parsed = parseFrontmatter(commandSkill!.content)
241
+
242
+ // Tool-agnostic path in project root — no rewriting needed
243
+ expect(parsed.body).toContain("compound-engineering.local.md")
244
+ })
245
+
246
+ test("rewrites .claude/ paths in agent skill bodies", () => {
247
+ const plugin: ClaudePlugin = {
248
+ ...fixturePlugin,
249
+ commands: [],
250
+ skills: [],
251
+ agents: [
252
+ {
253
+ name: "config-reader",
254
+ description: "Reads config",
255
+ body: "Read `compound-engineering.local.md` for config.",
256
+ sourcePath: "/tmp/plugin/agents/config-reader.md",
257
+ },
258
+ ],
259
+ }
260
+
261
+ const bundle = convertClaudeToCodex(plugin, {
262
+ agentMode: "subagent",
263
+ inferTemperature: false,
264
+ permissions: "none",
265
+ })
266
+
267
+ const agentSkill = bundle.generatedSkills.find((s) => s.name === "config-reader")
268
+ expect(agentSkill).toBeDefined()
269
+ const parsed = parseFrontmatter(agentSkill!.content)
270
+
271
+ // Tool-agnostic path in project root — no rewriting needed
272
+ expect(parsed.body).toContain("compound-engineering.local.md")
273
+ })
274
+
213
275
  test("truncates generated skill descriptions to Codex limits and single line", () => {
214
276
  const longDescription = `Line one\nLine two ${"a".repeat(2000)}`
215
277
  const plugin: ClaudePlugin = {