Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
275
extensions/toolkit-commands.ts
Normal file
275
extensions/toolkit-commands.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user