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 }