Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 13 additions & 92 deletions src/converters/claude-to-pi.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,12 +19,17 @@ export function convertClaudeToPi(
_options: ClaudeToPiOptions,
): PiBundle {
const promptNames = new Set<string>()
const usedSkillNames = new Set<string>(plugin.skills.map((skill) => normalizeName(skill.name)))
const usedSkillNames = new Set<string>()

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 = [
Expand All @@ -35,25 +41,21 @@ 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,
}
}

function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
const name = uniqueName(normalizeName(command.name), usedNames)
const name = uniquePiSkillName(normalizePiSkillName(command.name), usedNames)
const frontmatter: Record<string, unknown> = {
description: command.description,
"argument-hint": command.argumentHint,
}

let body = transformContentForPi(command.body)
body = appendCompatibilityNoteIfNeeded(body)
const body = transformPiBodyContent(command.body)

return {
name,
Expand All @@ -62,7 +64,7 @@ function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
}

function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): 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}`,
)
Expand All @@ -77,74 +79,19 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): 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,
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 = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
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<string, ClaudeMcpServer>): PiMcporterConfig {
const mcpServers: Record<string, PiMcporterServer> = {}

Expand All @@ -170,36 +117,10 @@ function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): 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
const ellipsis = "..."
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
}

function uniqueName(base: string, used: Set<string>): 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
}
32 changes: 32 additions & 0 deletions src/sync/pi-skills.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await ensureDir(skillsDir)

const usedNames = new Set<string>()

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)
}
}
4 changes: 2 additions & 2 deletions src/sync/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +24,7 @@ export async function syncToPi(
): Promise<void> {
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) {
Expand Down
8 changes: 4 additions & 4 deletions src/targets/pi.ts
Original file line number Diff line number Diff line change
@@ -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 = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/templates/pi/compat-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })),
Expand Down
Loading
Loading