@every-env/compound-plugin 0.5.1 → 0.7.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/CHANGELOG.md +34 -0
- package/README.md +20 -3
- package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
- package/docs/specs/gemini.md +122 -0
- package/package.json +1 -1
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/CHANGELOG.md +17 -0
- package/plugins/compound-engineering/commands/workflows/plan.md +3 -0
- package/plugins/compound-engineering/commands/workflows/work.md +8 -1
- package/src/commands/convert.ts +14 -25
- package/src/commands/install.ts +28 -26
- package/src/commands/sync.ts +44 -21
- 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/cursor.ts +78 -0
- package/src/sync/droid.ts +21 -0
- package/src/sync/pi.ts +88 -0
- package/src/targets/gemini.ts +68 -0
- package/src/targets/index.ts +18 -0
- package/src/targets/pi.ts +131 -0
- package/src/templates/pi/compat-extension.ts +452 -0
- package/src/types/gemini.ts +29 -0
- package/src/types/pi.ts +40 -0
- package/src/utils/resolve-home.ts +17 -0
- package/tests/cli.test.ts +76 -0
- package/tests/converter.test.ts +29 -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-cursor.test.ts +92 -0
- package/tests/sync-droid.test.ts +57 -0
- package/tests/sync-pi.test.ts +68 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
|
4
|
+
import type { ClaudeMcpServer } from "../types/claude"
|
|
5
|
+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
|
6
|
+
|
|
7
|
+
type CursorMcpServer = {
|
|
8
|
+
command?: string
|
|
9
|
+
args?: string[]
|
|
10
|
+
url?: string
|
|
11
|
+
env?: Record<string, string>
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type CursorMcpConfig = {
|
|
16
|
+
mcpServers: Record<string, CursorMcpServer>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function syncToCursor(
|
|
20
|
+
config: ClaudeHomeConfig,
|
|
21
|
+
outputRoot: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const skillsDir = path.join(outputRoot, "skills")
|
|
24
|
+
await fs.mkdir(skillsDir, { recursive: true })
|
|
25
|
+
|
|
26
|
+
for (const skill of config.skills) {
|
|
27
|
+
if (!isValidSkillName(skill.name)) {
|
|
28
|
+
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
const target = path.join(skillsDir, skill.name)
|
|
32
|
+
await forceSymlink(skill.sourceDir, target)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Object.keys(config.mcpServers).length > 0) {
|
|
36
|
+
const mcpPath = path.join(outputRoot, "mcp.json")
|
|
37
|
+
const existing = await readJsonSafe(mcpPath)
|
|
38
|
+
const converted = convertMcpForCursor(config.mcpServers)
|
|
39
|
+
const merged: CursorMcpConfig = {
|
|
40
|
+
mcpServers: {
|
|
41
|
+
...(existing.mcpServers ?? {}),
|
|
42
|
+
...converted,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readJsonSafe(filePath: string): Promise<Partial<CursorMcpConfig>> {
|
|
50
|
+
try {
|
|
51
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
52
|
+
return JSON.parse(content) as Partial<CursorMcpConfig>
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
55
|
+
return {}
|
|
56
|
+
}
|
|
57
|
+
throw err
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function convertMcpForCursor(
|
|
62
|
+
servers: Record<string, ClaudeMcpServer>,
|
|
63
|
+
): Record<string, CursorMcpServer> {
|
|
64
|
+
const result: Record<string, CursorMcpServer> = {}
|
|
65
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
66
|
+
const entry: CursorMcpServer = {}
|
|
67
|
+
if (server.command) {
|
|
68
|
+
entry.command = server.command
|
|
69
|
+
if (server.args && server.args.length > 0) entry.args = server.args
|
|
70
|
+
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
|
|
71
|
+
} else if (server.url) {
|
|
72
|
+
entry.url = server.url
|
|
73
|
+
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
|
|
74
|
+
}
|
|
75
|
+
result[name] = entry
|
|
76
|
+
}
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
|
4
|
+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
|
5
|
+
|
|
6
|
+
export async function syncToDroid(
|
|
7
|
+
config: ClaudeHomeConfig,
|
|
8
|
+
outputRoot: string,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
const skillsDir = path.join(outputRoot, "skills")
|
|
11
|
+
await fs.mkdir(skillsDir, { recursive: true })
|
|
12
|
+
|
|
13
|
+
for (const skill of config.skills) {
|
|
14
|
+
if (!isValidSkillName(skill.name)) {
|
|
15
|
+
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
const target = path.join(skillsDir, skill.name)
|
|
19
|
+
await forceSymlink(skill.sourceDir, target)
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/sync/pi.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
|
4
|
+
import type { ClaudeMcpServer } from "../types/claude"
|
|
5
|
+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
|
6
|
+
|
|
7
|
+
type McporterServer = {
|
|
8
|
+
baseUrl?: string
|
|
9
|
+
command?: string
|
|
10
|
+
args?: string[]
|
|
11
|
+
env?: Record<string, string>
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type McporterConfig = {
|
|
16
|
+
mcpServers: Record<string, McporterServer>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function syncToPi(
|
|
20
|
+
config: ClaudeHomeConfig,
|
|
21
|
+
outputRoot: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const skillsDir = path.join(outputRoot, "skills")
|
|
24
|
+
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
|
|
25
|
+
|
|
26
|
+
await fs.mkdir(skillsDir, { recursive: true })
|
|
27
|
+
|
|
28
|
+
for (const skill of config.skills) {
|
|
29
|
+
if (!isValidSkillName(skill.name)) {
|
|
30
|
+
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const target = path.join(skillsDir, skill.name)
|
|
34
|
+
await forceSymlink(skill.sourceDir, target)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (Object.keys(config.mcpServers).length > 0) {
|
|
38
|
+
await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
|
|
39
|
+
|
|
40
|
+
const existing = await readJsonSafe(mcporterPath)
|
|
41
|
+
const converted = convertMcpToMcporter(config.mcpServers)
|
|
42
|
+
const merged: McporterConfig = {
|
|
43
|
+
mcpServers: {
|
|
44
|
+
...(existing.mcpServers ?? {}),
|
|
45
|
+
...converted.mcpServers,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJsonSafe(filePath: string): Promise<Partial<McporterConfig>> {
|
|
54
|
+
try {
|
|
55
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
56
|
+
return JSON.parse(content) as Partial<McporterConfig>
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
61
|
+
throw err
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): McporterConfig {
|
|
66
|
+
const mcpServers: Record<string, McporterServer> = {}
|
|
67
|
+
|
|
68
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
69
|
+
if (server.command) {
|
|
70
|
+
mcpServers[name] = {
|
|
71
|
+
command: server.command,
|
|
72
|
+
args: server.args,
|
|
73
|
+
env: server.env,
|
|
74
|
+
headers: server.headers,
|
|
75
|
+
}
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (server.url) {
|
|
80
|
+
mcpServers[name] = {
|
|
81
|
+
baseUrl: server.url,
|
|
82
|
+
headers: server.headers,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { mcpServers }
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
|
|
3
|
+
import type { GeminiBundle } from "../types/gemini"
|
|
4
|
+
|
|
5
|
+
export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle): Promise<void> {
|
|
6
|
+
const paths = resolveGeminiPaths(outputRoot)
|
|
7
|
+
await ensureDir(paths.geminiDir)
|
|
8
|
+
|
|
9
|
+
if (bundle.generatedSkills.length > 0) {
|
|
10
|
+
for (const skill of bundle.generatedSkills) {
|
|
11
|
+
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (bundle.skillDirs.length > 0) {
|
|
16
|
+
for (const skill of bundle.skillDirs) {
|
|
17
|
+
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (bundle.commands.length > 0) {
|
|
22
|
+
for (const command of bundle.commands) {
|
|
23
|
+
await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
|
|
28
|
+
const settingsPath = path.join(paths.geminiDir, "settings.json")
|
|
29
|
+
const backupPath = await backupFile(settingsPath)
|
|
30
|
+
if (backupPath) {
|
|
31
|
+
console.log(`Backed up existing settings.json to ${backupPath}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Merge mcpServers into existing settings if present
|
|
35
|
+
let existingSettings: Record<string, unknown> = {}
|
|
36
|
+
if (await pathExists(settingsPath)) {
|
|
37
|
+
try {
|
|
38
|
+
existingSettings = await readJson<Record<string, unknown>>(settingsPath)
|
|
39
|
+
} catch {
|
|
40
|
+
console.warn("Warning: existing settings.json could not be parsed and will be replaced.")
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const existingMcp = (existingSettings.mcpServers && typeof existingSettings.mcpServers === "object")
|
|
45
|
+
? existingSettings.mcpServers as Record<string, unknown>
|
|
46
|
+
: {}
|
|
47
|
+
const merged = { ...existingSettings, mcpServers: { ...existingMcp, ...bundle.mcpServers } }
|
|
48
|
+
await writeJson(settingsPath, merged)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveGeminiPaths(outputRoot: string) {
|
|
53
|
+
const base = path.basename(outputRoot)
|
|
54
|
+
// If already pointing at .gemini, write directly into it
|
|
55
|
+
if (base === ".gemini") {
|
|
56
|
+
return {
|
|
57
|
+
geminiDir: outputRoot,
|
|
58
|
+
skillsDir: path.join(outputRoot, "skills"),
|
|
59
|
+
commandsDir: path.join(outputRoot, "commands"),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Otherwise nest under .gemini
|
|
63
|
+
return {
|
|
64
|
+
geminiDir: path.join(outputRoot, ".gemini"),
|
|
65
|
+
skillsDir: path.join(outputRoot, ".gemini", "skills"),
|
|
66
|
+
commandsDir: path.join(outputRoot, ".gemini", "commands"),
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/targets/index.ts
CHANGED
|
@@ -3,14 +3,20 @@ import type { OpenCodeBundle } from "../types/opencode"
|
|
|
3
3
|
import type { CodexBundle } from "../types/codex"
|
|
4
4
|
import type { DroidBundle } from "../types/droid"
|
|
5
5
|
import type { CursorBundle } from "../types/cursor"
|
|
6
|
+
import type { PiBundle } from "../types/pi"
|
|
7
|
+
import type { GeminiBundle } from "../types/gemini"
|
|
6
8
|
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
|
|
7
9
|
import { convertClaudeToCodex } from "../converters/claude-to-codex"
|
|
8
10
|
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
|
9
11
|
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
|
|
12
|
+
import { convertClaudeToPi } from "../converters/claude-to-pi"
|
|
13
|
+
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
|
|
10
14
|
import { writeOpenCodeBundle } from "./opencode"
|
|
11
15
|
import { writeCodexBundle } from "./codex"
|
|
12
16
|
import { writeDroidBundle } from "./droid"
|
|
13
17
|
import { writeCursorBundle } from "./cursor"
|
|
18
|
+
import { writePiBundle } from "./pi"
|
|
19
|
+
import { writeGeminiBundle } from "./gemini"
|
|
14
20
|
|
|
15
21
|
export type TargetHandler<TBundle = unknown> = {
|
|
16
22
|
name: string
|
|
@@ -44,4 +50,16 @@ export const targets: Record<string, TargetHandler> = {
|
|
|
44
50
|
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
|
|
45
51
|
write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
|
|
46
52
|
},
|
|
53
|
+
pi: {
|
|
54
|
+
name: "pi",
|
|
55
|
+
implemented: true,
|
|
56
|
+
convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
|
|
57
|
+
write: writePiBundle as TargetHandler<PiBundle>["write"],
|
|
58
|
+
},
|
|
59
|
+
gemini: {
|
|
60
|
+
name: "gemini",
|
|
61
|
+
implemented: true,
|
|
62
|
+
convert: convertClaudeToGemini as TargetHandler<GeminiBundle>["convert"],
|
|
63
|
+
write: writeGeminiBundle as TargetHandler<GeminiBundle>["write"],
|
|
64
|
+
},
|
|
47
65
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import {
|
|
3
|
+
backupFile,
|
|
4
|
+
copyDir,
|
|
5
|
+
ensureDir,
|
|
6
|
+
pathExists,
|
|
7
|
+
readText,
|
|
8
|
+
writeJson,
|
|
9
|
+
writeText,
|
|
10
|
+
} from "../utils/files"
|
|
11
|
+
import type { PiBundle } from "../types/pi"
|
|
12
|
+
|
|
13
|
+
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
|
|
14
|
+
const PI_AGENTS_BLOCK_END = "<!-- END COMPOUND PI TOOL MAP -->"
|
|
15
|
+
|
|
16
|
+
const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
|
|
17
|
+
|
|
18
|
+
This block is managed by compound-plugin.
|
|
19
|
+
|
|
20
|
+
Compatibility notes:
|
|
21
|
+
- Claude Task(agent, args) maps to the subagent extension tool
|
|
22
|
+
- For parallel agent runs, batch multiple subagent calls with multi_tool_use.parallel
|
|
23
|
+
- AskUserQuestion maps to the ask_user_question extension tool
|
|
24
|
+
- MCP access uses MCPorter via mcporter_list and mcporter_call extension tools
|
|
25
|
+
- MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global)
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promise<void> {
|
|
29
|
+
const paths = resolvePiPaths(outputRoot)
|
|
30
|
+
|
|
31
|
+
await ensureDir(paths.skillsDir)
|
|
32
|
+
await ensureDir(paths.promptsDir)
|
|
33
|
+
await ensureDir(paths.extensionsDir)
|
|
34
|
+
|
|
35
|
+
for (const prompt of bundle.prompts) {
|
|
36
|
+
await writeText(path.join(paths.promptsDir, `${prompt.name}.md`), prompt.content + "\n")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const skill of bundle.skillDirs) {
|
|
40
|
+
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const skill of bundle.generatedSkills) {
|
|
44
|
+
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const extension of bundle.extensions) {
|
|
48
|
+
await writeText(path.join(paths.extensionsDir, extension.name), extension.content + "\n")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (bundle.mcporterConfig) {
|
|
52
|
+
const backupPath = await backupFile(paths.mcporterConfigPath)
|
|
53
|
+
if (backupPath) {
|
|
54
|
+
console.log(`Backed up existing MCPorter config to ${backupPath}`)
|
|
55
|
+
}
|
|
56
|
+
await writeJson(paths.mcporterConfigPath, bundle.mcporterConfig)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await ensurePiAgentsBlock(paths.agentsPath)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolvePiPaths(outputRoot: string) {
|
|
63
|
+
const base = path.basename(outputRoot)
|
|
64
|
+
|
|
65
|
+
// Global install root: ~/.pi/agent
|
|
66
|
+
if (base === "agent") {
|
|
67
|
+
return {
|
|
68
|
+
skillsDir: path.join(outputRoot, "skills"),
|
|
69
|
+
promptsDir: path.join(outputRoot, "prompts"),
|
|
70
|
+
extensionsDir: path.join(outputRoot, "extensions"),
|
|
71
|
+
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
|
|
72
|
+
agentsPath: path.join(outputRoot, "AGENTS.md"),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Project local .pi directory
|
|
77
|
+
if (base === ".pi") {
|
|
78
|
+
return {
|
|
79
|
+
skillsDir: path.join(outputRoot, "skills"),
|
|
80
|
+
promptsDir: path.join(outputRoot, "prompts"),
|
|
81
|
+
extensionsDir: path.join(outputRoot, "extensions"),
|
|
82
|
+
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
|
|
83
|
+
agentsPath: path.join(outputRoot, "AGENTS.md"),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Custom output root -> nest under .pi
|
|
88
|
+
return {
|
|
89
|
+
skillsDir: path.join(outputRoot, ".pi", "skills"),
|
|
90
|
+
promptsDir: path.join(outputRoot, ".pi", "prompts"),
|
|
91
|
+
extensionsDir: path.join(outputRoot, ".pi", "extensions"),
|
|
92
|
+
mcporterConfigPath: path.join(outputRoot, ".pi", "compound-engineering", "mcporter.json"),
|
|
93
|
+
agentsPath: path.join(outputRoot, "AGENTS.md"),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function ensurePiAgentsBlock(filePath: string): Promise<void> {
|
|
98
|
+
const block = buildPiAgentsBlock()
|
|
99
|
+
|
|
100
|
+
if (!(await pathExists(filePath))) {
|
|
101
|
+
await writeText(filePath, block + "\n")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const existing = await readText(filePath)
|
|
106
|
+
const updated = upsertBlock(existing, block)
|
|
107
|
+
if (updated !== existing) {
|
|
108
|
+
await writeText(filePath, updated)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildPiAgentsBlock(): string {
|
|
113
|
+
return [PI_AGENTS_BLOCK_START, PI_AGENTS_BLOCK_BODY.trim(), PI_AGENTS_BLOCK_END].join("\n")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function upsertBlock(existing: string, block: string): string {
|
|
117
|
+
const startIndex = existing.indexOf(PI_AGENTS_BLOCK_START)
|
|
118
|
+
const endIndex = existing.indexOf(PI_AGENTS_BLOCK_END)
|
|
119
|
+
|
|
120
|
+
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
|
|
121
|
+
const before = existing.slice(0, startIndex).trimEnd()
|
|
122
|
+
const after = existing.slice(endIndex + PI_AGENTS_BLOCK_END.length).trimStart()
|
|
123
|
+
return [before, block, after].filter(Boolean).join("\n\n") + "\n"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (existing.trim().length === 0) {
|
|
127
|
+
return block + "\n"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return existing.trimEnd() + "\n\n" + block + "\n"
|
|
131
|
+
}
|