Merge pull request #181 from gvkhosla/feat/pi-target
feat: add first-class Pi target with MCPorter + subagent compatibility
This commit is contained in:
13
README.md
13
README.md
@@ -12,9 +12,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin**
|
|||||||
/plugin install compound-engineering
|
/plugin install compound-engineering
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenCode, Codex, Droid & Cursor (experimental) Install
|
## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install
|
||||||
|
|
||||||
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, and Cursor.
|
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# convert the compound-engineering plugin into OpenCode format
|
# convert the compound-engineering plugin into OpenCode format
|
||||||
@@ -28,6 +28,9 @@ bunx @every-env/compound-plugin install compound-engineering --to droid
|
|||||||
|
|
||||||
# convert to Cursor format
|
# convert to Cursor format
|
||||||
bunx @every-env/compound-plugin install compound-engineering --to cursor
|
bunx @every-env/compound-plugin install compound-engineering --to cursor
|
||||||
|
|
||||||
|
# convert to Pi format
|
||||||
|
bunx @every-env/compound-plugin install compound-engineering --to pi
|
||||||
```
|
```
|
||||||
|
|
||||||
Local dev:
|
Local dev:
|
||||||
@@ -40,12 +43,13 @@ OpenCode output is written to `~/.config/opencode` by default, with `opencode.js
|
|||||||
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
|
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
|
||||||
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands.
|
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands.
|
||||||
Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory.
|
Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory.
|
||||||
|
Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability.
|
||||||
|
|
||||||
All provider targets are experimental and may change as the formats evolve.
|
All provider targets are experimental and may change as the formats evolve.
|
||||||
|
|
||||||
## Sync Personal Config
|
## Sync Personal Config
|
||||||
|
|
||||||
Sync your personal Claude Code config (`~/.claude/`) to OpenCode or Codex:
|
Sync your personal Claude Code config (`~/.claude/`) to OpenCode, Codex, or Pi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync skills and MCP servers to OpenCode
|
# Sync skills and MCP servers to OpenCode
|
||||||
@@ -53,6 +57,9 @@ bunx @every-env/compound-plugin sync --target opencode
|
|||||||
|
|
||||||
# Sync to Codex
|
# Sync to Codex
|
||||||
bunx @every-env/compound-plugin sync --target codex
|
bunx @every-env/compound-plugin sync --target codex
|
||||||
|
|
||||||
|
# Sync to Pi
|
||||||
|
bunx @every-env/compound-plugin sync --target pi
|
||||||
```
|
```
|
||||||
|
|
||||||
This syncs:
|
This syncs:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor)",
|
description: "Target format (opencode | codex | droid | cursor | pi)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -34,6 +34,11 @@ export default defineCommand({
|
|||||||
alias: "codex-home",
|
alias: "codex-home",
|
||||||
description: "Write Codex output to this .codex root (ex: ~/.codex)",
|
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: {
|
also: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Comma-separated extra targets to generate (ex: codex)",
|
description: "Comma-separated extra targets to generate (ex: codex)",
|
||||||
@@ -73,6 +78,7 @@ export default defineCommand({
|
|||||||
const plugin = await loadClaudePlugin(String(args.source))
|
const plugin = await loadClaudePlugin(String(args.source))
|
||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const codexHome = resolveCodexRoot(args.codexHome)
|
const codexHome = resolveCodexRoot(args.codexHome)
|
||||||
|
const piHome = resolvePiRoot(args.piHome)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
||||||
@@ -80,7 +86,7 @@ export default defineCommand({
|
|||||||
permissions: permissions as PermissionMode,
|
permissions: permissions as PermissionMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome)
|
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome)
|
||||||
const bundle = target.convert(plugin, options)
|
const bundle = target.convert(plugin, options)
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
throw new Error(`Target ${targetName} did not return a 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.`)
|
console.warn(`Skipping ${extra}: no output returned.`)
|
||||||
continue
|
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)
|
await handler.write(extraRoot, extraBundle)
|
||||||
console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`)
|
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")
|
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 {
|
function expandHome(value: string): string {
|
||||||
if (value === "~") return os.homedir()
|
if (value === "~") return os.homedir()
|
||||||
if (value.startsWith(`~${path.sep}`)) {
|
if (value.startsWith(`~${path.sep}`)) {
|
||||||
@@ -153,8 +171,9 @@ function resolveOutputRoot(value: unknown): string {
|
|||||||
return process.cwd()
|
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 === "codex") return codexHome
|
||||||
|
if (targetName === "pi") return piHome
|
||||||
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
||||||
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
|
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
|
||||||
return outputRoot
|
return outputRoot
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor)",
|
description: "Target format (opencode | codex | droid | cursor | pi)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -36,6 +36,11 @@ export default defineCommand({
|
|||||||
alias: "codex-home",
|
alias: "codex-home",
|
||||||
description: "Write Codex output to this .codex root (ex: ~/.codex)",
|
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: {
|
also: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Comma-separated extra targets to generate (ex: codex)",
|
description: "Comma-separated extra targets to generate (ex: codex)",
|
||||||
@@ -77,6 +82,7 @@ export default defineCommand({
|
|||||||
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const codexHome = resolveCodexRoot(args.codexHome)
|
const codexHome = resolveCodexRoot(args.codexHome)
|
||||||
|
const piHome = resolvePiRoot(args.piHome)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
||||||
@@ -89,7 +95,7 @@ export default defineCommand({
|
|||||||
throw new Error(`Target ${targetName} did not return a bundle.`)
|
throw new Error(`Target ${targetName} did not return a bundle.`)
|
||||||
}
|
}
|
||||||
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
|
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)
|
await target.write(primaryOutputRoot, bundle)
|
||||||
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
|
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
|
||||||
|
|
||||||
@@ -110,7 +116,7 @@ export default defineCommand({
|
|||||||
console.warn(`Skipping ${extra}: no output returned.`)
|
console.warn(`Skipping ${extra}: no output returned.`)
|
||||||
continue
|
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)
|
await handler.write(extraRoot, extraBundle)
|
||||||
console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
|
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")
|
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 {
|
function expandHome(value: string): string {
|
||||||
if (value === "~") return os.homedir()
|
if (value === "~") return os.homedir()
|
||||||
if (value.startsWith(`~${path.sep}`)) {
|
if (value.startsWith(`~${path.sep}`)) {
|
||||||
@@ -182,8 +200,15 @@ function resolveOutputRoot(value: unknown): string {
|
|||||||
return path.join(os.homedir(), ".config", "opencode")
|
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 === "codex") return codexHome
|
||||||
|
if (targetName === "pi") return piHome
|
||||||
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
||||||
if (targetName === "cursor") {
|
if (targetName === "cursor") {
|
||||||
const base = hasExplicitOutput ? outputRoot : process.cwd()
|
const base = hasExplicitOutput ? outputRoot : process.cwd()
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import path from "path"
|
|||||||
import { loadClaudeHome } from "../parsers/claude-home"
|
import { loadClaudeHome } from "../parsers/claude-home"
|
||||||
import { syncToOpenCode } from "../sync/opencode"
|
import { syncToOpenCode } from "../sync/opencode"
|
||||||
import { syncToCodex } from "../sync/codex"
|
import { syncToCodex } from "../sync/codex"
|
||||||
|
import { syncToPi } from "../sync/pi"
|
||||||
|
|
||||||
function isValidTarget(value: string): value is "opencode" | "codex" {
|
function isValidTarget(value: string): value is "opencode" | "codex" | "pi" {
|
||||||
return value === "opencode" || value === "codex"
|
return value === "opencode" || value === "codex" || value === "pi"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if any MCP servers have env vars that might contain secrets */
|
/** 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({
|
export default defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "sync",
|
name: "sync",
|
||||||
description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex",
|
description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, or Pi",
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
target: {
|
target: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
description: "Target: opencode | codex",
|
description: "Target: opencode | codex | pi",
|
||||||
},
|
},
|
||||||
claudeHome: {
|
claudeHome: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -42,7 +43,7 @@ export default defineCommand({
|
|||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
if (!isValidTarget(args.target)) {
|
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"))
|
const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
|
||||||
@@ -63,12 +64,16 @@ export default defineCommand({
|
|||||||
const outputRoot =
|
const outputRoot =
|
||||||
args.target === "opencode"
|
args.target === "opencode"
|
||||||
? path.join(os.homedir(), ".config", "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") {
|
if (args.target === "opencode") {
|
||||||
await syncToOpenCode(config, outputRoot)
|
await syncToOpenCode(config, outputRoot)
|
||||||
} else {
|
} else if (args.target === "codex") {
|
||||||
await syncToCodex(config, outputRoot)
|
await syncToCodex(config, outputRoot)
|
||||||
|
} else {
|
||||||
|
await syncToPi(config, outputRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
|
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
|
||||||
|
|||||||
205
src/converters/claude-to-pi.ts
Normal file
205
src/converters/claude-to-pi.ts
Normal 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
88
src/sync/pi.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -3,14 +3,17 @@ import type { OpenCodeBundle } from "../types/opencode"
|
|||||||
import type { CodexBundle } from "../types/codex"
|
import type { CodexBundle } from "../types/codex"
|
||||||
import type { DroidBundle } from "../types/droid"
|
import type { DroidBundle } from "../types/droid"
|
||||||
import type { CursorBundle } from "../types/cursor"
|
import type { CursorBundle } from "../types/cursor"
|
||||||
|
import type { PiBundle } from "../types/pi"
|
||||||
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
|
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
|
||||||
import { convertClaudeToCodex } from "../converters/claude-to-codex"
|
import { convertClaudeToCodex } from "../converters/claude-to-codex"
|
||||||
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
||||||
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
|
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
|
||||||
|
import { convertClaudeToPi } from "../converters/claude-to-pi"
|
||||||
import { writeOpenCodeBundle } from "./opencode"
|
import { writeOpenCodeBundle } from "./opencode"
|
||||||
import { writeCodexBundle } from "./codex"
|
import { writeCodexBundle } from "./codex"
|
||||||
import { writeDroidBundle } from "./droid"
|
import { writeDroidBundle } from "./droid"
|
||||||
import { writeCursorBundle } from "./cursor"
|
import { writeCursorBundle } from "./cursor"
|
||||||
|
import { writePiBundle } from "./pi"
|
||||||
|
|
||||||
export type TargetHandler<TBundle = unknown> = {
|
export type TargetHandler<TBundle = unknown> = {
|
||||||
name: string
|
name: string
|
||||||
@@ -44,4 +47,10 @@ export const targets: Record<string, TargetHandler> = {
|
|||||||
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
|
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
|
||||||
write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
|
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
131
src/targets/pi.ts
Normal 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"
|
||||||
|
}
|
||||||
452
src/templates/pi/compat-extension.ts
Normal file
452
src/templates/pi/compat-extension.ts
Normal 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
40
src/types/pi.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -350,4 +350,80 @@ describe("CLI", () => {
|
|||||||
expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
|
expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
|
||||||
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
|
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("convert supports --pi-home for pi output", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-pi-home-"))
|
||||||
|
const piRoot = path.join(tempRoot, ".pi")
|
||||||
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||||
|
|
||||||
|
const proc = Bun.spawn([
|
||||||
|
"bun",
|
||||||
|
"run",
|
||||||
|
"src/index.ts",
|
||||||
|
"convert",
|
||||||
|
fixtureRoot,
|
||||||
|
"--to",
|
||||||
|
"pi",
|
||||||
|
"--pi-home",
|
||||||
|
piRoot,
|
||||||
|
], {
|
||||||
|
cwd: path.join(import.meta.dir, ".."),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
expect(await exists(path.join(piRoot, "compound-engineering", "mcporter.json"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("install supports --also with pi output", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-also-pi-"))
|
||||||
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||||
|
const piRoot = path.join(tempRoot, ".pi")
|
||||||
|
|
||||||
|
const proc = Bun.spawn([
|
||||||
|
"bun",
|
||||||
|
"run",
|
||||||
|
"src/index.ts",
|
||||||
|
"install",
|
||||||
|
fixtureRoot,
|
||||||
|
"--to",
|
||||||
|
"opencode",
|
||||||
|
"--also",
|
||||||
|
"pi",
|
||||||
|
"--pi-home",
|
||||||
|
piRoot,
|
||||||
|
"--output",
|
||||||
|
tempRoot,
|
||||||
|
], {
|
||||||
|
cwd: path.join(import.meta.dir, ".."),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
116
tests/pi-converter.test.ts
Normal file
116
tests/pi-converter.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { loadClaudePlugin } from "../src/parsers/claude"
|
||||||
|
import { convertClaudeToPi } from "../src/converters/claude-to-pi"
|
||||||
|
import { parseFrontmatter } from "../src/utils/frontmatter"
|
||||||
|
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 () => {
|
||||||
|
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||||
|
const bundle = convertClaudeToPi(plugin, {
|
||||||
|
agentMode: "subagent",
|
||||||
|
inferTemperature: false,
|
||||||
|
permissions: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prompts are normalized command names
|
||||||
|
expect(bundle.prompts.some((prompt) => prompt.name === "workflows-review")).toBe(true)
|
||||||
|
expect(bundle.prompts.some((prompt) => prompt.name === "plan_review")).toBe(true)
|
||||||
|
|
||||||
|
// Commands with disable-model-invocation are excluded
|
||||||
|
expect(bundle.prompts.some((prompt) => prompt.name === "deploy-docs")).toBe(false)
|
||||||
|
|
||||||
|
const workflowsReview = bundle.prompts.find((prompt) => prompt.name === "workflows-review")
|
||||||
|
expect(workflowsReview).toBeDefined()
|
||||||
|
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
|
||||||
|
expect(bundle.skillDirs.some((skill) => skill.name === "skill-one")).toBe(true)
|
||||||
|
expect(bundle.generatedSkills.some((skill) => skill.name === "repo-research-analyst")).toBe(true)
|
||||||
|
|
||||||
|
// 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"')
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("transforms Task calls, AskUserQuestion, slash commands, and todo tool references", () => {
|
||||||
|
const plugin: ClaudePlugin = {
|
||||||
|
root: "/tmp/plugin",
|
||||||
|
manifest: { name: "fixture", version: "1.0.0" },
|
||||||
|
agents: [],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "workflows:plan",
|
||||||
|
description: "Plan workflow",
|
||||||
|
body: [
|
||||||
|
"Run these in order:",
|
||||||
|
"- Task repo-research-analyst(feature_description)",
|
||||||
|
"- Task learnings-researcher(feature_description)",
|
||||||
|
"Use AskUserQuestion tool for follow-up.",
|
||||||
|
"Then use /workflows:work and /prompts:deepen-plan.",
|
||||||
|
"Track progress with TodoWrite and TodoRead.",
|
||||||
|
].join("\n"),
|
||||||
|
sourcePath: "/tmp/plugin/commands/plan.md",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
hooks: undefined,
|
||||||
|
mcpServers: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = convertClaudeToPi(plugin, {
|
||||||
|
agentMode: "subagent",
|
||||||
|
inferTemperature: false,
|
||||||
|
permissions: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bundle.prompts).toHaveLength(1)
|
||||||
|
const parsedPrompt = parseFrontmatter(bundle.prompts[0].content)
|
||||||
|
|
||||||
|
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")
|
||||||
|
expect(parsedPrompt.body).toContain("/workflows-work")
|
||||||
|
expect(parsedPrompt.body).toContain("/deepen-plan")
|
||||||
|
expect(parsedPrompt.body).toContain("file-based todos (todos/ + /skill:file-todos)")
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
99
tests/pi-writer.test.ts
Normal file
99
tests/pi-writer.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { writePiBundle } from "../src/targets/pi"
|
||||||
|
import type { PiBundle } from "../src/types/pi"
|
||||||
|
|
||||||
|
async function exists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writePiBundle", () => {
|
||||||
|
test("writes prompts, skills, extensions, mcporter config, and AGENTS.md block", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-writer-"))
|
||||||
|
const outputRoot = path.join(tempRoot, ".pi")
|
||||||
|
|
||||||
|
const bundle: PiBundle = {
|
||||||
|
prompts: [{ name: "workflows-plan", content: "Prompt content" }],
|
||||||
|
skillDirs: [
|
||||||
|
{
|
||||||
|
name: "skill-one",
|
||||||
|
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" }],
|
||||||
|
extensions: [{ name: "compound-engineering-compat.ts", content: "export default function () {}" }],
|
||||||
|
mcporterConfig: {
|
||||||
|
mcpServers: {
|
||||||
|
context7: { baseUrl: "https://mcp.context7.com/mcp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writePiBundle(outputRoot, bundle)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes to ~/.pi/agent style roots without nesting under .pi", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agent-root-"))
|
||||||
|
const outputRoot = path.join(tempRoot, "agent")
|
||||||
|
|
||||||
|
const bundle: PiBundle = {
|
||||||
|
prompts: [{ name: "workflows-work", content: "Prompt content" }],
|
||||||
|
skillDirs: [],
|
||||||
|
generatedSkills: [],
|
||||||
|
extensions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await writePiBundle(outputRoot, bundle)
|
||||||
|
|
||||||
|
expect(await exists(path.join(outputRoot, "prompts", "workflows-work.md"))).toBe(true)
|
||||||
|
expect(await exists(path.join(outputRoot, ".pi"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("backs up existing mcporter config before overwriting", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-backup-"))
|
||||||
|
const outputRoot = path.join(tempRoot, ".pi")
|
||||||
|
const configPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true })
|
||||||
|
await fs.writeFile(configPath, JSON.stringify({ previous: true }, null, 2))
|
||||||
|
|
||||||
|
const bundle: PiBundle = {
|
||||||
|
prompts: [],
|
||||||
|
skillDirs: [],
|
||||||
|
generatedSkills: [],
|
||||||
|
extensions: [],
|
||||||
|
mcporterConfig: {
|
||||||
|
mcpServers: {
|
||||||
|
linear: { baseUrl: "https://mcp.linear.app/mcp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writePiBundle(outputRoot, bundle)
|
||||||
|
|
||||||
|
const files = await fs.readdir(path.dirname(configPath))
|
||||||
|
const backupFileName = files.find((file) => file.startsWith("mcporter.json.bak."))
|
||||||
|
expect(backupFileName).toBeDefined()
|
||||||
|
|
||||||
|
const currentConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as { mcpServers: Record<string, unknown> }
|
||||||
|
expect(currentConfig.mcpServers.linear).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
68
tests/sync-pi.test.ts
Normal file
68
tests/sync-pi.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { syncToPi } from "../src/sync/pi"
|
||||||
|
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
|
||||||
|
|
||||||
|
describe("syncToPi", () => {
|
||||||
|
test("symlinks skills and writes MCPorter config", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-"))
|
||||||
|
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
||||||
|
|
||||||
|
const config: ClaudeHomeConfig = {
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "skill-one",
|
||||||
|
sourceDir: fixtureSkillDir,
|
||||||
|
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: {
|
||||||
|
context7: { url: "https://mcp.context7.com/mcp" },
|
||||||
|
local: { command: "echo", args: ["hello"] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncToPi(config, tempRoot)
|
||||||
|
|
||||||
|
const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
|
||||||
|
const linkedStat = await fs.lstat(linkedSkillPath)
|
||||||
|
expect(linkedStat.isSymbolicLink()).toBe(true)
|
||||||
|
|
||||||
|
const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
|
||||||
|
const mcporterConfig = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
|
||||||
|
mcpServers: Record<string, { baseUrl?: string; command?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mcporterConfig.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
|
||||||
|
expect(mcporterConfig.mcpServers.local?.command).toBe("echo")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges existing MCPorter config", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-merge-"))
|
||||||
|
const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
|
||||||
|
await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
mcporterPath,
|
||||||
|
JSON.stringify({ mcpServers: { existing: { baseUrl: "https://example.com/mcp" } } }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
const config: ClaudeHomeConfig = {
|
||||||
|
skills: [],
|
||||||
|
mcpServers: {
|
||||||
|
context7: { url: "https://mcp.context7.com/mcp" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncToPi(config, tempRoot)
|
||||||
|
|
||||||
|
const merged = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
|
||||||
|
mcpServers: Record<string, { baseUrl?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(merged.mcpServers.existing?.baseUrl).toBe("https://example.com/mcp")
|
||||||
|
expect(merged.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user