feat(pi): first-class support via pi-subagents + pi-ask-user (#651)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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-]+$/)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user