@every-env/compound-plugin 0.5.2 → 0.8.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.cursor-plugin/marketplace.json +25 -0
  3. package/CHANGELOG.md +47 -0
  4. package/README.md +29 -6
  5. package/bun.lock +1 -0
  6. package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
  7. package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
  8. package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
  9. package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
  10. package/docs/specs/copilot.md +122 -0
  11. package/docs/specs/gemini.md +122 -0
  12. package/package.json +1 -1
  13. package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
  14. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  15. package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
  16. package/plugins/compound-engineering/.mcp.json +8 -0
  17. package/plugins/compound-engineering/CHANGELOG.md +27 -0
  18. package/plugins/compound-engineering/commands/lfg.md +3 -3
  19. package/plugins/compound-engineering/commands/slfg.md +2 -2
  20. package/plugins/compound-engineering/commands/workflows/plan.md +18 -1
  21. package/plugins/compound-engineering/commands/workflows/work.md +8 -1
  22. package/src/commands/convert.ts +14 -25
  23. package/src/commands/install.ts +27 -25
  24. package/src/commands/sync.ts +44 -21
  25. package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
  26. package/src/converters/claude-to-gemini.ts +193 -0
  27. package/src/converters/claude-to-opencode.ts +16 -0
  28. package/src/converters/claude-to-pi.ts +205 -0
  29. package/src/sync/copilot.ts +100 -0
  30. package/src/sync/droid.ts +21 -0
  31. package/src/sync/pi.ts +88 -0
  32. package/src/targets/copilot.ts +48 -0
  33. package/src/targets/gemini.ts +68 -0
  34. package/src/targets/index.ts +25 -7
  35. package/src/targets/pi.ts +131 -0
  36. package/src/templates/pi/compat-extension.ts +452 -0
  37. package/src/types/copilot.ts +31 -0
  38. package/src/types/gemini.ts +29 -0
  39. package/src/types/pi.ts +40 -0
  40. package/src/utils/frontmatter.ts +1 -1
  41. package/src/utils/resolve-home.ts +17 -0
  42. package/tests/cli.test.ts +76 -0
  43. package/tests/converter.test.ts +29 -0
  44. package/tests/copilot-converter.test.ts +467 -0
  45. package/tests/copilot-writer.test.ts +189 -0
  46. package/tests/gemini-converter.test.ts +373 -0
  47. package/tests/gemini-writer.test.ts +181 -0
  48. package/tests/pi-converter.test.ts +116 -0
  49. package/tests/pi-writer.test.ts +99 -0
  50. package/tests/sync-copilot.test.ts +148 -0
  51. package/tests/sync-droid.test.ts +57 -0
  52. package/tests/sync-pi.test.ts +68 -0
  53. package/src/targets/cursor.ts +0 -48
  54. package/src/types/cursor.ts +0 -29
  55. package/tests/cursor-converter.test.ts +0 -347
  56. package/tests/cursor-writer.test.ts +0 -137
@@ -1,43 +1,63 @@
1
1
  import { formatFrontmatter } from "../utils/frontmatter"
2
2
  import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
- import type { CursorBundle, CursorCommand, CursorMcpServer, CursorRule } from "../types/cursor"
3
+ import type {
4
+ CopilotAgent,
5
+ CopilotBundle,
6
+ CopilotGeneratedSkill,
7
+ CopilotMcpServer,
8
+ } from "../types/copilot"
4
9
  import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
10
 
6
- export type ClaudeToCursorOptions = ClaudeToOpenCodeOptions
11
+ export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions
7
12
 
8
- export function convertClaudeToCursor(
13
+ const COPILOT_BODY_CHAR_LIMIT = 30_000
14
+
15
+ export function convertClaudeToCopilot(
9
16
  plugin: ClaudePlugin,
10
- _options: ClaudeToCursorOptions,
11
- ): CursorBundle {
12
- const usedRuleNames = new Set<string>()
13
- const usedCommandNames = new Set<string>()
17
+ _options: ClaudeToCopilotOptions,
18
+ ): CopilotBundle {
19
+ const usedAgentNames = new Set<string>()
20
+ const usedSkillNames = new Set<string>()
21
+
22
+ const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
23
+
24
+ // Reserve skill names first so generated skills (from commands) don't collide
25
+ const skillDirs = plugin.skills.map((skill) => {
26
+ usedSkillNames.add(skill.name)
27
+ return {
28
+ name: skill.name,
29
+ sourceDir: skill.sourceDir,
30
+ }
31
+ })
14
32
 
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
- }))
33
+ const generatedSkills = plugin.commands.map((command) =>
34
+ convertCommandToSkill(command, usedSkillNames),
35
+ )
21
36
 
22
- const mcpServers = convertMcpServers(plugin.mcpServers)
37
+ const mcpConfig = convertMcpServers(plugin.mcpServers)
23
38
 
24
39
  if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
25
- console.warn("Warning: Cursor does not support hooks. Hooks were skipped during conversion.")
40
+ console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
26
41
  }
27
42
 
28
- return { rules, commands, skillDirs, mcpServers }
43
+ return { agents, generatedSkills, skillDirs, mcpConfig }
29
44
  }
30
45
 
31
- function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorRule {
46
+ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CopilotAgent {
32
47
  const name = uniqueName(normalizeName(agent.name), usedNames)
33
48
  const description = agent.description ?? `Converted from Claude agent ${agent.name}`
34
49
 
35
50
  const frontmatter: Record<string, unknown> = {
36
51
  description,
37
- alwaysApply: false,
52
+ tools: ["*"],
53
+ infer: true,
38
54
  }
39
55
 
40
- let body = transformContentForCursor(agent.body.trim())
56
+ if (agent.model) {
57
+ frontmatter.model = agent.model
58
+ }
59
+
60
+ let body = transformContentForCopilot(agent.body.trim())
41
61
  if (agent.capabilities && agent.capabilities.length > 0) {
42
62
  const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
43
63
  body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
@@ -46,39 +66,44 @@ function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorR
46
66
  body = `Instructions converted from the ${agent.name} agent.`
47
67
  }
48
68
 
69
+ if (body.length > COPILOT_BODY_CHAR_LIMIT) {
70
+ console.warn(
71
+ `Warning: Agent "${agent.name}" body exceeds ${COPILOT_BODY_CHAR_LIMIT} characters (${body.length}). Copilot may truncate it.`,
72
+ )
73
+ }
74
+
49
75
  const content = formatFrontmatter(frontmatter, body)
50
76
  return { name, content }
51
77
  }
52
78
 
53
- function convertCommand(command: ClaudeCommand, usedNames: Set<string>): CursorCommand {
79
+ function convertCommandToSkill(
80
+ command: ClaudeCommand,
81
+ usedNames: Set<string>,
82
+ ): CopilotGeneratedSkill {
54
83
  const name = uniqueName(flattenCommandName(command.name), usedNames)
55
84
 
56
- const sections: string[] = []
57
-
85
+ const frontmatter: Record<string, unknown> = {
86
+ name,
87
+ }
58
88
  if (command.description) {
59
- sections.push(`<!-- ${command.description} -->`)
89
+ frontmatter.description = command.description
60
90
  }
61
91
 
92
+ const sections: string[] = []
93
+
62
94
  if (command.argumentHint) {
63
95
  sections.push(`## Arguments\n${command.argumentHint}`)
64
96
  }
65
97
 
66
- const transformedBody = transformContentForCursor(command.body.trim())
98
+ const transformedBody = transformContentForCopilot(command.body.trim())
67
99
  sections.push(transformedBody)
68
100
 
69
- const content = sections.filter(Boolean).join("\n\n").trim()
101
+ const body = sections.filter(Boolean).join("\n\n").trim()
102
+ const content = formatFrontmatter(frontmatter, body)
70
103
  return { name, content }
71
104
  }
72
105
 
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 {
106
+ export function transformContentForCopilot(body: string): string {
82
107
  let result = body
83
108
 
84
109
  // 1. Transform Task agent calls
@@ -88,24 +113,25 @@ export function transformContentForCursor(body: string): string {
88
113
  return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
89
114
  })
90
115
 
91
- // 2. Transform slash command references (flatten namespaces)
116
+ // 2. Transform slash command references (replace colons with hyphens)
92
117
  const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
93
118
  result = result.replace(slashCommandPattern, (match, commandName: string) => {
94
119
  if (commandName.includes("/")) return match
95
120
  if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
96
- const flattened = flattenCommandName(commandName)
97
- return `/${flattened}`
121
+ const normalized = flattenCommandName(commandName)
122
+ return `/${normalized}`
98
123
  })
99
124
 
100
- // 3. Rewrite .claude/ paths to .cursor/
125
+ // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/
101
126
  result = result
102
- .replace(/~\/\.claude\//g, "~/.cursor/")
103
- .replace(/\.claude\//g, ".cursor/")
127
+ .replace(/~\/\.claude\//g, "~/.copilot/")
128
+ .replace(/\.claude\//g, ".github/")
104
129
 
105
130
  // 4. Transform @agent-name references
106
- const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
131
+ const agentRefPattern =
132
+ /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
107
133
  result = result.replace(agentRefPattern, (_match, agentName: string) => {
108
- return `the ${normalizeName(agentName)} rule`
134
+ return `the ${normalizeName(agentName)} agent`
109
135
  })
110
136
 
111
137
  return result
@@ -113,29 +139,47 @@ export function transformContentForCursor(body: string): string {
113
139
 
114
140
  function convertMcpServers(
115
141
  servers?: Record<string, ClaudeMcpServer>,
116
- ): Record<string, CursorMcpServer> | undefined {
142
+ ): Record<string, CopilotMcpServer> | undefined {
117
143
  if (!servers || Object.keys(servers).length === 0) return undefined
118
144
 
119
- const result: Record<string, CursorMcpServer> = {}
145
+ const result: Record<string, CopilotMcpServer> = {}
120
146
  for (const [name, server] of Object.entries(servers)) {
121
- const entry: CursorMcpServer = {}
147
+ const entry: CopilotMcpServer = {
148
+ type: server.command ? "local" : "sse",
149
+ tools: ["*"],
150
+ }
151
+
122
152
  if (server.command) {
123
153
  entry.command = server.command
124
154
  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
155
  } else if (server.url) {
127
156
  entry.url = server.url
128
157
  if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
129
158
  }
159
+
160
+ if (server.env && Object.keys(server.env).length > 0) {
161
+ entry.env = prefixEnvVars(server.env)
162
+ }
163
+
130
164
  result[name] = entry
131
165
  }
132
166
  return result
133
167
  }
134
168
 
169
+ function prefixEnvVars(env: Record<string, string>): Record<string, string> {
170
+ const result: Record<string, string> = {}
171
+ for (const [key, value] of Object.entries(env)) {
172
+ if (key.startsWith("COPILOT_MCP_")) {
173
+ result[key] = value
174
+ } else {
175
+ result[`COPILOT_MCP_${key}`] = value
176
+ }
177
+ }
178
+ return result
179
+ }
180
+
135
181
  function flattenCommandName(name: string): string {
136
- const colonIndex = name.lastIndexOf(":")
137
- const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
138
- return normalizeName(base)
182
+ return normalizeName(name)
139
183
  }
140
184
 
141
185
  function normalizeName(value: string): string {
@@ -0,0 +1,193 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
+ import type { GeminiBundle, GeminiCommand, GeminiMcpServer, GeminiSkill } from "../types/gemini"
4
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
+
6
+ export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions
7
+
8
+ const GEMINI_DESCRIPTION_MAX_LENGTH = 1024
9
+
10
+ export function convertClaudeToGemini(
11
+ plugin: ClaudePlugin,
12
+ _options: ClaudeToGeminiOptions,
13
+ ): GeminiBundle {
14
+ const usedSkillNames = new Set<string>()
15
+ const usedCommandNames = new Set<string>()
16
+
17
+ const skillDirs = plugin.skills.map((skill) => ({
18
+ name: skill.name,
19
+ sourceDir: skill.sourceDir,
20
+ }))
21
+
22
+ // Reserve skill names from pass-through skills
23
+ for (const skill of skillDirs) {
24
+ usedSkillNames.add(normalizeName(skill.name))
25
+ }
26
+
27
+ const generatedSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, usedSkillNames))
28
+
29
+ const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
30
+
31
+ const mcpServers = convertMcpServers(plugin.mcpServers)
32
+
33
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
34
+ console.warn("Warning: Gemini CLI hooks use a different format (BeforeTool/AfterTool with matchers). Hooks were skipped during conversion.")
35
+ }
36
+
37
+ return { generatedSkills, skillDirs, commands, mcpServers }
38
+ }
39
+
40
+ function convertAgentToSkill(agent: ClaudeAgent, usedNames: Set<string>): GeminiSkill {
41
+ const name = uniqueName(normalizeName(agent.name), usedNames)
42
+ const description = sanitizeDescription(
43
+ agent.description ?? `Use this skill for ${agent.name} tasks`,
44
+ )
45
+
46
+ const frontmatter: Record<string, unknown> = { name, description }
47
+
48
+ let body = transformContentForGemini(agent.body.trim())
49
+ if (agent.capabilities && agent.capabilities.length > 0) {
50
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
51
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
52
+ }
53
+ if (body.length === 0) {
54
+ body = `Instructions converted from the ${agent.name} agent.`
55
+ }
56
+
57
+ const content = formatFrontmatter(frontmatter, body)
58
+ return { name, content }
59
+ }
60
+
61
+ function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiCommand {
62
+ // Preserve namespace structure: workflows:plan -> workflows/plan
63
+ const commandPath = resolveCommandPath(command.name)
64
+ const pathKey = commandPath.join("/")
65
+ uniqueName(pathKey, usedNames) // Track for dedup
66
+
67
+ const description = command.description ?? `Converted from Claude command ${command.name}`
68
+ const transformedBody = transformContentForGemini(command.body.trim())
69
+
70
+ let prompt = transformedBody
71
+ if (command.argumentHint) {
72
+ prompt += `\n\nUser request: {{args}}`
73
+ }
74
+
75
+ const content = toToml(description, prompt)
76
+ return { name: pathKey, content }
77
+ }
78
+
79
+ /**
80
+ * Transform Claude Code content to Gemini-compatible content.
81
+ *
82
+ * 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
83
+ * 2. Path rewriting: .claude/ -> .gemini/, ~/.claude/ -> ~/.gemini/
84
+ * 3. Agent references: @agent-name -> the agent-name skill
85
+ */
86
+ export function transformContentForGemini(body: string): string {
87
+ let result = body
88
+
89
+ // 1. Transform Task agent calls
90
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
91
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
92
+ const skillName = normalizeName(agentName)
93
+ return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
94
+ })
95
+
96
+ // 2. Rewrite .claude/ paths to .gemini/
97
+ result = result
98
+ .replace(/~\/\.claude\//g, "~/.gemini/")
99
+ .replace(/\.claude\//g, ".gemini/")
100
+
101
+ // 3. Transform @agent-name references
102
+ const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
103
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
104
+ return `the ${normalizeName(agentName)} skill`
105
+ })
106
+
107
+ return result
108
+ }
109
+
110
+ function convertMcpServers(
111
+ servers?: Record<string, ClaudeMcpServer>,
112
+ ): Record<string, GeminiMcpServer> | undefined {
113
+ if (!servers || Object.keys(servers).length === 0) return undefined
114
+
115
+ const result: Record<string, GeminiMcpServer> = {}
116
+ for (const [name, server] of Object.entries(servers)) {
117
+ const entry: GeminiMcpServer = {}
118
+ if (server.command) {
119
+ entry.command = server.command
120
+ if (server.args && server.args.length > 0) entry.args = server.args
121
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
122
+ } else if (server.url) {
123
+ entry.url = server.url
124
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
125
+ }
126
+ result[name] = entry
127
+ }
128
+ return result
129
+ }
130
+
131
+ /**
132
+ * Resolve command name to path segments.
133
+ * workflows:plan -> ["workflows", "plan"]
134
+ * plan -> ["plan"]
135
+ */
136
+ function resolveCommandPath(name: string): string[] {
137
+ return name.split(":").map((segment) => normalizeName(segment))
138
+ }
139
+
140
+ /**
141
+ * Serialize to TOML command format.
142
+ * Uses multi-line strings (""") for prompt field.
143
+ */
144
+ export function toToml(description: string, prompt: string): string {
145
+ const lines: string[] = []
146
+ lines.push(`description = ${formatTomlString(description)}`)
147
+
148
+ // Use multi-line string for prompt
149
+ const escapedPrompt = prompt.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"')
150
+ lines.push(`prompt = """`)
151
+ lines.push(escapedPrompt)
152
+ lines.push(`"""`)
153
+
154
+ return lines.join("\n")
155
+ }
156
+
157
+ function formatTomlString(value: string): string {
158
+ return JSON.stringify(value)
159
+ }
160
+
161
+ function normalizeName(value: string): string {
162
+ const trimmed = value.trim()
163
+ if (!trimmed) return "item"
164
+ const normalized = trimmed
165
+ .toLowerCase()
166
+ .replace(/[\\/]+/g, "-")
167
+ .replace(/[:\s]+/g, "-")
168
+ .replace(/[^a-z0-9_-]+/g, "-")
169
+ .replace(/-+/g, "-")
170
+ .replace(/^-+|-+$/g, "")
171
+ return normalized || "item"
172
+ }
173
+
174
+ function sanitizeDescription(value: string, maxLength = GEMINI_DESCRIPTION_MAX_LENGTH): string {
175
+ const normalized = value.replace(/\s+/g, " ").trim()
176
+ if (normalized.length <= maxLength) return normalized
177
+ const ellipsis = "..."
178
+ return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
179
+ }
180
+
181
+ function uniqueName(base: string, used: Set<string>): string {
182
+ if (!used.has(base)) {
183
+ used.add(base)
184
+ return base
185
+ }
186
+ let index = 2
187
+ while (used.has(`${base}-${index}`)) {
188
+ index += 1
189
+ }
190
+ const name = `${base}-${index}`
191
+ used.add(name)
192
+ return name
193
+ }
@@ -250,8 +250,24 @@ function rewriteClaudePaths(body: string): string {
250
250
  .replace(/\.claude\//g, ".opencode/")
251
251
  }
252
252
 
253
+ // Bare Claude family aliases used in Claude Code (e.g. `model: haiku`).
254
+ // Update these when new model generations are released.
255
+ const CLAUDE_FAMILY_ALIASES: Record<string, string> = {
256
+ haiku: "claude-haiku-4-5",
257
+ sonnet: "claude-sonnet-4-5",
258
+ opus: "claude-opus-4-6",
259
+ }
260
+
253
261
  function normalizeModel(model: string): string {
254
262
  if (model.includes("/")) return model
263
+ if (CLAUDE_FAMILY_ALIASES[model]) {
264
+ const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}`
265
+ console.warn(
266
+ `Warning: bare model alias "${model}" mapped to "${resolved}". ` +
267
+ `Update CLAUDE_FAMILY_ALIASES if a newer version is available.`,
268
+ )
269
+ return resolved
270
+ }
255
271
  if (/^claude-/.test(model)) return `anthropic/${model}`
256
272
  if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}`
257
273
  if (/^gemini-/.test(model)) return `google/${model}`
@@ -0,0 +1,205 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
+ import type {
4
+ PiBundle,
5
+ PiGeneratedSkill,
6
+ PiMcporterConfig,
7
+ PiMcporterServer,
8
+ } from "../types/pi"
9
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
10
+ import { PI_COMPAT_EXTENSION_SOURCE } from "../templates/pi/compat-extension"
11
+
12
+ export type ClaudeToPiOptions = ClaudeToOpenCodeOptions
13
+
14
+ const PI_DESCRIPTION_MAX_LENGTH = 1024
15
+
16
+ export function convertClaudeToPi(
17
+ plugin: ClaudePlugin,
18
+ _options: ClaudeToPiOptions,
19
+ ): PiBundle {
20
+ const promptNames = new Set<string>()
21
+ const usedSkillNames = new Set<string>(plugin.skills.map((skill) => normalizeName(skill.name)))
22
+
23
+ const prompts = plugin.commands
24
+ .filter((command) => !command.disableModelInvocation)
25
+ .map((command) => convertPrompt(command, promptNames))
26
+
27
+ const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
28
+
29
+ const extensions = [
30
+ {
31
+ name: "compound-engineering-compat.ts",
32
+ content: PI_COMPAT_EXTENSION_SOURCE,
33
+ },
34
+ ]
35
+
36
+ return {
37
+ prompts,
38
+ skillDirs: plugin.skills.map((skill) => ({
39
+ name: skill.name,
40
+ sourceDir: skill.sourceDir,
41
+ })),
42
+ generatedSkills,
43
+ extensions,
44
+ mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
45
+ }
46
+ }
47
+
48
+ function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
49
+ const name = uniqueName(normalizeName(command.name), usedNames)
50
+ const frontmatter: Record<string, unknown> = {
51
+ description: command.description,
52
+ "argument-hint": command.argumentHint,
53
+ }
54
+
55
+ let body = transformContentForPi(command.body)
56
+ body = appendCompatibilityNoteIfNeeded(body)
57
+
58
+ return {
59
+ name,
60
+ content: formatFrontmatter(frontmatter, body.trim()),
61
+ }
62
+ }
63
+
64
+ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSkill {
65
+ const name = uniqueName(normalizeName(agent.name), usedNames)
66
+ const description = sanitizeDescription(
67
+ agent.description ?? `Converted from Claude agent ${agent.name}`,
68
+ )
69
+
70
+ const frontmatter: Record<string, unknown> = {
71
+ name,
72
+ description,
73
+ }
74
+
75
+ const sections: string[] = []
76
+ if (agent.capabilities && agent.capabilities.length > 0) {
77
+ sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
78
+ }
79
+
80
+ const body = [
81
+ ...sections,
82
+ agent.body.trim().length > 0
83
+ ? agent.body.trim()
84
+ : `Instructions converted from the ${agent.name} agent.`,
85
+ ].join("\n\n")
86
+
87
+ return {
88
+ name,
89
+ content: formatFrontmatter(frontmatter, body),
90
+ }
91
+ }
92
+
93
+ function transformContentForPi(body: string): string {
94
+ let result = body
95
+
96
+ // Task repo-research-analyst(feature_description)
97
+ // -> Run subagent with agent="repo-research-analyst" and task="feature_description"
98
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
99
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
100
+ const skillName = normalizeName(agentName)
101
+ const trimmedArgs = args.trim().replace(/\s+/g, " ")
102
+ return `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
103
+ })
104
+
105
+ // Claude-specific tool references
106
+ result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question")
107
+ result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:file-todos)")
108
+ result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:file-todos)")
109
+
110
+ // /command-name or /workflows:command-name -> /workflows-command-name
111
+ const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
112
+ result = result.replace(slashCommandPattern, (match, commandName: string) => {
113
+ if (commandName.includes("/")) return match
114
+ if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) {
115
+ return match
116
+ }
117
+
118
+ if (commandName.startsWith("skill:")) {
119
+ const skillName = commandName.slice("skill:".length)
120
+ return `/skill:${normalizeName(skillName)}`
121
+ }
122
+
123
+ const withoutPrefix = commandName.startsWith("prompts:")
124
+ ? commandName.slice("prompts:".length)
125
+ : commandName
126
+
127
+ return `/${normalizeName(withoutPrefix)}`
128
+ })
129
+
130
+ return result
131
+ }
132
+
133
+ function appendCompatibilityNoteIfNeeded(body: string): string {
134
+ if (!/\bmcp\b/i.test(body)) return body
135
+
136
+ const note = [
137
+ "",
138
+ "## Pi + MCPorter note",
139
+ "For MCP access in Pi, use MCPorter via the generated tools:",
140
+ "- `mcporter_list` to inspect available MCP tools",
141
+ "- `mcporter_call` to invoke a tool",
142
+ "",
143
+ ].join("\n")
144
+
145
+ return body + note
146
+ }
147
+
148
+ function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
149
+ const mcpServers: Record<string, PiMcporterServer> = {}
150
+
151
+ for (const [name, server] of Object.entries(servers)) {
152
+ if (server.command) {
153
+ mcpServers[name] = {
154
+ command: server.command,
155
+ args: server.args,
156
+ env: server.env,
157
+ headers: server.headers,
158
+ }
159
+ continue
160
+ }
161
+
162
+ if (server.url) {
163
+ mcpServers[name] = {
164
+ baseUrl: server.url,
165
+ headers: server.headers,
166
+ }
167
+ }
168
+ }
169
+
170
+ return { mcpServers }
171
+ }
172
+
173
+ function normalizeName(value: string): string {
174
+ const trimmed = value.trim()
175
+ if (!trimmed) return "item"
176
+ const normalized = trimmed
177
+ .toLowerCase()
178
+ .replace(/[\\/]+/g, "-")
179
+ .replace(/[:\s]+/g, "-")
180
+ .replace(/[^a-z0-9_-]+/g, "-")
181
+ .replace(/-+/g, "-")
182
+ .replace(/^-+|-+$/g, "")
183
+ return normalized || "item"
184
+ }
185
+
186
+ function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGTH): string {
187
+ const normalized = value.replace(/\s+/g, " ").trim()
188
+ if (normalized.length <= maxLength) return normalized
189
+ const ellipsis = "..."
190
+ return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
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
+ }