feat: add first-class pi target with mcporter/subagent compatibility

This commit is contained in:
Geet Khosla
2026-02-12 23:07:34 +01:00
parent 87e98b24d3
commit e84fef7a56
14 changed files with 1358 additions and 18 deletions

View File

@@ -22,7 +22,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor)",
description: "Target format (opencode | codex | droid | cursor | pi)",
},
output: {
type: "string",
@@ -34,6 +34,11 @@ export default defineCommand({
alias: "codex-home",
description: "Write Codex output to this .codex root (ex: ~/.codex)",
},
piHome: {
type: "string",
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
@@ -73,6 +78,7 @@ export default defineCommand({
const plugin = await loadClaudePlugin(String(args.source))
const outputRoot = resolveOutputRoot(args.output)
const codexHome = resolveCodexRoot(args.codexHome)
const piHome = resolvePiRoot(args.piHome)
const options = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -80,7 +86,7 @@ export default defineCommand({
permissions: permissions as PermissionMode,
}
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome)
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome)
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
@@ -106,7 +112,7 @@ export default defineCommand({
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome)
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome)
await handler.write(extraRoot, extraBundle)
console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`)
}
@@ -137,6 +143,18 @@ function resolveCodexRoot(value: unknown): string {
return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
}
function resolvePiHome(value: unknown): string | null {
if (!value) return null
const raw = String(value).trim()
if (!raw) return null
const expanded = expandHome(raw)
return path.resolve(expanded)
}
function resolvePiRoot(value: unknown): string {
return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent")
}
function expandHome(value: string): string {
if (value === "~") return os.homedir()
if (value.startsWith(`~${path.sep}`)) {
@@ -153,8 +171,9 @@ function resolveOutputRoot(value: unknown): string {
return process.cwd()
}
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string {
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, piHome: string): string {
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
return outputRoot

View File

@@ -24,7 +24,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor)",
description: "Target format (opencode | codex | droid | cursor | pi)",
},
output: {
type: "string",
@@ -36,6 +36,11 @@ export default defineCommand({
alias: "codex-home",
description: "Write Codex output to this .codex root (ex: ~/.codex)",
},
piHome: {
type: "string",
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
@@ -77,6 +82,7 @@ export default defineCommand({
const plugin = await loadClaudePlugin(resolvedPlugin.path)
const outputRoot = resolveOutputRoot(args.output)
const codexHome = resolveCodexRoot(args.codexHome)
const piHome = resolvePiRoot(args.piHome)
const options = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -89,7 +95,7 @@ export default defineCommand({
throw new Error(`Target ${targetName} did not return a bundle.`)
}
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, hasExplicitOutput)
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput)
await target.write(primaryOutputRoot, bundle)
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
@@ -110,7 +116,7 @@ export default defineCommand({
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, hasExplicitOutput)
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput)
await handler.write(extraRoot, extraBundle)
console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
}
@@ -164,6 +170,18 @@ function resolveCodexRoot(value: unknown): string {
return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
}
function resolvePiHome(value: unknown): string | null {
if (!value) return null
const raw = String(value).trim()
if (!raw) return null
const expanded = expandHome(raw)
return path.resolve(expanded)
}
function resolvePiRoot(value: unknown): string {
return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent")
}
function expandHome(value: string): string {
if (value === "~") return os.homedir()
if (value.startsWith(`~${path.sep}`)) {
@@ -182,8 +200,15 @@ function resolveOutputRoot(value: unknown): string {
return path.join(os.homedir(), ".config", "opencode")
}
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, hasExplicitOutput: boolean): string {
function resolveTargetOutputRoot(
targetName: string,
outputRoot: string,
codexHome: string,
piHome: string,
hasExplicitOutput: boolean,
): string {
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") {
const base = hasExplicitOutput ? outputRoot : process.cwd()

View File

@@ -4,9 +4,10 @@ import path from "path"
import { loadClaudeHome } from "../parsers/claude-home"
import { syncToOpenCode } from "../sync/opencode"
import { syncToCodex } from "../sync/codex"
import { syncToPi } from "../sync/pi"
function isValidTarget(value: string): value is "opencode" | "codex" {
return value === "opencode" || value === "codex"
function isValidTarget(value: string): value is "opencode" | "codex" | "pi" {
return value === "opencode" || value === "codex" || value === "pi"
}
/** Check if any MCP servers have env vars that might contain secrets */
@@ -26,13 +27,13 @@ function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
export default defineCommand({
meta: {
name: "sync",
description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex",
description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, or Pi",
},
args: {
target: {
type: "string",
required: true,
description: "Target: opencode | codex",
description: "Target: opencode | codex | pi",
},
claudeHome: {
type: "string",
@@ -42,7 +43,7 @@ export default defineCommand({
},
async run({ args }) {
if (!isValidTarget(args.target)) {
throw new Error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`)
throw new Error(`Unknown target: ${args.target}. Use 'opencode', 'codex', or 'pi'.`)
}
const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
@@ -63,12 +64,16 @@ export default defineCommand({
const outputRoot =
args.target === "opencode"
? path.join(os.homedir(), ".config", "opencode")
: path.join(os.homedir(), ".codex")
: args.target === "codex"
? path.join(os.homedir(), ".codex")
: path.join(os.homedir(), ".pi", "agent")
if (args.target === "opencode") {
await syncToOpenCode(config, outputRoot)
} else {
} else if (args.target === "codex") {
await syncToCodex(config, outputRoot)
} else {
await syncToPi(config, outputRoot)
}
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)

View File

@@ -0,0 +1,205 @@
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import type {
PiBundle,
PiGeneratedSkill,
PiMcporterConfig,
PiMcporterServer,
} from "../types/pi"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import { PI_COMPAT_EXTENSION_SOURCE } from "../templates/pi/compat-extension"
export type ClaudeToPiOptions = ClaudeToOpenCodeOptions
const PI_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToPi(
plugin: ClaudePlugin,
_options: ClaudeToPiOptions,
): PiBundle {
const promptNames = new Set<string>()
const usedSkillNames = new Set<string>(plugin.skills.map((skill) => normalizeName(skill.name)))
const prompts = plugin.commands
.filter((command) => !command.disableModelInvocation)
.map((command) => convertPrompt(command, promptNames))
const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
const extensions = [
{
name: "compound-engineering-compat.ts",
content: PI_COMPAT_EXTENSION_SOURCE,
},
]
return {
prompts,
skillDirs: plugin.skills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
})),
generatedSkills,
extensions,
mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
}
}
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
const name = uniqueName(normalizeName(command.name), usedNames)
const frontmatter: Record<string, unknown> = {
description: command.description,
"argument-hint": command.argumentHint,
}
let body = transformContentForPi(command.body)
body = appendCompatibilityNoteIfNeeded(body)
return {
name,
content: formatFrontmatter(frontmatter, body.trim()),
}
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSkill {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Converted from Claude agent ${agent.name}`,
)
const frontmatter: Record<string, unknown> = {
name,
description,
}
const sections: string[] = []
if (agent.capabilities && agent.capabilities.length > 0) {
sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
}
const body = [
...sections,
agent.body.trim().length > 0
? agent.body.trim()
: `Instructions converted from the ${agent.name} agent.`,
].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> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
}
continue
}
if (server.url) {
mcpServers[name] = {
baseUrl: server.url,
headers: server.headers,
}
}
}
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
}

88
src/sync/pi.ts Normal file
View File

@@ -0,0 +1,88 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
type McporterServer = {
baseUrl?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
type McporterConfig = {
mcpServers: Record<string, McporterServer>
}
export async function syncToPi(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
await fs.mkdir(skillsDir, { recursive: true })
for (const skill of config.skills) {
if (!isValidSkillName(skill.name)) {
console.warn(`Skipping skill with invalid name: ${skill.name}`)
continue
}
const target = path.join(skillsDir, skill.name)
await forceSymlink(skill.sourceDir, target)
}
if (Object.keys(config.mcpServers).length > 0) {
await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
const existing = await readJsonSafe(mcporterPath)
const converted = convertMcpToMcporter(config.mcpServers)
const merged: McporterConfig = {
mcpServers: {
...(existing.mcpServers ?? {}),
...converted.mcpServers,
},
}
await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Partial<McporterConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<McporterConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
}
}
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): McporterConfig {
const mcpServers: Record<string, McporterServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
}
continue
}
if (server.url) {
mcpServers[name] = {
baseUrl: server.url,
headers: server.headers,
}
}
}
return { mcpServers }
}

View File

@@ -3,14 +3,17 @@ import type { OpenCodeBundle } from "../types/opencode"
import type { CodexBundle } from "../types/codex"
import type { DroidBundle } from "../types/droid"
import type { CursorBundle } from "../types/cursor"
import type { PiBundle } from "../types/pi"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writeDroidBundle } from "./droid"
import { writeCursorBundle } from "./cursor"
import { writePiBundle } from "./pi"
export type TargetHandler<TBundle = unknown> = {
name: string
@@ -44,4 +47,10 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
},
pi: {
name: "pi",
implemented: true,
convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
write: writePiBundle as TargetHandler<PiBundle>["write"],
},
}

131
src/targets/pi.ts Normal file
View File

@@ -0,0 +1,131 @@
import path from "path"
import {
backupFile,
copyDir,
ensureDir,
pathExists,
readText,
writeJson,
writeText,
} from "../utils/files"
import type { PiBundle } from "../types/pi"
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
const PI_AGENTS_BLOCK_END = "<!-- END COMPOUND PI TOOL MAP -->"
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
- 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)
`
export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promise<void> {
const paths = resolvePiPaths(outputRoot)
await ensureDir(paths.skillsDir)
await ensureDir(paths.promptsDir)
await ensureDir(paths.extensionsDir)
for (const prompt of bundle.prompts) {
await writeText(path.join(paths.promptsDir, `${prompt.name}.md`), prompt.content + "\n")
}
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
}
for (const extension of bundle.extensions) {
await writeText(path.join(paths.extensionsDir, extension.name), extension.content + "\n")
}
if (bundle.mcporterConfig) {
const backupPath = await backupFile(paths.mcporterConfigPath)
if (backupPath) {
console.log(`Backed up existing MCPorter config to ${backupPath}`)
}
await writeJson(paths.mcporterConfigPath, bundle.mcporterConfig)
}
await ensurePiAgentsBlock(paths.agentsPath)
}
function resolvePiPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// Global install root: ~/.pi/agent
if (base === "agent") {
return {
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
// Project local .pi directory
if (base === ".pi") {
return {
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
// Custom output root -> nest under .pi
return {
skillsDir: path.join(outputRoot, ".pi", "skills"),
promptsDir: path.join(outputRoot, ".pi", "prompts"),
extensionsDir: path.join(outputRoot, ".pi", "extensions"),
mcporterConfigPath: path.join(outputRoot, ".pi", "compound-engineering", "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
async function ensurePiAgentsBlock(filePath: string): Promise<void> {
const block = buildPiAgentsBlock()
if (!(await pathExists(filePath))) {
await writeText(filePath, block + "\n")
return
}
const existing = await readText(filePath)
const updated = upsertBlock(existing, block)
if (updated !== existing) {
await writeText(filePath, updated)
}
}
function buildPiAgentsBlock(): string {
return [PI_AGENTS_BLOCK_START, PI_AGENTS_BLOCK_BODY.trim(), PI_AGENTS_BLOCK_END].join("\n")
}
function upsertBlock(existing: string, block: string): string {
const startIndex = existing.indexOf(PI_AGENTS_BLOCK_START)
const endIndex = existing.indexOf(PI_AGENTS_BLOCK_END)
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
const before = existing.slice(0, startIndex).trimEnd()
const after = existing.slice(endIndex + PI_AGENTS_BLOCK_END.length).trimStart()
return [before, block, after].filter(Boolean).join("\n\n") + "\n"
}
if (existing.trim().length === 0) {
return block + "\n"
}
return existing.trimEnd() + "\n\n" + block + "\n"
}

View File

@@ -0,0 +1,452 @@
export const PI_COMPAT_EXTENSION_SOURCE = `import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
import { Type } from "@sinclair/typebox"
const MAX_BYTES = 50 * 1024
const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000
const MAX_PARALLEL_SUBAGENTS = 8
type SubagentTask = {
agent: string
task: string
cwd?: string
}
type SubagentResult = {
agent: string
task: string
cwd: string
exitCode: number
output: string
stderr: string
}
function truncate(value: string): string {
const input = value ?? ""
if (Buffer.byteLength(input, "utf8") <= MAX_BYTES) return input
const head = input.slice(0, MAX_BYTES)
return head + "\\n\\n[Output truncated to 50KB]"
}
function shellEscape(value: string): string {
return "'" + value.replace(/'/g, "'\\"'\\"'") + "'"
}
function normalizeName(value: string): string {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
}
function resolveBundledMcporterConfigPath(): string | undefined {
try {
const extensionDir = path.dirname(fileURLToPath(import.meta.url))
const candidates = [
path.join(extensionDir, "..", "pi-resources", "compound-engineering", "mcporter.json"),
path.join(extensionDir, "..", "compound-engineering", "mcporter.json"),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
} catch {
// noop: bundled path is best-effort fallback
}
return undefined
}
function resolveMcporterConfigPath(cwd: string, explicit?: string): string | undefined {
if (explicit && explicit.trim()) {
return path.resolve(explicit)
}
const projectPath = path.join(cwd, ".pi", "compound-engineering", "mcporter.json")
if (fs.existsSync(projectPath)) return projectPath
const globalPath = path.join(os.homedir(), ".pi", "agent", "compound-engineering", "mcporter.json")
if (fs.existsSync(globalPath)) return globalPath
return resolveBundledMcporterConfigPath()
}
function resolveTaskCwd(baseCwd: string, taskCwd?: string): string {
if (!taskCwd || !taskCwd.trim()) return baseCwd
const expanded = taskCwd === "~"
? os.homedir()
: taskCwd.startsWith("~" + path.sep)
? path.join(os.homedir(), taskCwd.slice(2))
: taskCwd
return path.resolve(baseCwd, expanded)
}
async function runSingleSubagent(
pi: ExtensionAPI,
baseCwd: string,
task: SubagentTask,
signal?: AbortSignal,
timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
): Promise<SubagentResult> {
const agent = normalizeName(task.agent)
if (!agent) {
throw new Error("Subagent task is missing a valid agent name")
}
const taskText = String(task.task ?? "").trim()
if (!taskText) {
throw new Error("Subagent task for " + agent + " is empty")
}
const cwd = resolveTaskCwd(baseCwd, task.cwd)
const prompt = "/skill:" + agent + " " + taskText
const script = "cd " + shellEscape(cwd) + " && pi --no-session -p " + shellEscape(prompt)
const result = await pi.exec("bash", ["-lc", script], { signal, timeout: timeoutMs })
return {
agent,
task: taskText,
cwd,
exitCode: result.code,
output: truncate(result.stdout || ""),
stderr: truncate(result.stderr || ""),
}
}
async function runParallelSubagents(
pi: ExtensionAPI,
baseCwd: string,
tasks: SubagentTask[],
signal?: AbortSignal,
timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
maxConcurrency = 4,
onProgress?: (completed: number, total: number) => void,
): Promise<SubagentResult[]> {
const safeConcurrency = Math.max(1, Math.min(maxConcurrency, MAX_PARALLEL_SUBAGENTS, tasks.length))
const results: SubagentResult[] = new Array(tasks.length)
let nextIndex = 0
let completed = 0
const workers = Array.from({ length: safeConcurrency }, async () => {
while (true) {
const current = nextIndex
nextIndex += 1
if (current >= tasks.length) return
results[current] = await runSingleSubagent(pi, baseCwd, tasks[current], signal, timeoutMs)
completed += 1
onProgress?.(completed, tasks.length)
}
})
await Promise.all(workers)
return results
}
function formatSubagentSummary(results: SubagentResult[]): string {
if (results.length === 0) return "No subagent work was executed."
const success = results.filter((result) => result.exitCode === 0).length
const failed = results.length - success
const header = failed === 0
? "Subagent run completed: " + success + "/" + results.length + " succeeded."
: "Subagent run completed: " + success + "/" + results.length + " succeeded, " + failed + " failed."
const lines = results.map((result) => {
const status = result.exitCode === 0 ? "ok" : "error"
const body = result.output || result.stderr || "(no output)"
const preview = body.split("\\n").slice(0, 6).join("\\n")
return "\\n[" + status + "] " + result.agent + "\\n" + preview
})
return header + lines.join("\\n")
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user_question",
label: "Ask User Question",
description: "Ask the user a question with optional choices.",
parameters: Type.Object({
question: Type.String({ description: "Question shown to the user" }),
options: Type.Optional(Type.Array(Type.String(), { description: "Selectable options" })),
allowCustom: Type.Optional(Type.Boolean({ default: true })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
isError: true,
content: [{ type: "text", text: "UI is unavailable in this mode." }],
details: {},
}
}
const options = params.options ?? []
const allowCustom = params.allowCustom ?? true
if (options.length === 0) {
const answer = await ctx.ui.input(params.question)
if (!answer) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
return {
content: [{ type: "text", text: "User answered: " + answer }],
details: { answer, mode: "input" },
}
}
const customLabel = "Other (type custom answer)"
const selectable = allowCustom ? [...options, customLabel] : options
const selected = await ctx.ui.select(params.question, selectable)
if (!selected) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
if (selected === customLabel) {
const custom = await ctx.ui.input("Your answer")
if (!custom) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
return {
content: [{ type: "text", text: "User answered: " + custom }],
details: { answer: custom, mode: "custom" },
}
}
return {
content: [{ type: "text", text: "User selected: " + selected }],
details: { answer: selected, mode: "select" },
}
},
})
const subagentTaskSchema = Type.Object({
agent: Type.String({ description: "Skill/agent name to invoke" }),
task: Type.String({ description: "Task instructions for that skill" }),
cwd: Type.Optional(Type.String({ description: "Optional working directory for this task" })),
})
pi.registerTool({
name: "subagent",
label: "Subagent",
description: "Run one or more 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" })),
cwd: Type.Optional(Type.String({ description: "Working directory for single mode" })),
tasks: Type.Optional(Type.Array(subagentTaskSchema, { description: "Parallel subagent tasks" })),
chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Sequential tasks; supports {previous} placeholder" })),
maxConcurrency: Type.Optional(Type.Number({ default: 4 })),
timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_SUBAGENT_TIMEOUT_MS })),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const hasSingle = Boolean(params.agent && params.task)
const hasTasks = Boolean(params.tasks && params.tasks.length > 0)
const hasChain = Boolean(params.chain && params.chain.length > 0)
const modeCount = Number(hasSingle) + Number(hasTasks) + Number(hasChain)
if (modeCount !== 1) {
return {
isError: true,
content: [{ type: "text", text: "Provide exactly one mode: single (agent+task), tasks, or chain." }],
details: {},
}
}
const timeoutMs = Number(params.timeoutMs || DEFAULT_SUBAGENT_TIMEOUT_MS)
try {
if (hasSingle) {
const result = await runSingleSubagent(
pi,
ctx.cwd,
{ agent: params.agent!, task: params.task!, cwd: params.cwd },
signal,
timeoutMs,
)
const body = formatSubagentSummary([result])
return {
isError: result.exitCode !== 0,
content: [{ type: "text", text: body }],
details: { mode: "single", results: [result] },
}
}
if (hasTasks) {
const tasks = params.tasks as SubagentTask[]
const maxConcurrency = Number(params.maxConcurrency || 4)
const results = await runParallelSubagents(
pi,
ctx.cwd,
tasks,
signal,
timeoutMs,
maxConcurrency,
(completed, total) => {
onUpdate?.({
content: [{ type: "text", text: "Subagent progress: " + completed + "/" + total }],
details: { mode: "parallel", completed, total },
})
},
)
const body = formatSubagentSummary(results)
const hasFailure = results.some((result) => result.exitCode !== 0)
return {
isError: hasFailure,
content: [{ type: "text", text: body }],
details: { mode: "parallel", results },
}
}
const chain = params.chain as SubagentTask[]
const results: SubagentResult[] = []
let previous = ""
for (const step of chain) {
const resolvedTask = step.task.replace(/\\{previous\\}/g, previous)
const result = await runSingleSubagent(
pi,
ctx.cwd,
{ agent: step.agent, task: resolvedTask, cwd: step.cwd },
signal,
timeoutMs,
)
results.push(result)
previous = result.output || result.stderr
onUpdate?.({
content: [{ type: "text", text: "Subagent chain progress: " + results.length + "/" + chain.length }],
details: { mode: "chain", completed: results.length, total: chain.length },
})
if (result.exitCode !== 0) break
}
const body = formatSubagentSummary(results)
const hasFailure = results.some((result) => result.exitCode !== 0)
return {
isError: hasFailure,
content: [{ type: "text", text: body }],
details: { mode: "chain", results },
}
} catch (error) {
return {
isError: true,
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
details: {},
}
}
},
})
pi.registerTool({
name: "mcporter_list",
label: "MCPorter List",
description: "List tools on an MCP server through MCPorter.",
parameters: Type.Object({
server: Type.String({ description: "Configured MCP server name" }),
allParameters: Type.Optional(Type.Boolean({ default: false })),
json: Type.Optional(Type.Boolean({ default: true })),
configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const args = ["list", params.server]
if (params.allParameters) args.push("--all-parameters")
if (params.json ?? true) args.push("--json")
const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
if (configPath) {
args.push("--config", configPath)
}
const result = await pi.exec("mcporter", args, { signal })
const output = truncate(result.stdout || result.stderr || "")
return {
isError: result.code !== 0,
content: [{ type: "text", text: output || "(no output)" }],
details: {
exitCode: result.code,
command: "mcporter " + args.join(" "),
configPath,
},
}
},
})
pi.registerTool({
name: "mcporter_call",
label: "MCPorter Call",
description: "Call a specific MCP tool through MCPorter.",
parameters: Type.Object({
call: Type.Optional(Type.String({ description: "Function-style call, e.g. linear.list_issues(limit: 5)" })),
server: Type.Optional(Type.String({ description: "Server name (if call is omitted)" })),
tool: Type.Optional(Type.String({ description: "Tool name (if call is omitted)" })),
args: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "JSON arguments object" })),
configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const args = ["call"]
if (params.call && params.call.trim()) {
args.push(params.call.trim())
} else {
if (!params.server || !params.tool) {
return {
isError: true,
content: [{ type: "text", text: "Provide either call, or server + tool." }],
details: {},
}
}
args.push(params.server + "." + params.tool)
if (params.args) {
args.push("--args", JSON.stringify(params.args))
}
}
args.push("--output", "json")
const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
if (configPath) {
args.push("--config", configPath)
}
const result = await pi.exec("mcporter", args, { signal })
const output = truncate(result.stdout || result.stderr || "")
return {
isError: result.code !== 0,
content: [{ type: "text", text: output || "(no output)" }],
details: {
exitCode: result.code,
command: "mcporter " + args.join(" "),
configPath,
},
}
},
})
}
`

40
src/types/pi.ts Normal file
View File

@@ -0,0 +1,40 @@
export type PiPrompt = {
name: string
content: string
}
export type PiSkillDir = {
name: string
sourceDir: string
}
export type PiGeneratedSkill = {
name: string
content: string
}
export type PiExtensionFile = {
name: string
content: string
}
export type PiMcporterServer = {
description?: string
baseUrl?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
export type PiMcporterConfig = {
mcpServers: Record<string, PiMcporterServer>
}
export type PiBundle = {
prompts: PiPrompt[]
skillDirs: PiSkillDir[]
generatedSkills: PiGeneratedSkill[]
extensions: PiExtensionFile[]
mcporterConfig?: PiMcporterConfig
}