From e84fef7a56f793f5ba955bd5b073e8a67d442b18 Mon Sep 17 00:00:00 2001 From: Geet Khosla Date: Thu, 12 Feb 2026 23:07:34 +0100 Subject: [PATCH] feat: add first-class pi target with mcporter/subagent compatibility --- README.md | 13 +- src/commands/convert.ts | 27 +- src/commands/install.ts | 33 +- src/commands/sync.ts | 19 +- src/converters/claude-to-pi.ts | 205 ++++++++++++ src/sync/pi.ts | 88 ++++++ src/targets/index.ts | 9 + src/targets/pi.ts | 131 ++++++++ src/templates/pi/compat-extension.ts | 452 +++++++++++++++++++++++++++ src/types/pi.ts | 40 +++ tests/cli.test.ts | 76 +++++ tests/pi-converter.test.ts | 116 +++++++ tests/pi-writer.test.ts | 99 ++++++ tests/sync-pi.test.ts | 68 ++++ 14 files changed, 1358 insertions(+), 18 deletions(-) create mode 100644 src/converters/claude-to-pi.ts create mode 100644 src/sync/pi.ts create mode 100644 src/targets/pi.ts create mode 100644 src/templates/pi/compat-extension.ts create mode 100644 src/types/pi.ts create mode 100644 tests/pi-converter.test.ts create mode 100644 tests/pi-writer.test.ts create mode 100644 tests/sync-pi.test.ts diff --git a/README.md b/README.md index 7badfd2..3a930b8 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /plugin install compound-engineering ``` -## OpenCode, Codex, Droid & Cursor (experimental) Install +## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, and Cursor. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi. ```bash # convert the compound-engineering plugin into OpenCode format @@ -28,6 +28,9 @@ bunx @every-env/compound-plugin install compound-engineering --to droid # convert to Cursor format bunx @every-env/compound-plugin install compound-engineering --to cursor + +# convert to Pi format +bunx @every-env/compound-plugin install compound-engineering --to pi ``` Local dev: @@ -40,12 +43,13 @@ OpenCode output is written to `~/.config/opencode` by default, with `opencode.js Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit). Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands. Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory. +Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. All provider targets are experimental and may change as the formats evolve. ## Sync Personal Config -Sync your personal Claude Code config (`~/.claude/`) to OpenCode or Codex: +Sync your personal Claude Code config (`~/.claude/`) to OpenCode, Codex, or Pi: ```bash # Sync skills and MCP servers to OpenCode @@ -53,6 +57,9 @@ bunx @every-env/compound-plugin sync --target opencode # Sync to Codex bunx @every-env/compound-plugin sync --target codex + +# Sync to Pi +bunx @every-env/compound-plugin sync --target pi ``` This syncs: diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 2830a98..e5a36d9 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -22,7 +22,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor)", + description: "Target format (opencode | codex | droid | cursor | pi)", }, output: { type: "string", @@ -34,6 +34,11 @@ export default defineCommand({ alias: "codex-home", description: "Write Codex output to this .codex root (ex: ~/.codex)", }, + piHome: { + type: "string", + alias: "pi-home", + description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", @@ -73,6 +78,7 @@ export default defineCommand({ const plugin = await loadClaudePlugin(String(args.source)) const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveCodexRoot(args.codexHome) + const piHome = resolvePiRoot(args.piHome) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -80,7 +86,7 @@ export default defineCommand({ permissions: permissions as PermissionMode, } - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome) + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome) const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) @@ -106,7 +112,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome) await handler.write(extraRoot, extraBundle) console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) } @@ -137,6 +143,18 @@ function resolveCodexRoot(value: unknown): string { return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") } +function resolvePiHome(value: unknown): string | null { + if (!value) return null + const raw = String(value).trim() + if (!raw) return null + const expanded = expandHome(raw) + return path.resolve(expanded) +} + +function resolvePiRoot(value: unknown): string { + return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent") +} + function expandHome(value: string): string { if (value === "~") return os.homedir() if (value.startsWith(`~${path.sep}`)) { @@ -153,8 +171,9 @@ function resolveOutputRoot(value: unknown): string { return process.cwd() } -function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string { +function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, piHome: string): string { if (targetName === "codex") return codexHome + if (targetName === "pi") return piHome if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") return path.join(outputRoot, ".cursor") return outputRoot diff --git a/src/commands/install.ts b/src/commands/install.ts index 6e86404..b1f053f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -24,7 +24,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor)", + description: "Target format (opencode | codex | droid | cursor | pi)", }, output: { type: "string", @@ -36,6 +36,11 @@ export default defineCommand({ alias: "codex-home", description: "Write Codex output to this .codex root (ex: ~/.codex)", }, + piHome: { + type: "string", + alias: "pi-home", + description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", @@ -77,6 +82,7 @@ export default defineCommand({ const plugin = await loadClaudePlugin(resolvedPlugin.path) const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveCodexRoot(args.codexHome) + const piHome = resolvePiRoot(args.piHome) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -89,7 +95,7 @@ export default defineCommand({ throw new Error(`Target ${targetName} did not return a bundle.`) } const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, hasExplicitOutput) + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -110,7 +116,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, hasExplicitOutput) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -164,6 +170,18 @@ function resolveCodexRoot(value: unknown): string { return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") } +function resolvePiHome(value: unknown): string | null { + if (!value) return null + const raw = String(value).trim() + if (!raw) return null + const expanded = expandHome(raw) + return path.resolve(expanded) +} + +function resolvePiRoot(value: unknown): string { + return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent") +} + function expandHome(value: string): string { if (value === "~") return os.homedir() if (value.startsWith(`~${path.sep}`)) { @@ -182,8 +200,15 @@ function resolveOutputRoot(value: unknown): string { return path.join(os.homedir(), ".config", "opencode") } -function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, hasExplicitOutput: boolean): string { +function resolveTargetOutputRoot( + targetName: string, + outputRoot: string, + codexHome: string, + piHome: string, + hasExplicitOutput: boolean, +): string { if (targetName === "codex") return codexHome + if (targetName === "pi") return piHome if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") { const base = hasExplicitOutput ? outputRoot : process.cwd() diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 5678b2e..aa6626b 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -4,9 +4,10 @@ import path from "path" import { loadClaudeHome } from "../parsers/claude-home" import { syncToOpenCode } from "../sync/opencode" import { syncToCodex } from "../sync/codex" +import { syncToPi } from "../sync/pi" -function isValidTarget(value: string): value is "opencode" | "codex" { - return value === "opencode" || value === "codex" +function isValidTarget(value: string): value is "opencode" | "codex" | "pi" { + return value === "opencode" || value === "codex" || value === "pi" } /** Check if any MCP servers have env vars that might contain secrets */ @@ -26,13 +27,13 @@ function hasPotentialSecrets(mcpServers: Record): boolean { export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, or Pi", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex", + description: "Target: opencode | codex | pi", }, claudeHome: { type: "string", @@ -42,7 +43,7 @@ export default defineCommand({ }, async run({ args }) { if (!isValidTarget(args.target)) { - throw new Error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`) + throw new Error(`Unknown target: ${args.target}. Use 'opencode', 'codex', or 'pi'.`) } const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude")) @@ -63,12 +64,16 @@ export default defineCommand({ const outputRoot = args.target === "opencode" ? path.join(os.homedir(), ".config", "opencode") - : path.join(os.homedir(), ".codex") + : args.target === "codex" + ? path.join(os.homedir(), ".codex") + : path.join(os.homedir(), ".pi", "agent") if (args.target === "opencode") { await syncToOpenCode(config, outputRoot) - } else { + } else if (args.target === "codex") { await syncToCodex(config, outputRoot) + } else { + await syncToPi(config, outputRoot) } console.log(`✓ Synced to ${args.target}: ${outputRoot}`) diff --git a/src/converters/claude-to-pi.ts b/src/converters/claude-to-pi.ts new file mode 100644 index 0000000..e266abd --- /dev/null +++ b/src/converters/claude-to-pi.ts @@ -0,0 +1,205 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + PiBundle, + PiGeneratedSkill, + PiMcporterConfig, + PiMcporterServer, +} from "../types/pi" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" +import { PI_COMPAT_EXTENSION_SOURCE } from "../templates/pi/compat-extension" + +export type ClaudeToPiOptions = ClaudeToOpenCodeOptions + +const PI_DESCRIPTION_MAX_LENGTH = 1024 + +export function convertClaudeToPi( + plugin: ClaudePlugin, + _options: ClaudeToPiOptions, +): PiBundle { + const promptNames = new Set() + const usedSkillNames = new Set(plugin.skills.map((skill) => normalizeName(skill.name))) + + const prompts = plugin.commands + .filter((command) => !command.disableModelInvocation) + .map((command) => convertPrompt(command, promptNames)) + + const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames)) + + const extensions = [ + { + name: "compound-engineering-compat.ts", + content: PI_COMPAT_EXTENSION_SOURCE, + }, + ] + + return { + prompts, + skillDirs: plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })), + generatedSkills, + extensions, + mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined, + } +} + +function convertPrompt(command: ClaudeCommand, usedNames: Set) { + const name = uniqueName(normalizeName(command.name), usedNames) + const frontmatter: Record = { + description: command.description, + "argument-hint": command.argumentHint, + } + + let body = transformContentForPi(command.body) + body = appendCompatibilityNoteIfNeeded(body) + + return { + name, + content: formatFrontmatter(frontmatter, body.trim()), + } +} + +function convertAgent(agent: ClaudeAgent, usedNames: Set): PiGeneratedSkill { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = sanitizeDescription( + agent.description ?? `Converted from Claude agent ${agent.name}`, + ) + + const frontmatter: Record = { + name, + description, + } + + const sections: string[] = [] + if (agent.capabilities && agent.capabilities.length > 0) { + sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`) + } + + const body = [ + ...sections, + agent.body.trim().length > 0 + ? agent.body.trim() + : `Instructions converted from the ${agent.name} agent.`, + ].join("\n\n") + + return { + name, + content: formatFrontmatter(frontmatter, body), + } +} + +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 = {} + + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + mcpServers[name] = { + command: server.command, + args: server.args, + env: server.env, + headers: server.headers, + } + continue + } + + if (server.url) { + mcpServers[name] = { + baseUrl: server.url, + headers: server.headers, + } + } + } + + 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 + const ellipsis = "..." + 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.ts b/src/sync/pi.ts new file mode 100644 index 0000000..3f6d0f6 --- /dev/null +++ b/src/sync/pi.ts @@ -0,0 +1,88 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import type { ClaudeMcpServer } from "../types/claude" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +type McporterServer = { + baseUrl?: string + command?: string + args?: string[] + env?: Record + headers?: Record +} + +type McporterConfig = { + mcpServers: Record +} + +export async function syncToPi( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + const skillsDir = path.join(outputRoot, "skills") + const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json") + + await fs.mkdir(skillsDir, { recursive: true }) + + for (const skill of config.skills) { + if (!isValidSkillName(skill.name)) { + console.warn(`Skipping skill with invalid name: ${skill.name}`) + continue + } + const target = path.join(skillsDir, skill.name) + await forceSymlink(skill.sourceDir, target) + } + + if (Object.keys(config.mcpServers).length > 0) { + await fs.mkdir(path.dirname(mcporterPath), { recursive: true }) + + const existing = await readJsonSafe(mcporterPath) + const converted = convertMcpToMcporter(config.mcpServers) + const merged: McporterConfig = { + mcpServers: { + ...(existing.mcpServers ?? {}), + ...converted.mcpServers, + }, + } + + await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) + } +} + +async function readJsonSafe(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Partial + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err + } +} + +function convertMcpToMcporter(servers: Record): McporterConfig { + const mcpServers: Record = {} + + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + mcpServers[name] = { + command: server.command, + args: server.args, + env: server.env, + headers: server.headers, + } + continue + } + + if (server.url) { + mcpServers[name] = { + baseUrl: server.url, + headers: server.headers, + } + } + } + + return { mcpServers } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 21372b9..3e60631 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -3,14 +3,17 @@ import type { OpenCodeBundle } from "../types/opencode" import type { CodexBundle } from "../types/codex" import type { DroidBundle } from "../types/droid" import type { CursorBundle } from "../types/cursor" +import type { PiBundle } from "../types/pi" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" import { convertClaudeToCursor } from "../converters/claude-to-cursor" +import { convertClaudeToPi } from "../converters/claude-to-pi" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" import { writeCursorBundle } from "./cursor" +import { writePiBundle } from "./pi" export type TargetHandler = { name: string @@ -44,4 +47,10 @@ export const targets: Record = { convert: convertClaudeToCursor as TargetHandler["convert"], write: writeCursorBundle as TargetHandler["write"], }, + pi: { + name: "pi", + implemented: true, + convert: convertClaudeToPi as TargetHandler["convert"], + write: writePiBundle as TargetHandler["write"], + }, } diff --git a/src/targets/pi.ts b/src/targets/pi.ts new file mode 100644 index 0000000..93ba286 --- /dev/null +++ b/src/targets/pi.ts @@ -0,0 +1,131 @@ +import path from "path" +import { + backupFile, + copyDir, + ensureDir, + pathExists, + readText, + writeJson, + writeText, +} from "../utils/files" +import type { PiBundle } from "../types/pi" + +const PI_AGENTS_BLOCK_START = "" +const PI_AGENTS_BLOCK_END = "" + +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 +- 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) +` + +export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promise { + const paths = resolvePiPaths(outputRoot) + + await ensureDir(paths.skillsDir) + await ensureDir(paths.promptsDir) + await ensureDir(paths.extensionsDir) + + for (const prompt of bundle.prompts) { + await writeText(path.join(paths.promptsDir, `${prompt.name}.md`), prompt.content + "\n") + } + + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name)) + } + + for (const skill of bundle.generatedSkills) { + await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n") + } + + for (const extension of bundle.extensions) { + await writeText(path.join(paths.extensionsDir, extension.name), extension.content + "\n") + } + + if (bundle.mcporterConfig) { + const backupPath = await backupFile(paths.mcporterConfigPath) + if (backupPath) { + console.log(`Backed up existing MCPorter config to ${backupPath}`) + } + await writeJson(paths.mcporterConfigPath, bundle.mcporterConfig) + } + + await ensurePiAgentsBlock(paths.agentsPath) +} + +function resolvePiPaths(outputRoot: string) { + const base = path.basename(outputRoot) + + // Global install root: ~/.pi/agent + if (base === "agent") { + return { + skillsDir: path.join(outputRoot, "skills"), + promptsDir: path.join(outputRoot, "prompts"), + extensionsDir: path.join(outputRoot, "extensions"), + mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"), + agentsPath: path.join(outputRoot, "AGENTS.md"), + } + } + + // Project local .pi directory + if (base === ".pi") { + return { + skillsDir: path.join(outputRoot, "skills"), + promptsDir: path.join(outputRoot, "prompts"), + extensionsDir: path.join(outputRoot, "extensions"), + mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"), + agentsPath: path.join(outputRoot, "AGENTS.md"), + } + } + + // Custom output root -> nest under .pi + return { + skillsDir: path.join(outputRoot, ".pi", "skills"), + promptsDir: path.join(outputRoot, ".pi", "prompts"), + extensionsDir: path.join(outputRoot, ".pi", "extensions"), + mcporterConfigPath: path.join(outputRoot, ".pi", "compound-engineering", "mcporter.json"), + agentsPath: path.join(outputRoot, "AGENTS.md"), + } +} + +async function ensurePiAgentsBlock(filePath: string): Promise { + const block = buildPiAgentsBlock() + + if (!(await pathExists(filePath))) { + await writeText(filePath, block + "\n") + return + } + + const existing = await readText(filePath) + const updated = upsertBlock(existing, block) + if (updated !== existing) { + await writeText(filePath, updated) + } +} + +function buildPiAgentsBlock(): string { + return [PI_AGENTS_BLOCK_START, PI_AGENTS_BLOCK_BODY.trim(), PI_AGENTS_BLOCK_END].join("\n") +} + +function upsertBlock(existing: string, block: string): string { + const startIndex = existing.indexOf(PI_AGENTS_BLOCK_START) + const endIndex = existing.indexOf(PI_AGENTS_BLOCK_END) + + if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { + const before = existing.slice(0, startIndex).trimEnd() + const after = existing.slice(endIndex + PI_AGENTS_BLOCK_END.length).trimStart() + return [before, block, after].filter(Boolean).join("\n\n") + "\n" + } + + if (existing.trim().length === 0) { + return block + "\n" + } + + return existing.trimEnd() + "\n\n" + block + "\n" +} diff --git a/src/templates/pi/compat-extension.ts b/src/templates/pi/compat-extension.ts new file mode 100644 index 0000000..8be4176 --- /dev/null +++ b/src/templates/pi/compat-extension.ts @@ -0,0 +1,452 @@ +export const PI_COMPAT_EXTENSION_SOURCE = `import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" +import { Type } from "@sinclair/typebox" + +const MAX_BYTES = 50 * 1024 +const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000 +const MAX_PARALLEL_SUBAGENTS = 8 + +type SubagentTask = { + agent: string + task: string + cwd?: string +} + +type SubagentResult = { + agent: string + task: string + cwd: string + exitCode: number + output: string + stderr: string +} + +function truncate(value: string): string { + const input = value ?? "" + if (Buffer.byteLength(input, "utf8") <= MAX_BYTES) return input + const head = input.slice(0, MAX_BYTES) + return head + "\\n\\n[Output truncated to 50KB]" +} + +function shellEscape(value: string): string { + return "'" + value.replace(/'/g, "'\\"'\\"'") + "'" +} + +function normalizeName(value: string): string { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function resolveBundledMcporterConfigPath(): string | undefined { + try { + const extensionDir = path.dirname(fileURLToPath(import.meta.url)) + const candidates = [ + path.join(extensionDir, "..", "pi-resources", "compound-engineering", "mcporter.json"), + path.join(extensionDir, "..", "compound-engineering", "mcporter.json"), + ] + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate + } + } catch { + // noop: bundled path is best-effort fallback + } + + return undefined +} + +function resolveMcporterConfigPath(cwd: string, explicit?: string): string | undefined { + if (explicit && explicit.trim()) { + return path.resolve(explicit) + } + + const projectPath = path.join(cwd, ".pi", "compound-engineering", "mcporter.json") + if (fs.existsSync(projectPath)) return projectPath + + const globalPath = path.join(os.homedir(), ".pi", "agent", "compound-engineering", "mcporter.json") + if (fs.existsSync(globalPath)) return globalPath + + return resolveBundledMcporterConfigPath() +} + +function resolveTaskCwd(baseCwd: string, taskCwd?: string): string { + if (!taskCwd || !taskCwd.trim()) return baseCwd + const expanded = taskCwd === "~" + ? os.homedir() + : taskCwd.startsWith("~" + path.sep) + ? path.join(os.homedir(), taskCwd.slice(2)) + : taskCwd + return path.resolve(baseCwd, expanded) +} + +async function runSingleSubagent( + pi: ExtensionAPI, + baseCwd: string, + task: SubagentTask, + signal?: AbortSignal, + timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS, +): Promise { + const agent = normalizeName(task.agent) + if (!agent) { + throw new Error("Subagent task is missing a valid agent name") + } + + const taskText = String(task.task ?? "").trim() + if (!taskText) { + throw new Error("Subagent task for " + agent + " is empty") + } + + const cwd = resolveTaskCwd(baseCwd, task.cwd) + const prompt = "/skill:" + agent + " " + taskText + const script = "cd " + shellEscape(cwd) + " && pi --no-session -p " + shellEscape(prompt) + const result = await pi.exec("bash", ["-lc", script], { signal, timeout: timeoutMs }) + + return { + agent, + task: taskText, + cwd, + exitCode: result.code, + output: truncate(result.stdout || ""), + stderr: truncate(result.stderr || ""), + } +} + +async function runParallelSubagents( + pi: ExtensionAPI, + baseCwd: string, + tasks: SubagentTask[], + signal?: AbortSignal, + timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS, + maxConcurrency = 4, + onProgress?: (completed: number, total: number) => void, +): Promise { + const safeConcurrency = Math.max(1, Math.min(maxConcurrency, MAX_PARALLEL_SUBAGENTS, tasks.length)) + const results: SubagentResult[] = new Array(tasks.length) + + let nextIndex = 0 + let completed = 0 + + const workers = Array.from({ length: safeConcurrency }, async () => { + while (true) { + const current = nextIndex + nextIndex += 1 + if (current >= tasks.length) return + + results[current] = await runSingleSubagent(pi, baseCwd, tasks[current], signal, timeoutMs) + completed += 1 + onProgress?.(completed, tasks.length) + } + }) + + await Promise.all(workers) + return results +} + +function formatSubagentSummary(results: SubagentResult[]): string { + if (results.length === 0) return "No subagent work was executed." + + const success = results.filter((result) => result.exitCode === 0).length + const failed = results.length - success + const header = failed === 0 + ? "Subagent run completed: " + success + "/" + results.length + " succeeded." + : "Subagent run completed: " + success + "/" + results.length + " succeeded, " + failed + " failed." + + const lines = results.map((result) => { + const status = result.exitCode === 0 ? "ok" : "error" + const body = result.output || result.stderr || "(no output)" + const preview = body.split("\\n").slice(0, 6).join("\\n") + return "\\n[" + status + "] " + result.agent + "\\n" + preview + }) + + return header + lines.join("\\n") +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "ask_user_question", + label: "Ask User Question", + description: "Ask the user a question with optional choices.", + parameters: Type.Object({ + question: Type.String({ description: "Question shown to the user" }), + options: Type.Optional(Type.Array(Type.String(), { description: "Selectable options" })), + allowCustom: Type.Optional(Type.Boolean({ default: true })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return { + isError: true, + content: [{ type: "text", text: "UI is unavailable in this mode." }], + details: {}, + } + } + + const options = params.options ?? [] + const allowCustom = params.allowCustom ?? true + + if (options.length === 0) { + const answer = await ctx.ui.input(params.question) + if (!answer) { + return { + content: [{ type: "text", text: "User cancelled." }], + details: { answer: null }, + } + } + + return { + content: [{ type: "text", text: "User answered: " + answer }], + details: { answer, mode: "input" }, + } + } + + const customLabel = "Other (type custom answer)" + const selectable = allowCustom ? [...options, customLabel] : options + const selected = await ctx.ui.select(params.question, selectable) + + if (!selected) { + return { + content: [{ type: "text", text: "User cancelled." }], + details: { answer: null }, + } + } + + if (selected === customLabel) { + const custom = await ctx.ui.input("Your answer") + if (!custom) { + return { + content: [{ type: "text", text: "User cancelled." }], + details: { answer: null }, + } + } + + return { + content: [{ type: "text", text: "User answered: " + custom }], + details: { answer: custom, mode: "custom" }, + } + } + + return { + content: [{ type: "text", text: "User selected: " + selected }], + details: { answer: selected, mode: "select" }, + } + }, + }) + + const subagentTaskSchema = Type.Object({ + agent: Type.String({ description: "Skill/agent name to invoke" }), + task: Type.String({ description: "Task instructions for that skill" }), + cwd: Type.Optional(Type.String({ description: "Optional working directory for this task" })), + }) + + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: "Run one or more 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" })), + cwd: Type.Optional(Type.String({ description: "Working directory for single mode" })), + tasks: Type.Optional(Type.Array(subagentTaskSchema, { description: "Parallel subagent tasks" })), + chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Sequential tasks; supports {previous} placeholder" })), + maxConcurrency: Type.Optional(Type.Number({ default: 4 })), + timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_SUBAGENT_TIMEOUT_MS })), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const hasSingle = Boolean(params.agent && params.task) + const hasTasks = Boolean(params.tasks && params.tasks.length > 0) + const hasChain = Boolean(params.chain && params.chain.length > 0) + const modeCount = Number(hasSingle) + Number(hasTasks) + Number(hasChain) + + if (modeCount !== 1) { + return { + isError: true, + content: [{ type: "text", text: "Provide exactly one mode: single (agent+task), tasks, or chain." }], + details: {}, + } + } + + const timeoutMs = Number(params.timeoutMs || DEFAULT_SUBAGENT_TIMEOUT_MS) + + try { + if (hasSingle) { + const result = await runSingleSubagent( + pi, + ctx.cwd, + { agent: params.agent!, task: params.task!, cwd: params.cwd }, + signal, + timeoutMs, + ) + + const body = formatSubagentSummary([result]) + return { + isError: result.exitCode !== 0, + content: [{ type: "text", text: body }], + details: { mode: "single", results: [result] }, + } + } + + if (hasTasks) { + const tasks = params.tasks as SubagentTask[] + const maxConcurrency = Number(params.maxConcurrency || 4) + + const results = await runParallelSubagents( + pi, + ctx.cwd, + tasks, + signal, + timeoutMs, + maxConcurrency, + (completed, total) => { + onUpdate?.({ + content: [{ type: "text", text: "Subagent progress: " + completed + "/" + total }], + details: { mode: "parallel", completed, total }, + }) + }, + ) + + const body = formatSubagentSummary(results) + const hasFailure = results.some((result) => result.exitCode !== 0) + + return { + isError: hasFailure, + content: [{ type: "text", text: body }], + details: { mode: "parallel", results }, + } + } + + const chain = params.chain as SubagentTask[] + const results: SubagentResult[] = [] + let previous = "" + + for (const step of chain) { + const resolvedTask = step.task.replace(/\\{previous\\}/g, previous) + const result = await runSingleSubagent( + pi, + ctx.cwd, + { agent: step.agent, task: resolvedTask, cwd: step.cwd }, + signal, + timeoutMs, + ) + results.push(result) + previous = result.output || result.stderr + + onUpdate?.({ + content: [{ type: "text", text: "Subagent chain progress: " + results.length + "/" + chain.length }], + details: { mode: "chain", completed: results.length, total: chain.length }, + }) + + if (result.exitCode !== 0) break + } + + const body = formatSubagentSummary(results) + const hasFailure = results.some((result) => result.exitCode !== 0) + + return { + isError: hasFailure, + content: [{ type: "text", text: body }], + details: { mode: "chain", results }, + } + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], + details: {}, + } + } + }, + }) + + pi.registerTool({ + name: "mcporter_list", + label: "MCPorter List", + description: "List tools on an MCP server through MCPorter.", + parameters: Type.Object({ + server: Type.String({ description: "Configured MCP server name" }), + allParameters: Type.Optional(Type.Boolean({ default: false })), + json: Type.Optional(Type.Boolean({ default: true })), + configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const args = ["list", params.server] + if (params.allParameters) args.push("--all-parameters") + if (params.json ?? true) args.push("--json") + + const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath) + if (configPath) { + args.push("--config", configPath) + } + + const result = await pi.exec("mcporter", args, { signal }) + const output = truncate(result.stdout || result.stderr || "") + + return { + isError: result.code !== 0, + content: [{ type: "text", text: output || "(no output)" }], + details: { + exitCode: result.code, + command: "mcporter " + args.join(" "), + configPath, + }, + } + }, + }) + + pi.registerTool({ + name: "mcporter_call", + label: "MCPorter Call", + description: "Call a specific MCP tool through MCPorter.", + parameters: Type.Object({ + call: Type.Optional(Type.String({ description: "Function-style call, e.g. linear.list_issues(limit: 5)" })), + server: Type.Optional(Type.String({ description: "Server name (if call is omitted)" })), + tool: Type.Optional(Type.String({ description: "Tool name (if call is omitted)" })), + args: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "JSON arguments object" })), + configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const args = ["call"] + + if (params.call && params.call.trim()) { + args.push(params.call.trim()) + } else { + if (!params.server || !params.tool) { + return { + isError: true, + content: [{ type: "text", text: "Provide either call, or server + tool." }], + details: {}, + } + } + args.push(params.server + "." + params.tool) + if (params.args) { + args.push("--args", JSON.stringify(params.args)) + } + } + + args.push("--output", "json") + + const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath) + if (configPath) { + args.push("--config", configPath) + } + + const result = await pi.exec("mcporter", args, { signal }) + const output = truncate(result.stdout || result.stderr || "") + + return { + isError: result.code !== 0, + content: [{ type: "text", text: output || "(no output)" }], + details: { + exitCode: result.code, + command: "mcporter " + args.join(" "), + configPath, + }, + } + }, + }) +} +` diff --git a/src/types/pi.ts b/src/types/pi.ts new file mode 100644 index 0000000..96df784 --- /dev/null +++ b/src/types/pi.ts @@ -0,0 +1,40 @@ +export type PiPrompt = { + name: string + content: string +} + +export type PiSkillDir = { + name: string + sourceDir: string +} + +export type PiGeneratedSkill = { + name: string + content: string +} + +export type PiExtensionFile = { + name: string + content: string +} + +export type PiMcporterServer = { + description?: string + baseUrl?: string + command?: string + args?: string[] + env?: Record + headers?: Record +} + +export type PiMcporterConfig = { + mcpServers: Record +} + +export type PiBundle = { + prompts: PiPrompt[] + skillDirs: PiSkillDir[] + generatedSkills: PiGeneratedSkill[] + extensions: PiExtensionFile[] + mcporterConfig?: PiMcporterConfig +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2a1ce33..49c20a6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -350,4 +350,80 @@ describe("CLI", () => { expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true) }) + + test("convert supports --pi-home for pi output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-pi-home-")) + const piRoot = path.join(tempRoot, ".pi") + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "convert", + fixtureRoot, + "--to", + "pi", + "--pi-home", + piRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Converted compound-engineering") + expect(stdout).toContain(piRoot) + expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true) + expect(await exists(path.join(piRoot, "skills", "repo-research-analyst", "SKILL.md"))).toBe(true) + expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true) + expect(await exists(path.join(piRoot, "compound-engineering", "mcporter.json"))).toBe(true) + }) + + test("install supports --also with pi output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-also-pi-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + const piRoot = path.join(tempRoot, ".pi") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "install", + fixtureRoot, + "--to", + "opencode", + "--also", + "pi", + "--pi-home", + piRoot, + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(stdout).toContain(piRoot) + expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true) + expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true) + }) }) diff --git a/tests/pi-converter.test.ts b/tests/pi-converter.test.ts new file mode 100644 index 0000000..d7edf95 --- /dev/null +++ b/tests/pi-converter.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { loadClaudePlugin } from "../src/parsers/claude" +import { convertClaudeToPi } from "../src/converters/claude-to-pi" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + +describe("convertClaudeToPi", () => { + test("converts commands, skills, extensions, and MCPorter config", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToPi(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + // 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) + + // Commands with disable-model-invocation are excluded + expect(bundle.prompts.some((prompt) => prompt.name === "deploy-docs")).toBe(false) + + const workflowsReview = bundle.prompts.find((prompt) => prompt.name === "workflows-review") + expect(workflowsReview).toBeDefined() + const parsedPrompt = parseFrontmatter(workflowsReview!.content) + expect(parsedPrompt.data.description).toBe("Run a multi-agent review workflow") + + // Existing skills are copied and agents are converted into generated Pi skills + 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) + 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: "mcporter_call"') + + // Claude MCP config is translated to MCPorter config + expect(bundle.mcporterConfig?.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp") + expect(bundle.mcporterConfig?.mcpServers["local-tooling"]?.command).toBe("echo") + }) + + test("transforms Task calls, AskUserQuestion, slash commands, and todo tool references", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [], + commands: [ + { + name: "workflows:plan", + description: "Plan workflow", + body: [ + "Run these in order:", + "- Task repo-research-analyst(feature_description)", + "- Task learnings-researcher(feature_description)", + "Use AskUserQuestion tool for follow-up.", + "Then use /workflows:work and /prompts:deepen-plan.", + "Track progress with TodoWrite and TodoRead.", + ].join("\n"), + sourcePath: "/tmp/plugin/commands/plan.md", + }, + ], + skills: [], + hooks: undefined, + mcpServers: undefined, + } + + const bundle = convertClaudeToPi(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + 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("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("appends MCPorter compatibility note when command references MCP", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [], + commands: [ + { + name: "docs", + description: "Read MCP docs", + body: "Use MCP servers for docs lookup.", + sourcePath: "/tmp/plugin/commands/docs.md", + }, + ], + skills: [], + hooks: undefined, + mcpServers: undefined, + } + + const bundle = convertClaudeToPi(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsedPrompt = parseFrontmatter(bundle.prompts[0].content) + expect(parsedPrompt.body).toContain("Pi + MCPorter note") + expect(parsedPrompt.body).toContain("mcporter_call") + }) +}) diff --git a/tests/pi-writer.test.ts b/tests/pi-writer.test.ts new file mode 100644 index 0000000..5af7ea6 --- /dev/null +++ b/tests/pi-writer.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writePiBundle } from "../src/targets/pi" +import type { PiBundle } from "../src/types/pi" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writePiBundle", () => { + test("writes prompts, skills, extensions, mcporter config, and AGENTS.md block", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-writer-")) + const outputRoot = path.join(tempRoot, ".pi") + + const bundle: PiBundle = { + prompts: [{ name: "workflows-plan", content: "Prompt content" }], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + generatedSkills: [{ name: "repo-research-analyst", content: "---\nname: repo-research-analyst\n---\n\nBody" }], + extensions: [{ name: "compound-engineering-compat.ts", content: "export default function () {}" }], + mcporterConfig: { + mcpServers: { + context7: { baseUrl: "https://mcp.context7.com/mcp" }, + }, + }, + } + + await writePiBundle(outputRoot, bundle) + + expect(await exists(path.join(outputRoot, "prompts", "workflows-plan.md"))).toBe(true) + expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(outputRoot, "skills", "repo-research-analyst", "SKILL.md"))).toBe(true) + expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true) + expect(await exists(path.join(outputRoot, "compound-engineering", "mcporter.json"))).toBe(true) + + const agentsPath = path.join(outputRoot, "AGENTS.md") + const agentsContent = await fs.readFile(agentsPath, "utf8") + expect(agentsContent).toContain("BEGIN COMPOUND PI TOOL MAP") + expect(agentsContent).toContain("MCPorter") + }) + + test("writes to ~/.pi/agent style roots without nesting under .pi", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agent-root-")) + const outputRoot = path.join(tempRoot, "agent") + + const bundle: PiBundle = { + prompts: [{ name: "workflows-work", content: "Prompt content" }], + skillDirs: [], + generatedSkills: [], + extensions: [], + } + + await writePiBundle(outputRoot, bundle) + + expect(await exists(path.join(outputRoot, "prompts", "workflows-work.md"))).toBe(true) + expect(await exists(path.join(outputRoot, ".pi"))).toBe(false) + }) + + 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") + const configPath = path.join(outputRoot, "compound-engineering", "mcporter.json") + + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify({ previous: true }, null, 2)) + + const bundle: PiBundle = { + prompts: [], + skillDirs: [], + generatedSkills: [], + extensions: [], + mcporterConfig: { + mcpServers: { + linear: { baseUrl: "https://mcp.linear.app/mcp" }, + }, + }, + } + + await writePiBundle(outputRoot, bundle) + + const files = await fs.readdir(path.dirname(configPath)) + const backupFileName = files.find((file) => file.startsWith("mcporter.json.bak.")) + expect(backupFileName).toBeDefined() + + const currentConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as { mcpServers: Record } + expect(currentConfig.mcpServers.linear).toBeDefined() + }) +}) diff --git a/tests/sync-pi.test.ts b/tests/sync-pi.test.ts new file mode 100644 index 0000000..6459e65 --- /dev/null +++ b/tests/sync-pi.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { syncToPi } from "../src/sync/pi" +import type { ClaudeHomeConfig } from "../src/parsers/claude-home" + +describe("syncToPi", () => { + test("symlinks skills and writes MCPorter config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + local: { command: "echo", args: ["hello"] }, + }, + } + + await syncToPi(config, tempRoot) + + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + + const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json") + const mcporterConfig = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as { + mcpServers: Record + } + + expect(mcporterConfig.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp") + expect(mcporterConfig.mcpServers.local?.command).toBe("echo") + }) + + 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") + await fs.mkdir(path.dirname(mcporterPath), { recursive: true }) + + await fs.writeFile( + mcporterPath, + JSON.stringify({ mcpServers: { existing: { baseUrl: "https://example.com/mcp" } } }, null, 2), + ) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + }, + } + + await syncToPi(config, tempRoot) + + const merged = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as { + mcpServers: Record + } + + expect(merged.mcpServers.existing?.baseUrl).toBe("https://example.com/mcp") + expect(merged.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp") + }) +})