Files
pi-skill/extensions/toolkit-commands.ts
2026-05-25 16:41:08 +07:00

276 lines
8.8 KiB
TypeScript

// ABOUTME: Registers toolkit .md files from .pi/commands/ as dynamic Pi slash commands.
// ABOUTME: Supports inline (inject as user message) and fork (spawn subprocess) execution modes.
/**
* Toolkit Commands — Register toolkit command .md files as Pi slash commands
*
* Scans ~/.pi/commands/ (including symlinked toolkit/commands) for .md files.
* Parses frontmatter (description, argument-hint, allowed-tools, context) and registers
* each as a Pi slash command. When invoked:
* - Inline (no context: fork): injects body with $ARGUMENTS replaced as user message
* - Fork (context: fork): spawns a pi subprocess with the command body as system prompt
*
* Usage: loaded via packages in settings.json
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { join, dirname, resolve, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "child_process";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
import { DEFAULT_SUBAGENT_MODEL } from "./lib/defaults.ts";
import { TOOLKIT_WORKER_MODEL } from "./lib/toolkit-cli.ts";
// ── Types ────────────────────────────────────────
interface CommandDef {
name: string;
nameFromFrontmatter: boolean;
description: string;
argumentHint: string;
allowedTools: string[];
context: "fork" | "inline";
agent: string;
body: string;
file: string;
}
// Map toolkit tool names to Pi tool names
const TOOL_MAP: Record<string, string> = {
Bash: "bash",
bash: "bash",
Read: "read",
read: "read",
Write: "write",
write: "write",
Edit: "edit",
edit: "edit",
Grep: "grep",
grep: "grep",
Glob: "find",
glob: "find",
Find: "find",
find: "find",
Ls: "ls",
ls: "ls",
"file-system": "read,write,edit",
"AskUserQuestion": "ask_user",
Task: "dispatch_agent",
Skill: "skill",
Python: "bash",
python: "bash",
terminal: "bash",
"claude-code-sdk": "read,grep,bash",
// Commander MCP tools (Claude Code → Pi name mapping)
"mcp__commander__commander_task": "commander_task",
"mcp__commander__commander_session": "commander_session",
"mcp__commander__commander_workflow": "commander_workflow",
"mcp__commander__commander_spec": "commander_spec",
"mcp__commander__commander_jira": "commander_jira",
"mcp__commander__commander_mailbox": "commander_mailbox",
"mcp__commander__commander_orchestration": "commander_orchestration",
"mcp__commander__commander_dependency": "commander_dependency",
"mcp__commander__commander_agentmail": "commander_agentmail",
// Legacy tool names used in session-cleanup.md
"mcp__commander__commander_session_cleanup": "commander_session",
"mcp__commander__commander_terminal_sessions": "commander_session",
// Legacy pre-unification commander tool names (all map to unified commander_task)
"mcp__commander__commander_task_lifecycle": "commander_task",
"mcp__commander__commander_task_group": "commander_task",
"mcp__commander__commander_comment": "commander_task",
"mcp__commander__commander_log": "commander_task",
// Claude Code tool equivalents
"SlashCommand": "skill",
};
export function mapTools(toolList: string[]): string[] {
const result: string[] = [];
for (let t of toolList) {
// Handle Claude Code tool filter patterns like "Bash(python3:*)"
// Strip the filter suffix — Pi doesn't use it, just map the base tool name
const filterMatch = t.match(/^([A-Za-z_-]+)\(.*\)$/);
if (filterMatch) t = filterMatch[1];
const mapped = TOOL_MAP[t] ?? t.toLowerCase().replace(/-/g, "_");
for (const m of mapped.split(",")) {
const trimmed = m.trim();
if (trimmed && !result.includes(trimmed)) result.push(trimmed);
}
}
return result.length > 0 ? result : ["read", "grep", "find", "ls", "bash"];
}
// ── Parser ───────────────────────────────────────
function parseCommandFile(filePath: string): CommandDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
const desc = frontmatter.description;
if (!desc) return null;
const allowedToolsRaw = frontmatter["allowed-tools"];
let allowedTools: string[] = [];
if (allowedToolsRaw) {
try {
const parsed = JSON.parse(allowedToolsRaw.replace(/'/g, '"'));
allowedTools = Array.isArray(parsed) ? parsed : [parsed];
} catch {
allowedTools = allowedToolsRaw.split(",").map((s) => s.trim()).filter(Boolean);
}
}
const context = (frontmatter.context || "").toLowerCase() === "fork" ? "fork" : "inline";
const nameFromFrontmatter = !!frontmatter.name;
const name = frontmatter.name || filePath.split("/").pop()?.replace(/\.md$/, "") || "unknown";
return {
name,
nameFromFrontmatter,
description: desc,
argumentHint: frontmatter["argument-hint"] || "",
allowedTools,
context,
agent: frontmatter.agent || "general-purpose",
body: match[2].trim(),
file: filePath,
};
} catch {
return null;
}
}
export function scanCommandDirs(baseDir: string): CommandDef[] {
const commands: CommandDef[] = [];
const seen = new Set<string>();
function scan(d: string) {
if (!existsSync(d)) return;
for (const file of readdirSync(d, { withFileTypes: true })) {
const fullPath = join(d, file.name);
// Follow symlinks to directories (isDirectory() returns false for symlinks)
// Wrap statSync in try-catch to skip broken symlinks gracefully
let isDir = file.isDirectory();
if (!isDir && file.isSymbolicLink()) {
try { isDir = statSync(fullPath).isDirectory(); } catch { /* broken symlink */ }
}
if (isDir) {
scan(fullPath);
} else if (file.name.endsWith(".md")) {
const def = parseCommandFile(fullPath);
if (def) {
if (!def.nameFromFrontmatter) {
const relDir = relative(baseDir, d);
if (relDir) {
def.name = `${relDir.replace(/[\\/]/g, "-")}-${def.name}`;
}
}
const key = def.name.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
commands.push(def);
}
}
}
}
}
scan(baseDir);
return commands;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const extDir = dirname(fileURLToPath(import.meta.url));
const agentRoot = resolve(extDir, "..");
let commandsDir = join(agentRoot, ".pi", "commands");
if (!existsSync(commandsDir)) {
commandsDir = join(agentRoot, "commands");
}
const commands = scanCommandDirs(commandsDir);
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
});
for (const cmd of commands) {
const cmdName = cmd.name;
const desc = cmd.argumentHint
? `${cmd.description}${cmd.argumentHint}`
: cmd.description;
pi.registerCommand(cmdName, {
description: desc,
handler: async (args, _ctx) => {
const userArgs = (args ?? "").trim();
const body = cmd.body.replace(/\$ARGUMENTS/g, userArgs);
if (cmd.context === "fork") {
const tools = mapTools(cmd.allowedTools).join(",");
const model = TOOLKIT_WORKER_MODEL || DEFAULT_SUBAGENT_MODEL;
const tasksExtPath = join(dirname(fileURLToPath(import.meta.url)), "tasks.ts");
const proc = spawn("pi", [
"--mode", "json",
"-p",
"--no-extensions",
"-e", tasksExtPath,
"--model", model,
"--tools", tools,
"--thinking", "off",
"--append-system-prompt", body,
userArgs || "",
], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PI_SUBAGENT: "1" },
});
let output = "";
proc.stdout?.setEncoding("utf-8");
proc.stdout?.on("data", (chunk) => { output += chunk; });
proc.stderr?.on("data", () => {});
await new Promise<void>((res) => proc.on("close", () => res()));
const truncated = output.length > 8000
? output.slice(0, 8000) + "\n\n... [truncated]"
: output;
pi.sendMessage(
{
customType: "toolkit-command-result",
content: truncated || "(no output)",
display: true,
},
{ deliverAs: "followUp", triggerTurn: true },
);
} else {
const tools = mapTools(cmd.allowedTools);
if (tools.length > 0) {
pi.setActiveTools(tools);
}
pi.sendMessage(
{
customType: "toolkit-command",
content: body,
display: true,
},
{ deliverAs: "user", triggerTurn: true },
);
}
},
});
}
}