@every-env/compound-plugin 0.5.2 → 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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +20 -3
  4. package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
  5. package/docs/specs/gemini.md +122 -0
  6. package/package.json +1 -1
  7. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  8. package/plugins/compound-engineering/CHANGELOG.md +17 -0
  9. package/plugins/compound-engineering/commands/workflows/plan.md +3 -0
  10. package/plugins/compound-engineering/commands/workflows/work.md +8 -1
  11. package/src/commands/convert.ts +14 -25
  12. package/src/commands/install.ts +23 -25
  13. package/src/commands/sync.ts +44 -21
  14. package/src/converters/claude-to-gemini.ts +193 -0
  15. package/src/converters/claude-to-opencode.ts +16 -0
  16. package/src/converters/claude-to-pi.ts +205 -0
  17. package/src/sync/cursor.ts +78 -0
  18. package/src/sync/droid.ts +21 -0
  19. package/src/sync/pi.ts +88 -0
  20. package/src/targets/gemini.ts +68 -0
  21. package/src/targets/index.ts +18 -0
  22. package/src/targets/pi.ts +131 -0
  23. package/src/templates/pi/compat-extension.ts +452 -0
  24. package/src/types/gemini.ts +29 -0
  25. package/src/types/pi.ts +40 -0
  26. package/src/utils/resolve-home.ts +17 -0
  27. package/tests/cli.test.ts +76 -0
  28. package/tests/converter.test.ts +29 -0
  29. package/tests/gemini-converter.test.ts +373 -0
  30. package/tests/gemini-writer.test.ts +181 -0
  31. package/tests/pi-converter.test.ts +116 -0
  32. package/tests/pi-writer.test.ts +99 -0
  33. package/tests/sync-cursor.test.ts +92 -0
  34. package/tests/sync-droid.test.ts +57 -0
  35. 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
+ }
@@ -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
+ }