@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
@@ -0,0 +1,100 @@
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 CopilotMcpServer = {
8
+ type: string
9
+ command?: string
10
+ args?: string[]
11
+ url?: string
12
+ tools: string[]
13
+ env?: Record<string, string>
14
+ headers?: Record<string, string>
15
+ }
16
+
17
+ type CopilotMcpConfig = {
18
+ mcpServers: Record<string, CopilotMcpServer>
19
+ }
20
+
21
+ export async function syncToCopilot(
22
+ config: ClaudeHomeConfig,
23
+ outputRoot: string,
24
+ ): Promise<void> {
25
+ const skillsDir = path.join(outputRoot, "skills")
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
+ const mcpPath = path.join(outputRoot, "copilot-mcp-config.json")
39
+ const existing = await readJsonSafe(mcpPath)
40
+ const converted = convertMcpForCopilot(config.mcpServers)
41
+ const merged: CopilotMcpConfig = {
42
+ mcpServers: {
43
+ ...(existing.mcpServers ?? {}),
44
+ ...converted,
45
+ },
46
+ }
47
+ await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
48
+ }
49
+ }
50
+
51
+ async function readJsonSafe(filePath: string): Promise<Partial<CopilotMcpConfig>> {
52
+ try {
53
+ const content = await fs.readFile(filePath, "utf-8")
54
+ return JSON.parse(content) as Partial<CopilotMcpConfig>
55
+ } catch (err) {
56
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
57
+ return {}
58
+ }
59
+ throw err
60
+ }
61
+ }
62
+
63
+ function convertMcpForCopilot(
64
+ servers: Record<string, ClaudeMcpServer>,
65
+ ): Record<string, CopilotMcpServer> {
66
+ const result: Record<string, CopilotMcpServer> = {}
67
+ for (const [name, server] of Object.entries(servers)) {
68
+ const entry: CopilotMcpServer = {
69
+ type: server.command ? "local" : "sse",
70
+ tools: ["*"],
71
+ }
72
+
73
+ if (server.command) {
74
+ entry.command = server.command
75
+ if (server.args && server.args.length > 0) entry.args = server.args
76
+ } else if (server.url) {
77
+ entry.url = server.url
78
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
79
+ }
80
+
81
+ if (server.env && Object.keys(server.env).length > 0) {
82
+ entry.env = prefixEnvVars(server.env)
83
+ }
84
+
85
+ result[name] = entry
86
+ }
87
+ return result
88
+ }
89
+
90
+ function prefixEnvVars(env: Record<string, string>): Record<string, string> {
91
+ const result: Record<string, string> = {}
92
+ for (const [key, value] of Object.entries(env)) {
93
+ if (key.startsWith("COPILOT_MCP_")) {
94
+ result[key] = value
95
+ } else {
96
+ result[`COPILOT_MCP_${key}`] = value
97
+ }
98
+ }
99
+ return result
100
+ }
@@ -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,48 @@
1
+ import path from "path"
2
+ import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
+ import type { CopilotBundle } from "../types/copilot"
4
+
5
+ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise<void> {
6
+ const paths = resolveCopilotPaths(outputRoot)
7
+ await ensureDir(paths.githubDir)
8
+
9
+ if (bundle.agents.length > 0) {
10
+ const agentsDir = path.join(paths.githubDir, "agents")
11
+ for (const agent of bundle.agents) {
12
+ await writeText(path.join(agentsDir, `${agent.name}.agent.md`), agent.content + "\n")
13
+ }
14
+ }
15
+
16
+ if (bundle.generatedSkills.length > 0) {
17
+ const skillsDir = path.join(paths.githubDir, "skills")
18
+ for (const skill of bundle.generatedSkills) {
19
+ await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
20
+ }
21
+ }
22
+
23
+ if (bundle.skillDirs.length > 0) {
24
+ const skillsDir = path.join(paths.githubDir, "skills")
25
+ for (const skill of bundle.skillDirs) {
26
+ await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
27
+ }
28
+ }
29
+
30
+ if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) {
31
+ const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
32
+ const backupPath = await backupFile(mcpPath)
33
+ if (backupPath) {
34
+ console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`)
35
+ }
36
+ await writeJson(mcpPath, { mcpServers: bundle.mcpConfig })
37
+ }
38
+ }
39
+
40
+ function resolveCopilotPaths(outputRoot: string) {
41
+ const base = path.basename(outputRoot)
42
+ // If already pointing at .github, write directly into it
43
+ if (base === ".github") {
44
+ return { githubDir: outputRoot }
45
+ }
46
+ // Otherwise nest under .github
47
+ return { githubDir: path.join(outputRoot, ".github") }
48
+ }
@@ -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
+ }
@@ -2,15 +2,21 @@ import type { ClaudePlugin } from "../types/claude"
2
2
  import type { OpenCodeBundle } from "../types/opencode"
3
3
  import type { CodexBundle } from "../types/codex"
4
4
  import type { DroidBundle } from "../types/droid"
5
- import type { CursorBundle } from "../types/cursor"
5
+ import type { PiBundle } from "../types/pi"
6
+ import type { CopilotBundle } from "../types/copilot"
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
- import { convertClaudeToCursor } from "../converters/claude-to-cursor"
11
+ import { convertClaudeToPi } from "../converters/claude-to-pi"
12
+ import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
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
- import { writeCursorBundle } from "./cursor"
17
+ import { writePiBundle } from "./pi"
18
+ import { writeCopilotBundle } from "./copilot"
19
+ import { writeGeminiBundle } from "./gemini"
14
20
 
15
21
  export type TargetHandler<TBundle = unknown> = {
16
22
  name: string
@@ -38,10 +44,22 @@ export const targets: Record<string, TargetHandler> = {
38
44
  convert: convertClaudeToDroid as TargetHandler<DroidBundle>["convert"],
39
45
  write: writeDroidBundle as TargetHandler<DroidBundle>["write"],
40
46
  },
41
- cursor: {
42
- name: "cursor",
47
+ pi: {
48
+ name: "pi",
43
49
  implemented: true,
44
- convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
45
- write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
50
+ convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
51
+ write: writePiBundle as TargetHandler<PiBundle>["write"],
52
+ },
53
+ copilot: {
54
+ name: "copilot",
55
+ implemented: true,
56
+ convert: convertClaudeToCopilot as TargetHandler<CopilotBundle>["convert"],
57
+ write: writeCopilotBundle as TargetHandler<CopilotBundle>["write"],
58
+ },
59
+ gemini: {
60
+ name: "gemini",
61
+ implemented: true,
62
+ convert: convertClaudeToGemini as TargetHandler<GeminiBundle>["convert"],
63
+ write: writeGeminiBundle as TargetHandler<GeminiBundle>["write"],
46
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
+ }