276 lines
8.8 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|