diff --git a/src/converters/claude-to-pi.ts b/src/converters/claude-to-pi.ts index e266abdd..e54069f8 100644 --- a/src/converters/claude-to-pi.ts +++ b/src/converters/claude-to-pi.ts @@ -1,4 +1,5 @@ import { formatFrontmatter } from "../utils/frontmatter" +import { normalizePiSkillName, transformPiBodyContent, uniquePiSkillName } from "../utils/pi-skills" import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" import type { PiBundle, @@ -18,12 +19,17 @@ export function convertClaudeToPi( _options: ClaudeToPiOptions, ): PiBundle { const promptNames = new Set() - const usedSkillNames = new Set(plugin.skills.map((skill) => normalizeName(skill.name))) + const usedSkillNames = new Set() const prompts = plugin.commands .filter((command) => !command.disableModelInvocation) .map((command) => convertPrompt(command, promptNames)) + const skillDirs = plugin.skills.map((skill) => ({ + name: uniquePiSkillName(normalizePiSkillName(skill.name), usedSkillNames), + sourceDir: skill.sourceDir, + })) + const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames)) const extensions = [ @@ -35,10 +41,7 @@ export function convertClaudeToPi( return { prompts, - skillDirs: plugin.skills.map((skill) => ({ - name: skill.name, - sourceDir: skill.sourceDir, - })), + skillDirs, generatedSkills, extensions, mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined, @@ -46,14 +49,13 @@ export function convertClaudeToPi( } function convertPrompt(command: ClaudeCommand, usedNames: Set) { - const name = uniqueName(normalizeName(command.name), usedNames) + const name = uniquePiSkillName(normalizePiSkillName(command.name), usedNames) const frontmatter: Record = { description: command.description, "argument-hint": command.argumentHint, } - let body = transformContentForPi(command.body) - body = appendCompatibilityNoteIfNeeded(body) + const body = transformPiBodyContent(command.body) return { name, @@ -62,7 +64,7 @@ function convertPrompt(command: ClaudeCommand, usedNames: Set) { } function convertAgent(agent: ClaudeAgent, usedNames: Set): PiGeneratedSkill { - const name = uniqueName(normalizeName(agent.name), usedNames) + const name = uniquePiSkillName(normalizePiSkillName(agent.name), usedNames) const description = sanitizeDescription( agent.description ?? `Converted from Claude agent ${agent.name}`, ) @@ -77,12 +79,12 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set): PiGeneratedSk sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`) } - const body = [ + const body = transformPiBodyContent([ ...sections, agent.body.trim().length > 0 ? agent.body.trim() : `Instructions converted from the ${agent.name} agent.`, - ].join("\n\n") + ].join("\n\n")) return { name, @@ -90,61 +92,6 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set): PiGeneratedSk } } -function transformContentForPi(body: string): string { - let result = body - - // Task repo-research-analyst(feature_description) - // -> Run subagent with agent="repo-research-analyst" and task="feature_description" - const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm - result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { - const skillName = normalizeName(agentName) - const trimmedArgs = args.trim().replace(/\s+/g, " ") - return `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".` - }) - - // Claude-specific tool references - result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question") - result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:file-todos)") - result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:file-todos)") - - // /command-name or /workflows:command-name -> /workflows-command-name - const slashCommandPattern = /(? { - if (commandName.includes("/")) return match - if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) { - return match - } - - if (commandName.startsWith("skill:")) { - const skillName = commandName.slice("skill:".length) - return `/skill:${normalizeName(skillName)}` - } - - const withoutPrefix = commandName.startsWith("prompts:") - ? commandName.slice("prompts:".length) - : commandName - - return `/${normalizeName(withoutPrefix)}` - }) - - return result -} - -function appendCompatibilityNoteIfNeeded(body: string): string { - if (!/\bmcp\b/i.test(body)) return body - - const note = [ - "", - "## Pi + MCPorter note", - "For MCP access in Pi, use MCPorter via the generated tools:", - "- `mcporter_list` to inspect available MCP tools", - "- `mcporter_call` to invoke a tool", - "", - ].join("\n") - - return body + note -} - function convertMcpToMcporter(servers: Record): PiMcporterConfig { const mcpServers: Record = {} @@ -170,19 +117,6 @@ function convertMcpToMcporter(servers: Record): PiMcpor return { mcpServers } } -function normalizeName(value: string): string { - const trimmed = value.trim() - if (!trimmed) return "item" - const normalized = trimmed - .toLowerCase() - .replace(/[\\/]+/g, "-") - .replace(/[:\s]+/g, "-") - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, "") - return normalized || "item" -} - function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGTH): string { const normalized = value.replace(/\s+/g, " ").trim() if (normalized.length <= maxLength) return normalized @@ -190,16 +124,3 @@ function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGT return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis } -function uniqueName(base: string, used: Set): string { - if (!used.has(base)) { - used.add(base) - return base - } - let index = 2 - while (used.has(`${base}-${index}`)) { - index += 1 - } - const name = `${base}-${index}` - used.add(name) - return name -} diff --git a/src/sync/pi-skills.ts b/src/sync/pi-skills.ts new file mode 100644 index 00000000..1659038d --- /dev/null +++ b/src/sync/pi-skills.ts @@ -0,0 +1,32 @@ +import path from "path" +import type { ClaudeSkill } from "../types/claude" +import { ensureDir } from "../utils/files" +import { copySkillDirForPi, normalizePiSkillName, skillFileMatchesPiTarget, uniquePiSkillName } from "../utils/pi-skills" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +export async function syncPiSkills( + skills: ClaudeSkill[], + skillsDir: string, +): Promise { + await ensureDir(skillsDir) + + const usedNames = new Set() + + for (const skill of skills) { + if (!isValidSkillName(skill.name)) { + console.warn(`Skipping skill with unsafe name: ${skill.name}`) + continue + } + + const targetName = uniquePiSkillName(normalizePiSkillName(skill.name), usedNames) + const target = path.join(skillsDir, targetName) + const alreadyPiCompatible = await skillFileMatchesPiTarget(skill.skillPath, targetName) + + if (skill.name === targetName && alreadyPiCompatible) { + await forceSymlink(skill.sourceDir, target) + continue + } + + await copySkillDirForPi(skill.sourceDir, target, targetName) + } +} diff --git a/src/sync/pi.ts b/src/sync/pi.ts index 9bd00766..7cee7f9f 100644 --- a/src/sync/pi.ts +++ b/src/sync/pi.ts @@ -4,7 +4,7 @@ import type { ClaudeMcpServer } from "../types/claude" import { ensureDir } from "../utils/files" import { syncPiCommands } from "./commands" import { mergeJsonConfigAtKey } from "./json-config" -import { syncSkills } from "./skills" +import { syncPiSkills } from "./pi-skills" type McporterServer = { baseUrl?: string @@ -24,7 +24,7 @@ export async function syncToPi( ): Promise { const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json") - await syncSkills(config.skills, path.join(outputRoot, "skills")) + await syncPiSkills(config.skills, path.join(outputRoot, "skills")) await syncPiCommands(config, outputRoot) if (Object.keys(config.mcpServers).length > 0) { diff --git a/src/targets/pi.ts b/src/targets/pi.ts index 93ba286d..2eb48a5b 100644 --- a/src/targets/pi.ts +++ b/src/targets/pi.ts @@ -1,13 +1,13 @@ import path from "path" import { backupFile, - copyDir, ensureDir, pathExists, readText, writeJson, writeText, } from "../utils/files" +import { copySkillDirForPi } from "../utils/pi-skills" import type { PiBundle } from "../types/pi" const PI_AGENTS_BLOCK_START = "" @@ -18,8 +18,8 @@ const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility) This block is managed by compound-plugin. Compatibility notes: -- Claude Task(agent, args) maps to the subagent extension tool -- For parallel agent runs, batch multiple subagent calls with multi_tool_use.parallel +- Claude Task(agent, args) maps to the ce_subagent extension tool +- Use ce_subagent for Compound Engineering workflows even when another extension also provides a generic subagent tool - AskUserQuestion maps to the ask_user_question extension tool - MCP access uses MCPorter via mcporter_list and mcporter_call extension tools - MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global) @@ -37,7 +37,7 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi } for (const skill of bundle.skillDirs) { - await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name)) + await copySkillDirForPi(skill.sourceDir, path.join(paths.skillsDir, skill.name), skill.name) } for (const skill of bundle.generatedSkills) { diff --git a/src/templates/pi/compat-extension.ts b/src/templates/pi/compat-extension.ts index 8be4176f..3514dddb 100644 --- a/src/templates/pi/compat-extension.ts +++ b/src/templates/pi/compat-extension.ts @@ -245,9 +245,9 @@ export default function (pi: ExtensionAPI) { }) pi.registerTool({ - name: "subagent", - label: "Subagent", - description: "Run one or more skill-based subagent tasks. Supports single, parallel, and chained execution.", + name: "ce_subagent", + label: "Compound Engineering Subagent", + description: "Run one or more Compound Engineering skill-based subagent tasks. Supports single, parallel, and chained execution.", parameters: Type.Object({ agent: Type.Optional(Type.String({ description: "Single subagent name" })), task: Type.Optional(Type.String({ description: "Single subagent task" })), diff --git a/src/utils/pi-skills.ts b/src/utils/pi-skills.ts new file mode 100644 index 00000000..2919e9d9 --- /dev/null +++ b/src/utils/pi-skills.ts @@ -0,0 +1,155 @@ +import { promises as fs } from "fs" +import path from "path" +import { copyDir, pathExists, readText, writeText } from "./files" +import { formatFrontmatter, parseFrontmatter } from "./frontmatter" + +export const PI_CE_SUBAGENT_TOOL = "ce_subagent" + +export function normalizePiSkillName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + + const normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:_\s]+/g, "-") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + + return normalized || "item" +} + +export function isValidPiSkillName(value: string): boolean { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) +} + +export function uniquePiSkillName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + + const name = `${base}-${index}` + used.add(name) + return name +} + +export function transformPiBodyContent(body: string): string { + let result = body + + const taskPattern = /^(\s*(?:(?:[-*])\s+|\d+\.\s+)?)Task\s+([a-z][a-z0-9:_-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + const normalizedAgent = normalizePiTaskAgentName(agentName) + const trimmedArgs = args.trim().replace(/\s+/g, " ") + return `${prefix}Run ${PI_CE_SUBAGENT_TOOL} with agent="${normalizedAgent}" and task="${trimmedArgs}".` + }) + + result = result.replace(/\bRun subagent with\b/g, `Run ${PI_CE_SUBAGENT_TOOL} with`) + result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question") + result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:file-todos)") + result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:file-todos)") + + const slashCommandPattern = /(? { + if (commandName.includes("/")) return match + if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) { + return match + } + + if (commandName.startsWith("skill:")) { + const skillName = commandName.slice("skill:".length) + return `/skill:${normalizePiSkillName(skillName)}` + } + + const withoutPrefix = commandName.startsWith("prompts:") + ? commandName.slice("prompts:".length) + : commandName + + return `/${normalizePiSkillName(withoutPrefix)}` + }) + + return appendCompatibilityNoteIfNeeded(result) +} + +export async function skillFileMatchesPiTarget(skillPath: string, targetName: string): Promise { + if (!(await pathExists(skillPath))) { + return false + } + + const raw = await readText(skillPath) + const parsed = parseFrontmatter(raw) + if (Object.keys(parsed.data).length === 0 && parsed.body === raw) { + return false + } + + if (parsed.data.name !== targetName) { + return false + } + + return transformPiBodyContent(parsed.body) === parsed.body +} + +export async function copySkillDirForPi( + sourceDir: string, + targetDir: string, + targetName: string, +): Promise { + if (await pathExists(targetDir)) { + const stats = await fs.lstat(targetDir) + if (stats.isSymbolicLink()) { + await fs.unlink(targetDir) + } else { + await fs.rm(targetDir, { recursive: true, force: true }) + } + } + + await copyDir(sourceDir, targetDir) + await rewriteSkillFileForPi(path.join(targetDir, "SKILL.md"), targetName) +} + +export async function rewriteSkillFileForPi(skillPath: string, targetName: string): Promise { + if (!(await pathExists(skillPath))) { + return + } + + const raw = await readText(skillPath) + const parsed = parseFrontmatter(raw) + if (Object.keys(parsed.data).length === 0 && parsed.body === raw) { + return + } + + const updated = formatFrontmatter( + { ...parsed.data, name: targetName }, + transformPiBodyContent(parsed.body), + ) + + if (updated !== raw) { + await writeText(skillPath, updated) + } +} + +function normalizePiTaskAgentName(value: string): string { + const leafName = value.split(":").filter(Boolean).pop() ?? value + return normalizePiSkillName(leafName) +} + +function appendCompatibilityNoteIfNeeded(body: string): string { + if (!/\bmcp\b/i.test(body)) return body + + const note = [ + "", + "## Pi + MCPorter note", + "For MCP access in Pi, use MCPorter via the generated tools:", + "- `mcporter_list` to inspect available MCP tools", + "- `mcporter_call` to invoke a tool", + "", + ].join("\n") + + return body + note +} diff --git a/tests/pi-converter.test.ts b/tests/pi-converter.test.ts index d7edf958..fdc9f82a 100644 --- a/tests/pi-converter.test.ts +++ b/tests/pi-converter.test.ts @@ -18,7 +18,7 @@ describe("convertClaudeToPi", () => { // Prompts are normalized command names expect(bundle.prompts.some((prompt) => prompt.name === "workflows-review")).toBe(true) - expect(bundle.prompts.some((prompt) => prompt.name === "plan_review")).toBe(true) + expect(bundle.prompts.some((prompt) => prompt.name === "plan-review")).toBe(true) // Commands with disable-model-invocation are excluded expect(bundle.prompts.some((prompt) => prompt.name === "deploy-docs")).toBe(false) @@ -32,10 +32,10 @@ describe("convertClaudeToPi", () => { expect(bundle.skillDirs.some((skill) => skill.name === "skill-one")).toBe(true) expect(bundle.generatedSkills.some((skill) => skill.name === "repo-research-analyst")).toBe(true) - // Pi compatibility extension is included (with subagent + MCPorter tools) + // Pi compatibility extension is included (with ce_subagent + MCPorter tools) const compatExtension = bundle.extensions.find((extension) => extension.name === "compound-engineering-compat.ts") expect(compatExtension).toBeDefined() - expect(compatExtension!.content).toContain('name: "subagent"') + expect(compatExtension!.content).toContain('name: "ce_subagent"') expect(compatExtension!.content).toContain('name: "mcporter_call"') // Claude MCP config is translated to MCPorter config @@ -54,8 +54,8 @@ describe("convertClaudeToPi", () => { description: "Plan workflow", body: [ "Run these in order:", - "- Task repo-research-analyst(feature_description)", - "- Task learnings-researcher(feature_description)", + "- Task compound-engineering:research:repo-research-analyst(feature_description)", + "- Task compound-engineering:research:learnings-researcher(feature_description)", "Use AskUserQuestion tool for follow-up.", "Then use /workflows:work and /prompts:deepen-plan.", "Track progress with TodoWrite and TodoRead.", @@ -77,14 +77,53 @@ describe("convertClaudeToPi", () => { expect(bundle.prompts).toHaveLength(1) const parsedPrompt = parseFrontmatter(bundle.prompts[0].content) - expect(parsedPrompt.body).toContain("Run subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".") - expect(parsedPrompt.body).toContain("Run subagent with agent=\"learnings-researcher\" and task=\"feature_description\".") + expect(parsedPrompt.body).toContain("Run ce_subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".") + expect(parsedPrompt.body).toContain("Run ce_subagent with agent=\"learnings-researcher\" and task=\"feature_description\".") expect(parsedPrompt.body).toContain("ask_user_question") expect(parsedPrompt.body).toContain("/workflows-work") expect(parsedPrompt.body).toContain("/deepen-plan") expect(parsedPrompt.body).toContain("file-based todos (todos/ + /skill:file-todos)") }) + test("normalizes copied skill names to Pi-safe names and avoids collisions", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "ce:plan", + description: "Plan helper", + body: "Agent body", + sourcePath: "/tmp/plugin/agents/ce:plan.md", + }, + ], + commands: [], + skills: [ + { + name: "ce:plan", + sourceDir: "/tmp/plugin/skills/ce:plan", + skillPath: "/tmp/plugin/skills/ce:plan/SKILL.md", + }, + { + name: "generate_command", + sourceDir: "/tmp/plugin/skills/generate_command", + skillPath: "/tmp/plugin/skills/generate_command/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: undefined, + } + + const bundle = convertClaudeToPi(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.skillDirs.map((skill) => skill.name)).toEqual(["ce-plan", "generate-command"]) + expect(bundle.generatedSkills[0]?.name).toBe("ce-plan-2") + }) + test("appends MCPorter compatibility note when command references MCP", () => { const plugin: ClaudePlugin = { root: "/tmp/plugin", diff --git a/tests/pi-writer.test.ts b/tests/pi-writer.test.ts index 5af7ea6d..090198dd 100644 --- a/tests/pi-writer.test.ts +++ b/tests/pi-writer.test.ts @@ -67,6 +67,46 @@ describe("writePiBundle", () => { expect(await exists(path.join(outputRoot, ".pi"))).toBe(false) }) + test("rewrites copied skill frontmatter names to match Pi-safe directory names", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-skill-frontmatter-")) + const outputRoot = path.join(tempRoot, ".pi") + const sourceSkillDir = path.join(tempRoot, "source-skill") + + await fs.mkdir(sourceSkillDir, { recursive: true }) + await fs.writeFile( + path.join(sourceSkillDir, "SKILL.md"), + [ + "---", + "name: generate_command", + "description: Generate a command", + "---", + "", + "# Generate command", + "", + "1. Task compound-engineering:workflow:pr-comment-resolver(comment1)", + ].join("\n"), + ) + + const bundle: PiBundle = { + prompts: [], + skillDirs: [ + { + name: "generate-command", + sourceDir: sourceSkillDir, + }, + ], + generatedSkills: [], + extensions: [], + } + + await writePiBundle(outputRoot, bundle) + + const copiedSkill = await fs.readFile(path.join(outputRoot, "skills", "generate-command", "SKILL.md"), "utf8") + expect(copiedSkill).toContain("name: generate-command") + expect(copiedSkill).not.toContain("name: generate_command") + expect(copiedSkill).toContain("Run ce_subagent with agent=\"pr-comment-resolver\" and task=\"comment1\".") + }) + test("backs up existing mcporter config before overwriting", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-backup-")) const outputRoot = path.join(tempRoot, ".pi") diff --git a/tests/sync-pi.test.ts b/tests/sync-pi.test.ts index 6459e65a..d8d18901 100644 --- a/tests/sync-pi.test.ts +++ b/tests/sync-pi.test.ts @@ -39,6 +39,132 @@ describe("syncToPi", () => { expect(mcporterConfig.mcpServers.local?.command).toBe("echo") }) + test("materializes invalid skill names into Pi-safe directories", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-invalid-")) + const sourceSkillDir = path.join(tempRoot, "claude-skill") + await fs.mkdir(sourceSkillDir, { recursive: true }) + await fs.writeFile( + path.join(sourceSkillDir, "SKILL.md"), + [ + "---", + "name: ce:plan", + "description: Plan workflow", + "---", + "", + "# Plan", + "", + "- Task compound-engineering:research:repo-research-analyst(feature_description)", + ].join("\n"), + ) + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "ce:plan", + sourceDir: sourceSkillDir, + skillPath: path.join(sourceSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToPi(config, tempRoot) + + const materializedSkillPath = path.join(tempRoot, "skills", "ce-plan") + const skillStat = await fs.lstat(materializedSkillPath) + expect(skillStat.isSymbolicLink()).toBe(false) + + const copiedSkill = await fs.readFile(path.join(materializedSkillPath, "SKILL.md"), "utf8") + expect(copiedSkill).toContain("name: ce-plan") + expect(copiedSkill).not.toContain("name: ce:plan") + expect(copiedSkill).toContain("Run ce_subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".") + }) + + test("materializes valid Pi-named skills when body needs Pi-specific rewrites", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-transform-")) + const sourceSkillDir = path.join(tempRoot, "claude-skill-valid") + await fs.mkdir(sourceSkillDir, { recursive: true }) + await fs.writeFile( + path.join(sourceSkillDir, "SKILL.md"), + [ + "---", + "name: ce-plan", + "description: Plan workflow", + "---", + "", + "# Plan", + "", + "- Task compound-engineering:research:repo-research-analyst(feature_description)", + ].join("\n"), + ) + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "ce-plan", + sourceDir: sourceSkillDir, + skillPath: path.join(sourceSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToPi(config, tempRoot) + + const syncedSkillPath = path.join(tempRoot, "skills", "ce-plan") + const skillStat = await fs.lstat(syncedSkillPath) + expect(skillStat.isSymbolicLink()).toBe(false) + + const copiedSkill = await fs.readFile(path.join(syncedSkillPath, "SKILL.md"), "utf8") + expect(copiedSkill).toContain("Run ce_subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".") + }) + + test("replaces an existing symlink when Pi-specific materialization is required", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-symlink-migration-")) + const existingTargetDir = path.join(tempRoot, "existing-skill") + const sourceSkillDir = path.join(tempRoot, "claude-skill-migrated") + const syncedSkillPath = path.join(tempRoot, "skills", "ce-plan") + + await fs.mkdir(existingTargetDir, { recursive: true }) + await fs.writeFile(path.join(existingTargetDir, "SKILL.md"), "---\nname: ce-plan\ndescription: Existing\n---\n\n# Existing\n") + await fs.mkdir(path.dirname(syncedSkillPath), { recursive: true }) + await fs.symlink(existingTargetDir, syncedSkillPath) + + await fs.mkdir(sourceSkillDir, { recursive: true }) + await fs.writeFile( + path.join(sourceSkillDir, "SKILL.md"), + [ + "---", + "name: ce-plan", + "description: Plan workflow", + "---", + "", + "# Plan", + "", + "- Task compound-engineering:research:repo-research-analyst(feature_description)", + ].join("\n"), + ) + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "ce-plan", + sourceDir: sourceSkillDir, + skillPath: path.join(sourceSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToPi(config, tempRoot) + + const skillStat = await fs.lstat(syncedSkillPath) + expect(skillStat.isSymbolicLink()).toBe(false) + + const copiedSkill = await fs.readFile(path.join(syncedSkillPath, "SKILL.md"), "utf8") + expect(copiedSkill).toContain("Run ce_subagent with agent=\"repo-research-analyst\" and task=\"feature_description\".") + }) + test("merges existing MCPorter config", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-merge-")) const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")