Skip to content

Commit 5570e1f

Browse files
committed
fix(pi): normalize skill names and avoid subagent tool conflicts
1 parent bf6d7d5 commit 5570e1f

9 files changed

Lines changed: 421 additions & 108 deletions

File tree

src/converters/claude-to-pi.ts

Lines changed: 13 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { formatFrontmatter } from "../utils/frontmatter"
2+
import { normalizePiSkillName, transformPiBodyContent, uniquePiSkillName } from "../utils/pi-skills"
23
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
34
import type {
45
PiBundle,
@@ -18,12 +19,17 @@ 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

2324
const prompts = plugin.commands
2425
.filter((command) => !command.disableModelInvocation)
2526
.map((command) => convertPrompt(command, promptNames))
2627

28+
const skillDirs = plugin.skills.map((skill) => ({
29+
name: uniquePiSkillName(normalizePiSkillName(skill.name), usedSkillNames),
30+
sourceDir: skill.sourceDir,
31+
}))
32+
2733
const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
2834

2935
const extensions = [
@@ -35,25 +41,21 @@ export function convertClaudeToPi(
3541

3642
return {
3743
prompts,
38-
skillDirs: plugin.skills.map((skill) => ({
39-
name: skill.name,
40-
sourceDir: skill.sourceDir,
41-
})),
44+
skillDirs,
4245
generatedSkills,
4346
extensions,
4447
mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
4548
}
4649
}
4750

4851
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
49-
const name = uniqueName(normalizeName(command.name), usedNames)
52+
const name = uniquePiSkillName(normalizePiSkillName(command.name), usedNames)
5053
const frontmatter: Record<string, unknown> = {
5154
description: command.description,
5255
"argument-hint": command.argumentHint,
5356
}
5457

55-
let body = transformContentForPi(command.body)
56-
body = appendCompatibilityNoteIfNeeded(body)
58+
const body = transformPiBodyContent(command.body)
5759

5860
return {
5961
name,
@@ -62,7 +64,7 @@ function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
6264
}
6365

6466
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSkill {
65-
const name = uniqueName(normalizeName(agent.name), usedNames)
67+
const name = uniquePiSkillName(normalizePiSkillName(agent.name), usedNames)
6668
const description = sanitizeDescription(
6769
agent.description ?? `Converted from Claude agent ${agent.name}`,
6870
)
@@ -77,74 +79,19 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSk
7779
sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
7880
}
7981

80-
const body = [
82+
const body = transformPiBodyContent([
8183
...sections,
8284
agent.body.trim().length > 0
8385
? agent.body.trim()
8486
: `Instructions converted from the ${agent.name} agent.`,
85-
].join("\n\n")
87+
].join("\n\n"))
8688

8789
return {
8890
name,
8991
content: formatFrontmatter(frontmatter, body),
9092
}
9193
}
9294

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

@@ -170,36 +117,10 @@ function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcpor
170117
return { mcpServers }
171118
}
172119

173-
function normalizeName(value: string): string {
174-
const trimmed = value.trim()
175-
if (!trimmed) return "item"
176-
const normalized = trimmed
177-
.toLowerCase()
178-
.replace(/[\\/]+/g, "-")
179-
.replace(/[:\s]+/g, "-")
180-
.replace(/[^a-z0-9_-]+/g, "-")
181-
.replace(/-+/g, "-")
182-
.replace(/^-+|-+$/g, "")
183-
return normalized || "item"
184-
}
185-
186120
function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGTH): string {
187121
const normalized = value.replace(/\s+/g, " ").trim()
188122
if (normalized.length <= maxLength) return normalized
189123
const ellipsis = "..."
190124
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
191125
}
192126

193-
function uniqueName(base: string, used: Set<string>): string {
194-
if (!used.has(base)) {
195-
used.add(base)
196-
return base
197-
}
198-
let index = 2
199-
while (used.has(`${base}-${index}`)) {
200-
index += 1
201-
}
202-
const name = `${base}-${index}`
203-
used.add(name)
204-
return name
205-
}

src/sync/pi-skills.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import path from "path"
2+
import type { ClaudeSkill } from "../types/claude"
3+
import { ensureDir } from "../utils/files"
4+
import { copySkillDirForPi, normalizePiSkillName, skillFileMatchesPiTarget, uniquePiSkillName } from "../utils/pi-skills"
5+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
6+
7+
export async function syncPiSkills(
8+
skills: ClaudeSkill[],
9+
skillsDir: string,
10+
): Promise<void> {
11+
await ensureDir(skillsDir)
12+
13+
const usedNames = new Set<string>()
14+
15+
for (const skill of skills) {
16+
if (!isValidSkillName(skill.name)) {
17+
console.warn(`Skipping skill with unsafe name: ${skill.name}`)
18+
continue
19+
}
20+
21+
const targetName = uniquePiSkillName(normalizePiSkillName(skill.name), usedNames)
22+
const target = path.join(skillsDir, targetName)
23+
const alreadyPiCompatible = await skillFileMatchesPiTarget(skill.skillPath, targetName)
24+
25+
if (skill.name === targetName && alreadyPiCompatible) {
26+
await forceSymlink(skill.sourceDir, target)
27+
continue
28+
}
29+
30+
await copySkillDirForPi(skill.sourceDir, target, targetName)
31+
}
32+
}

src/sync/pi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ClaudeMcpServer } from "../types/claude"
44
import { ensureDir } from "../utils/files"
55
import { syncPiCommands } from "./commands"
66
import { mergeJsonConfigAtKey } from "./json-config"
7-
import { syncSkills } from "./skills"
7+
import { syncPiSkills } from "./pi-skills"
88

99
type McporterServer = {
1010
baseUrl?: string
@@ -24,7 +24,7 @@ export async function syncToPi(
2424
): Promise<void> {
2525
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
2626

27-
await syncSkills(config.skills, path.join(outputRoot, "skills"))
27+
await syncPiSkills(config.skills, path.join(outputRoot, "skills"))
2828
await syncPiCommands(config, outputRoot)
2929

3030
if (Object.keys(config.mcpServers).length > 0) {

src/targets/pi.ts

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

1313
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
@@ -18,8 +18,8 @@ const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
1818
This block is managed by compound-plugin.
1919
2020
Compatibility notes:
21-
- Claude Task(agent, args) maps to the subagent extension tool
22-
- 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
2323
- AskUserQuestion maps to the ask_user_question extension tool
2424
- MCP access uses MCPorter via mcporter_list and mcporter_call extension tools
2525
- 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
3737
}
3838

3939
for (const skill of bundle.skillDirs) {
40-
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
40+
await copySkillDirForPi(skill.sourceDir, path.join(paths.skillsDir, skill.name), skill.name)
4141
}
4242

4343
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" })),

0 commit comments

Comments
 (0)