feat(pi): first-class support via pi-subagents + pi-ask-user (#651)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-22 10:26:29 -07:00
committed by GitHub
parent cce95fb814
commit 7ddfbed33b
53 changed files with 371 additions and 636 deletions

View File

@@ -1680,8 +1680,14 @@ describe("CLI", () => {
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)
// Claude agents now install at .pi/agents/<name>.md (Pi agent format) so
// nicobailon/pi-subagents can resolve them via the `subagent` tool.
expect(await exists(path.join(piRoot, "agents", "repo-research-analyst.md"))).toBe(true)
// Pi installs no longer ship a plugin-authored compat extension; users install
// community pi-subagents + pi-ask-user extensions directly in Pi. MCP servers
// declared in plugin.json are still translated to mcporter.json so plugins
// with MCP wiring keep their backends after conversion.
expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(false)
expect(await exists(path.join(piRoot, "compound-engineering", "mcporter.json"))).toBe(true)
})
@@ -1721,7 +1727,7 @@ describe("CLI", () => {
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)
expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(false)
})
test("install --to opencode uses permissions:none by default", async () => {

View File

@@ -94,6 +94,19 @@ describe("frontmatter YAML validity", () => {
`Shorten description to ${MAX_SKILL_DESCRIPTION_LENGTH} chars or less`,
).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH)
})
// Pi rejects skill names that don't match the parent directory or contain
// characters outside [a-z0-9-]. Upgrading from a pre-v3 install with
// `name: ce:brainstorm` frontmatter in a renamed `ce-brainstorm` directory
// triggered issue #449. Catch any reintroduction at the source.
test(`${pluginRoot}/${rel} skill frontmatter name matches directory and uses valid characters`, () => {
const parsed = load(yaml) as Record<string, unknown> | null
const name = parsed && typeof parsed.name === "string" ? parsed.name : ""
const dirName = path.basename(path.dirname(rel))
expect(name, `frontmatter name must be present`).not.toBe("")
expect(name, `frontmatter name "${name}" must match parent directory "${dirName}"`).toBe(dirName)
expect(name, `frontmatter name "${name}" must be lowercase a-z, 0-9, and hyphens`).toMatch(/^[a-z0-9-]+$/)
})
}
}
}

View File

@@ -8,7 +8,7 @@ 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 () => {
test("converts commands, skills, agents, and MCP servers without shipping a Pi extension", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToPi(plugin, {
agentMode: "subagent",
@@ -28,22 +28,61 @@ describe("convertClaudeToPi", () => {
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
// Existing skills are copied as skill dirs; Claude agents are converted to
// Pi agent files (under bundle.agents, written to .pi/agents/<name>.md) so
// that nicobailon/pi-subagents' `subagent` tool can resolve them by name.
expect(bundle.skillDirs.some((skill) => skill.name === "skill-one")).toBe(true)
expect(bundle.generatedSkills.some((skill) => skill.name === "repo-research-analyst")).toBe(true)
expect(bundle.agents.some((agent) => agent.name === "repo-research-analyst")).toBe(true)
// Agents no longer leak into generatedSkills — that field is reserved for
// commands-as-skills on other targets; Pi keeps it empty.
expect(bundle.generatedSkills).toEqual([])
// 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"')
// Pi installs now depend on the community pi-subagents and pi-ask-user extensions,
// so the converter emits no bundled extension. Legacy cleanup in the Pi writer
// removes any prior compound-engineering-compat.ts on upgrade.
expect(bundle.extensions).toEqual([])
// 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")
// MCP servers declared in plugin.json are translated to Pi's mcporter.json
// shape so plugins with MCP wiring keep their backends after conversion.
// The fixture declares both an HTTP url server (context7) and a stdio
// command server (local-tooling).
expect(bundle.mcporterConfig).toEqual({
mcpServers: {
context7: {
baseUrl: "https://mcp.context7.com/mcp",
headers: undefined,
},
"local-tooling": {
command: "echo",
args: ["fixture"],
env: undefined,
headers: undefined,
},
},
})
})
test("transforms Task calls, AskUserQuestion, slash commands, and todo tool references", () => {
test("omits mcporterConfig when the plugin declares no MCP servers", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [],
commands: [],
skills: [],
hooks: undefined,
mcpServers: undefined,
}
const bundle = convertClaudeToPi(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.mcporterConfig).toBeUndefined()
})
test("transforms Task calls, slash commands, and todo tool references; preserves AskUserQuestion", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
@@ -79,7 +118,10 @@ describe("convertClaudeToPi", () => {
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")
// AskUserQuestion is preserved; skill source-side enumerations name each platform's
// blocking-question tool (including `ask_user` for Pi via pi-ask-user), so the
// converter no longer rewrites the token.
expect(parsedPrompt.body).toContain("AskUserQuestion")
expect(parsedPrompt.body).toContain("/workflows-work")
expect(parsedPrompt.body).toContain("/todo-resolve")
expect(parsedPrompt.body).toContain("the platform's task-tracking primitive")
@@ -184,32 +226,4 @@ describe("convertClaudeToPi", () => {
expect(parsedPrompt.body).not.toContain("()")
})
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")
})
})

View File

@@ -47,6 +47,7 @@ describe("writePiBundle", () => {
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [],
extensions: [],
}
@@ -69,7 +70,8 @@ describe("writePiBundle", () => {
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" }],
generatedSkills: [],
agents: [{ name: "repo-research-analyst", content: "---\nname: repo-research-analyst\n---\n\nBody" }],
extensions: [{ name: "compound-engineering-compat.ts", content: "export default function () {}" }],
mcporterConfig: {
mcpServers: {
@@ -82,7 +84,10 @@ describe("writePiBundle", () => {
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)
// Claude agents are now written as Pi agent files (.pi/agents/<name>.md),
// not skill directories, so nicobailon/pi-subagents can resolve them via
// the `subagent` tool.
expect(await exists(path.join(outputRoot, "agents", "repo-research-analyst.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)
expect(await exists(path.join(outputRoot, "compound-engineering", "install-manifest.json"))).toBe(true)
@@ -90,7 +95,8 @@ describe("writePiBundle", () => {
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")
expect(agentsContent).toContain("pi-subagents")
expect(agentsContent).toContain("pi-ask-user")
})
test("transforms Task calls in copied SKILL.md files", async () => {
@@ -117,6 +123,7 @@ Run these research agents:
prompts: [],
skillDirs: [{ name: "ce-plan", sourceDir: sourceSkillDir }],
generatedSkills: [],
agents: [],
extensions: [],
}
@@ -141,6 +148,7 @@ Run these research agents:
prompts: [{ name: "workflows-work", content: "Prompt content" }],
skillDirs: [],
generatedSkills: [],
agents: [],
extensions: [],
}
@@ -162,6 +170,7 @@ Run these research agents:
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [],
extensions: [],
mcporterConfig: {
mcpServers: {
@@ -193,7 +202,8 @@ Run these research agents:
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "old-agent", content: "---\nname: old-agent\n---\n\nBody" }],
generatedSkills: [],
agents: [{ name: "old-agent", content: "---\nname: old-agent\n---\n\nBody" }],
extensions: [{ name: "compound-engineering-compat.ts", content: "export default function first() {}" }],
})
@@ -201,15 +211,16 @@ Run these research agents:
pluginName: "compound-engineering",
prompts: [{ name: "new-prompt", content: "Prompt content" }],
skillDirs: [],
generatedSkills: [{ name: "new-agent", content: "---\nname: new-agent\n---\n\nBody" }],
generatedSkills: [],
agents: [{ name: "new-agent", content: "---\nname: new-agent\n---\n\nBody" }],
extensions: [],
})
expect(await exists(path.join(outputRoot, "prompts", "old-prompt.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "new-prompt.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "old-agent", "SKILL.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "new-agent", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "agents", "old-agent.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "agents", "new-agent.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(false)
})
@@ -228,6 +239,7 @@ Run these research agents:
},
],
generatedSkills: [{ name: "ce-gen-skill", content: "---\nname: ce-gen-skill\n---\n\nBody" }],
agents: [],
extensions: [{ name: "ce-ext.ts", content: "export default function () {}" }],
})
@@ -242,6 +254,7 @@ Run these research agents:
},
],
generatedSkills: [{ name: "tutor-gen-skill", content: "---\nname: tutor-gen-skill\n---\n\nBody" }],
agents: [],
extensions: [{ name: "tutor-ext.ts", content: "export default function () {}" }],
})
@@ -258,6 +271,7 @@ Run these research agents:
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [],
extensions: [],
})
@@ -272,6 +286,53 @@ Run these research agents:
expect(await exists(path.join(outputRoot, "coding-tutor", "install-manifest.json"))).toBe(true)
})
test("moves stale compound-engineering mcporter.json to legacy backup when bundle has no mcporterConfig", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-legacy-mcporter-"))
const outputRoot = path.join(tempRoot, ".pi")
const staleConfigPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
await fs.mkdir(path.dirname(staleConfigPath), { recursive: true })
await fs.writeFile(
staleConfigPath,
JSON.stringify({ mcpServers: { stale: { baseUrl: "https://example.invalid/mcp" } } }, null, 2),
)
const bundle: PiBundle = {
pluginName: "compound-engineering",
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [],
extensions: [],
// No mcporterConfig — the compound-engineering plugin ships no MCP
// servers, so the file written by the removed compat extension should
// be swept into legacy-backup rather than lingering on disk.
}
await writePiBundle(outputRoot, bundle)
expect(await exists(staleConfigPath)).toBe(false)
const legacyBackupRoot = path.join(outputRoot, "compound-engineering", "legacy-backup")
expect(await exists(legacyBackupRoot)).toBe(true)
const timestamps = await fs.readdir(legacyBackupRoot)
const mcporterBackup = (
await Promise.all(
timestamps.map(async (timestamp) => {
const candidate = path.join(legacyBackupRoot, timestamp, "mcporter", "mcporter.json")
return (await exists(candidate)) ? candidate : null
}),
)
).find((candidate): candidate is string => candidate !== null)
expect(mcporterBackup).toBeDefined()
const backedUp = JSON.parse(await fs.readFile(mcporterBackup!, "utf8")) as {
mcpServers: Record<string, { baseUrl?: string }>
}
expect(backedUp.mcpServers.stale?.baseUrl).toBe("https://example.invalid/mcp")
})
test("moves legacy flat Pi CE artifacts to a namespaced backup", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-legacy-artifacts-"))
const outputRoot = path.join(tempRoot, ".pi")
@@ -297,7 +358,9 @@ Run these research agents:
expect(await exists(path.join(outputRoot, "prompts", "reproduce-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "prompts", "report-bug.md"))).toBe(false)
expect(await exists(path.join(outputRoot, "skills", "ce-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "ce-repo-research-analyst", "SKILL.md"))).toBe(true)
// ce-repo-research-analyst is a Claude agent, so it installs to .pi/agents/<name>.md
// (not .pi/skills/<name>/SKILL.md) so nicobailon/pi-subagents can resolve it.
expect(await exists(path.join(outputRoot, "agents", "ce-repo-research-analyst.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "compound-engineering", "legacy-backup"))).toBe(true)
})
})