Skip to content

Commit 0bff8ea

Browse files
committed
fix(pi): normalize skill names and avoid subagent tool conflicts
- Normalize Pi skill names to lowercase-hyphen form (ce:plan → ce-plan) - Truncate normalized names to 60 chars to stay within Pi's 64-char limit - Rename CE compat tool from subagent to ce_subagent to avoid pi-subagents collision - Rewrite Task calls, slash commands, and tool refs in copied/synced Pi content - Support zero-argument Task calls (Task agent() with no args) - Resolve Task, /skill:, and slash refs against deduped name maps in converter and sync - Two-pass name allocation for skills, agents, and prompts to handle collisions - Sort skills, agents, and commands before dedup for deterministic allocation across runs - Use locale-independent codepoint sort for cross-platform stability - Pass prompt maps from sync orchestrator into skill sync for cross-reference resolution - Fall through /skill: refs to agent map since Pi uses a flat skills/ namespace - Thread name maps through writer path so copied skills use deduped names - Rewrite frontmatterless skill bodies during Pi copy - Fallback-rewrite malformed-frontmatter skill bodies (body only, preserving broken YAML) - Apply MCPorter compatibility note only to prompts, not agent skill files - Handle dangling symlinks during skill dir cleanup using lstat instead of access - Back up existing skill dirs before overwrite, retain most recent backup - Skip symlinks during backup cleanup to prevent unintended deletions - Strip doubled quotes from Task args and tighten subagent regex to avoid false positives - Use sentinel comment for MCPorter note idempotency - Write compat extension during skills-only sync - Guard parseFrontmatter calls against malformed YAML with warnings
1 parent fe27f85 commit 0bff8ea

10 files changed

Lines changed: 912 additions & 127 deletions

File tree

src/converters/claude-to-pi.ts

Lines changed: 40 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { formatFrontmatter } from "../utils/frontmatter"
2+
import { appendCompatibilityNoteIfNeeded, normalizePiSkillName, transformPiBodyContent, uniquePiSkillName, type PiNameMaps } from "../utils/pi-skills"
23
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
34
import type {
45
PiBundle,
@@ -18,13 +19,41 @@ export function convertClaudeToPi(
1819
_options: ClaudeToPiOptions,
1920
): PiBundle {
2021
const promptNames = new Set<string>()
21-
const usedSkillNames = new Set<string>(plugin.skills.map((skill) => normalizeName(skill.name)))
22+
const usedSkillNames = new Set<string>()
2223

23-
const prompts = plugin.commands
24+
const sortedSkills = [...plugin.skills].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
25+
const sortedAgents = [...plugin.agents].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
26+
27+
const skillDirs = sortedSkills.map((skill) => ({
28+
name: uniquePiSkillName(normalizePiSkillName(skill.name), usedSkillNames),
29+
sourceDir: skill.sourceDir,
30+
}))
31+
32+
const agentNames = sortedAgents.map((agent) =>
33+
uniquePiSkillName(normalizePiSkillName(agent.name), usedSkillNames),
34+
)
35+
36+
const agentMap: Record<string, string> = {}
37+
sortedAgents.forEach((agent, i) => { agentMap[agent.name] = agentNames[i] })
38+
39+
const skillMap: Record<string, string> = {}
40+
sortedSkills.forEach((skill, i) => { skillMap[skill.name] = skillDirs[i].name })
41+
42+
const convertibleCommands = [...plugin.commands]
2443
.filter((command) => !command.disableModelInvocation)
25-
.map((command) => convertPrompt(command, promptNames))
44+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
45+
const promptTargetNames = convertibleCommands.map((command) =>
46+
uniquePiSkillName(normalizePiSkillName(command.name), promptNames),
47+
)
2648

27-
const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
49+
const promptMap: Record<string, string> = {}
50+
convertibleCommands.forEach((command, i) => { promptMap[command.name] = promptTargetNames[i] })
51+
52+
const nameMaps: PiNameMaps = { agents: agentMap, skills: skillMap, prompts: promptMap }
53+
54+
const prompts = convertibleCommands.map((command, i) => convertPrompt(command, promptTargetNames[i], nameMaps))
55+
56+
const generatedSkills = sortedAgents.map((agent, i) => convertAgent(agent, agentNames[i], nameMaps))
2857

2958
const extensions = [
3059
{
@@ -35,34 +64,29 @@ export function convertClaudeToPi(
3564

3665
return {
3766
prompts,
38-
skillDirs: plugin.skills.map((skill) => ({
39-
name: skill.name,
40-
sourceDir: skill.sourceDir,
41-
})),
67+
skillDirs,
4268
generatedSkills,
4369
extensions,
4470
mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
71+
nameMaps,
4572
}
4673
}
4774

48-
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
49-
const name = uniqueName(normalizeName(command.name), usedNames)
75+
function convertPrompt(command: ClaudeCommand, name: string, nameMaps: PiNameMaps) {
5076
const frontmatter: Record<string, unknown> = {
5177
description: command.description,
5278
"argument-hint": command.argumentHint,
5379
}
5480

55-
let body = transformContentForPi(command.body)
56-
body = appendCompatibilityNoteIfNeeded(body)
81+
const body = appendCompatibilityNoteIfNeeded(transformPiBodyContent(command.body, nameMaps))
5782

5883
return {
5984
name,
6085
content: formatFrontmatter(frontmatter, body.trim()),
6186
}
6287
}
6388

64-
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSkill {
65-
const name = uniqueName(normalizeName(agent.name), usedNames)
89+
function convertAgent(agent: ClaudeAgent, name: string, nameMaps: PiNameMaps): PiGeneratedSkill {
6690
const description = sanitizeDescription(
6791
agent.description ?? `Converted from Claude agent ${agent.name}`,
6892
)
@@ -77,77 +101,19 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSk
77101
sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
78102
}
79103

80-
const body = [
104+
const body = transformPiBodyContent([
81105
...sections,
82106
agent.body.trim().length > 0
83107
? agent.body.trim()
84108
: `Instructions converted from the ${agent.name} agent.`,
85-
].join("\n\n")
109+
].join("\n\n"), nameMaps)
86110

87111
return {
88112
name,
89113
content: formatFrontmatter(frontmatter, body),
90114
}
91115
}
92116

93-
export function transformContentForPi(body: string): string {
94-
let result = body
95-
96-
// Task repo-research-analyst(feature_description) or Task compound-engineering:research:repo-research-analyst(args)
97-
// -> Run subagent with agent="repo-research-analyst" and task="feature_description"
98-
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
99-
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
100-
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
101-
const skillName = normalizeName(finalSegment)
102-
const trimmedArgs = args.trim().replace(/\s+/g, " ")
103-
return trimmedArgs
104-
? `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
105-
: `${prefix}Run subagent with agent=\"${skillName}\".`
106-
})
107-
108-
// Claude-specific tool references
109-
result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question")
110-
result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:todo-create)")
111-
result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:todo-create)")
112-
113-
// /command-name or /workflows:command-name -> /workflows-command-name
114-
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
115-
result = result.replace(slashCommandPattern, (match, commandName: string) => {
116-
if (commandName.includes("/")) return match
117-
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) {
118-
return match
119-
}
120-
121-
if (commandName.startsWith("skill:")) {
122-
const skillName = commandName.slice("skill:".length)
123-
return `/skill:${normalizeName(skillName)}`
124-
}
125-
126-
const withoutPrefix = commandName.startsWith("prompts:")
127-
? commandName.slice("prompts:".length)
128-
: commandName
129-
130-
return `/${normalizeName(withoutPrefix)}`
131-
})
132-
133-
return result
134-
}
135-
136-
function appendCompatibilityNoteIfNeeded(body: string): string {
137-
if (!/\bmcp\b/i.test(body)) return body
138-
139-
const note = [
140-
"",
141-
"## Pi + MCPorter note",
142-
"For MCP access in Pi, use MCPorter via the generated tools:",
143-
"- `mcporter_list` to inspect available MCP tools",
144-
"- `mcporter_call` to invoke a tool",
145-
"",
146-
].join("\n")
147-
148-
return body + note
149-
}
150-
151117
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
152118
const mcpServers: Record<string, PiMcporterServer> = {}
153119

@@ -173,36 +139,9 @@ function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcpor
173139
return { mcpServers }
174140
}
175141

176-
function normalizeName(value: string): string {
177-
const trimmed = value.trim()
178-
if (!trimmed) return "item"
179-
const normalized = trimmed
180-
.toLowerCase()
181-
.replace(/[\\/]+/g, "-")
182-
.replace(/[:\s]+/g, "-")
183-
.replace(/[^a-z0-9_-]+/g, "-")
184-
.replace(/-+/g, "-")
185-
.replace(/^-+|-+$/g, "")
186-
return normalized || "item"
187-
}
188-
189142
function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGTH): string {
190143
const normalized = value.replace(/\s+/g, " ").trim()
191144
if (normalized.length <= maxLength) return normalized
192145
const ellipsis = "..."
193146
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
194147
}
195-
196-
function uniqueName(base: string, used: Set<string>): string {
197-
if (!used.has(base)) {
198-
used.add(base)
199-
return base
200-
}
201-
let index = 2
202-
while (used.has(`${base}-${index}`)) {
203-
index += 1
204-
}
205-
const name = `${base}-${index}`
206-
used.add(name)
207-
return name
208-
}

src/sync/pi-skills.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import path from "path"
2+
import type { ClaudeSkill } from "../types/claude"
3+
import { ensureDir } from "../utils/files"
4+
import { copySkillDirForPi, normalizePiSkillName, skillFileMatchesPiTarget, uniquePiSkillName, type PiNameMaps } from "../utils/pi-skills"
5+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
6+
7+
export async function syncPiSkills(
8+
skills: ClaudeSkill[],
9+
skillsDir: string,
10+
extraNameMaps?: PiNameMaps,
11+
): Promise<void> {
12+
await ensureDir(skillsDir)
13+
14+
const validSkills = skills
15+
.filter((skill) => {
16+
if (!isValidSkillName(skill.name)) {
17+
console.warn(`Skipping skill with unsafe name: ${skill.name}`)
18+
return false
19+
}
20+
return true
21+
})
22+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
23+
24+
const usedNames = new Set<string>()
25+
const targetNames = validSkills.map((skill) =>
26+
uniquePiSkillName(normalizePiSkillName(skill.name), usedNames),
27+
)
28+
29+
const skillMap: Record<string, string> = {}
30+
validSkills.forEach((skill, i) => { skillMap[skill.name] = targetNames[i] })
31+
const nameMaps: PiNameMaps = { skills: skillMap, prompts: extraNameMaps?.prompts }
32+
33+
for (const [i, skill] of validSkills.entries()) {
34+
const targetName = targetNames[i]
35+
const target = path.join(skillsDir, targetName)
36+
const alreadyPiCompatible = await skillFileMatchesPiTarget(skill.skillPath, targetName, nameMaps)
37+
38+
if (skill.name === targetName && alreadyPiCompatible) {
39+
await forceSymlink(skill.sourceDir, target)
40+
continue
41+
}
42+
43+
await copySkillDirForPi(skill.sourceDir, target, targetName, nameMaps)
44+
}
45+
}

src/sync/pi.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import path from "path"
22
import type { ClaudeHomeConfig } from "../parsers/claude-home"
33
import type { ClaudeMcpServer } from "../types/claude"
4-
import { ensureDir } from "../utils/files"
4+
import { ensureDir, writeText } from "../utils/files"
5+
import { normalizePiSkillName, uniquePiSkillName } from "../utils/pi-skills"
6+
import { PI_COMPAT_EXTENSION_SOURCE } from "../templates/pi/compat-extension"
57
import { syncPiCommands } from "./commands"
68
import { mergeJsonConfigAtKey } from "./json-config"
7-
import { syncSkills } from "./skills"
9+
import { syncPiSkills } from "./pi-skills"
810

911
type McporterServer = {
1012
baseUrl?: string
@@ -24,9 +26,26 @@ export async function syncToPi(
2426
): Promise<void> {
2527
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
2628

27-
await syncSkills(config.skills, path.join(outputRoot, "skills"))
29+
const commands = [...(config.commands ?? [])].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
30+
const promptNames = new Set<string>()
31+
const promptMap: Record<string, string> = {}
32+
for (const command of commands) {
33+
if (command.disableModelInvocation) continue
34+
const targetName = uniquePiSkillName(normalizePiSkillName(command.name), promptNames)
35+
promptMap[command.name] = targetName
36+
}
37+
38+
await syncPiSkills(config.skills, path.join(outputRoot, "skills"), { prompts: promptMap })
2839
await syncPiCommands(config, outputRoot)
2940

41+
if (config.skills.length > 0) {
42+
await ensureDir(path.join(outputRoot, "extensions"))
43+
await writeText(
44+
path.join(outputRoot, "extensions", "compound-engineering-compat.ts"),
45+
PI_COMPAT_EXTENSION_SOURCE + "\n",
46+
)
47+
}
48+
3049
if (Object.keys(config.mcpServers).length > 0) {
3150
await ensureDir(path.dirname(mcporterPath))
3251
const converted = convertMcpToMcporter(config.mcpServers)

src/targets/pi.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import path from "path"
22
import {
33
backupFile,
4-
copySkillDir,
54
ensureDir,
65
pathExists,
76
readText,
87
writeJson,
98
writeText,
109
} from "../utils/files"
11-
import { transformContentForPi } from "../converters/claude-to-pi"
10+
import { copySkillDirForPi } from "../utils/pi-skills"
1211
import type { PiBundle } from "../types/pi"
1312

1413
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
@@ -19,8 +18,8 @@ const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
1918
This block is managed by compound-plugin.
2019
2120
Compatibility notes:
22-
- Claude Task(agent, args) maps to the subagent extension tool
23-
- For parallel agent runs, batch multiple subagent calls with multi_tool_use.parallel
21+
- Claude Task(agent, args) maps to the ce_subagent extension tool
22+
- Use ce_subagent for Compound Engineering workflows even when another extension also provides a generic subagent tool
2423
- AskUserQuestion maps to the ask_user_question extension tool
2524
- MCP access uses MCPorter via mcporter_list and mcporter_call extension tools
2625
- MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global)
@@ -38,7 +37,7 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
3837
}
3938

4039
for (const skill of bundle.skillDirs) {
41-
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForPi)
40+
await copySkillDirForPi(skill.sourceDir, path.join(paths.skillsDir, skill.name), skill.name, bundle.nameMaps)
4241
}
4342

4443
for (const skill of bundle.generatedSkills) {

src/templates/pi/compat-extension.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@ export default function (pi: ExtensionAPI) {
245245
})
246246
247247
pi.registerTool({
248-
name: "subagent",
249-
label: "Subagent",
250-
description: "Run one or more skill-based subagent tasks. Supports single, parallel, and chained execution.",
248+
name: "ce_subagent",
249+
label: "Compound Engineering Subagent",
250+
description: "Run one or more Compound Engineering skill-based subagent tasks. Supports single, parallel, and chained execution.",
251251
parameters: Type.Object({
252252
agent: Type.Optional(Type.String({ description: "Single subagent name" })),
253253
task: Type.Optional(Type.String({ description: "Single subagent task" })),

src/types/pi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export type PiBundle = {
3737
generatedSkills: PiGeneratedSkill[]
3838
extensions: PiExtensionFile[]
3939
mcporterConfig?: PiMcporterConfig
40+
nameMaps?: import("../utils/pi-skills").PiNameMaps
4041
}

0 commit comments

Comments
 (0)