@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.cursor-plugin/marketplace.json +25 -0
- package/CHANGELOG.md +47 -0
- package/README.md +29 -6
- package/bun.lock +1 -0
- package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
- package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
- package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
- package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
- package/docs/specs/copilot.md +122 -0
- package/docs/specs/gemini.md +122 -0
- package/package.json +1 -1
- package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
- package/plugins/compound-engineering/.mcp.json +8 -0
- package/plugins/compound-engineering/CHANGELOG.md +27 -0
- package/plugins/compound-engineering/commands/lfg.md +3 -3
- package/plugins/compound-engineering/commands/slfg.md +2 -2
- package/plugins/compound-engineering/commands/workflows/plan.md +18 -1
- package/plugins/compound-engineering/commands/workflows/work.md +8 -1
- package/src/commands/convert.ts +14 -25
- package/src/commands/install.ts +27 -25
- package/src/commands/sync.ts +44 -21
- package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
- package/src/converters/claude-to-gemini.ts +193 -0
- package/src/converters/claude-to-opencode.ts +16 -0
- package/src/converters/claude-to-pi.ts +205 -0
- package/src/sync/copilot.ts +100 -0
- package/src/sync/droid.ts +21 -0
- package/src/sync/pi.ts +88 -0
- package/src/targets/copilot.ts +48 -0
- package/src/targets/gemini.ts +68 -0
- package/src/targets/index.ts +25 -7
- package/src/targets/pi.ts +131 -0
- package/src/templates/pi/compat-extension.ts +452 -0
- package/src/types/copilot.ts +31 -0
- package/src/types/gemini.ts +29 -0
- package/src/types/pi.ts +40 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/utils/resolve-home.ts +17 -0
- package/tests/cli.test.ts +76 -0
- package/tests/converter.test.ts +29 -0
- package/tests/copilot-converter.test.ts +467 -0
- package/tests/copilot-writer.test.ts +189 -0
- package/tests/gemini-converter.test.ts +373 -0
- package/tests/gemini-writer.test.ts +181 -0
- package/tests/pi-converter.test.ts +116 -0
- package/tests/pi-writer.test.ts +99 -0
- package/tests/sync-copilot.test.ts +148 -0
- package/tests/sync-droid.test.ts +57 -0
- package/tests/sync-pi.test.ts +68 -0
- package/src/targets/cursor.ts +0 -48
- package/src/types/cursor.ts +0 -29
- package/tests/cursor-converter.test.ts +0 -347
- 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 {
|
|
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
|
|
11
|
+
export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
const COPILOT_BODY_CHAR_LIMIT = 30_000
|
|
14
|
+
|
|
15
|
+
export function convertClaudeToCopilot(
|
|
9
16
|
plugin: ClaudePlugin,
|
|
10
|
-
_options:
|
|
11
|
-
):
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
37
|
+
const mcpConfig = convertMcpServers(plugin.mcpServers)
|
|
23
38
|
|
|
24
39
|
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
|
|
25
|
-
console.warn("Warning:
|
|
40
|
+
console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
return {
|
|
43
|
+
return { agents, generatedSkills, skillDirs, mcpConfig }
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
function
|
|
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
|
-
|
|
52
|
+
tools: ["*"],
|
|
53
|
+
infer: true,
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
85
|
+
const frontmatter: Record<string, unknown> = {
|
|
86
|
+
name,
|
|
87
|
+
}
|
|
58
88
|
if (command.description) {
|
|
59
|
-
|
|
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 =
|
|
98
|
+
const transformedBody = transformContentForCopilot(command.body.trim())
|
|
67
99
|
sections.push(transformedBody)
|
|
68
100
|
|
|
69
|
-
const
|
|
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 (
|
|
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
|
|
97
|
-
return `/${
|
|
121
|
+
const normalized = flattenCommandName(commandName)
|
|
122
|
+
return `/${normalized}`
|
|
98
123
|
})
|
|
99
124
|
|
|
100
|
-
// 3. Rewrite .claude/ paths to .
|
|
125
|
+
// 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/
|
|
101
126
|
result = result
|
|
102
|
-
.replace(/~\/\.claude\//g, "~/.
|
|
103
|
-
.replace(/\.claude\//g, ".
|
|
127
|
+
.replace(/~\/\.claude\//g, "~/.copilot/")
|
|
128
|
+
.replace(/\.claude\//g, ".github/")
|
|
104
129
|
|
|
105
130
|
// 4. Transform @agent-name references
|
|
106
|
-
const agentRefPattern =
|
|
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)}
|
|
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,
|
|
142
|
+
): Record<string, CopilotMcpServer> | undefined {
|
|
117
143
|
if (!servers || Object.keys(servers).length === 0) return undefined
|
|
118
144
|
|
|
119
|
-
const result: Record<string,
|
|
145
|
+
const result: Record<string, CopilotMcpServer> = {}
|
|
120
146
|
for (const [name, server] of Object.entries(servers)) {
|
|
121
|
-
const entry:
|
|
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
|
-
|
|
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
|
+
}
|