import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { truncateTail, formatSize, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent"; import { readFileSync, readdirSync, existsSync, statSync, mkdirSync, rmSync, writeFileSync, realpathSync, } from "node:fs"; import { homedir } from "node:os"; import { join, resolve, sep, dirname } from "node:path"; import { loadProfileManifest } from "../../../core/profiles/manifest.js"; import { stripMarkdownComments } from "../../../core/runtime/command-expansion.js"; // --------------------------------------------------------------------------- // claude-plugin-commands: surfaces Claude Code commands AND skills as pi slash // commands, with Claude's `plugin:command` namespacing AND full runtime // behavior: `$1/$ARGUMENTS` argument substitution and `!`cmd` / ```! shell // execution. // // Sources scanned (commands from `commands/`, skills from `skills/`): // - User: ~/.claude/commands, ~/.claude/skills // - Project: /.claude/commands|skills from cwd and every project in // the selected crouter profile, with cwd taking precedence // - Plugins: /commands|skills for each installed plugin // // Names: commands and skills are namespaced by their path under the source // root, joined with `:` (e.g. `git-smart:commit`, `skill:headless`, // `skill:grove:configuration`). Skills all carry a leading `skill:`; plugin // entries are additionally prefixed with the plugin name. // // Two pieces: // 1. Shim files (for discovery/autocomplete). A flat dir of namespaced .md // files, e.g. `git-smart:commit.md`, contributed via `resources_discover`. // Frontmatter is sanitized so pi's strict YAML parser doesn't drop them. // 2. An `input` hook (the real execution). Pi's built-in template expansion // substitutes $ARGUMENTS but never runs the `!`git ...`` context blocks // Claude commands depend on. So we intercept `/: args` // BEFORE expansion, read the ORIGINAL source file, substitute args, run // the embedded shell, and `transform` the input to the resolved text. // Shell-execution logic mirrors @juicesharp/rpiv-args. // --------------------------------------------------------------------------- const HOME = homedir(); const CLAUDE = join(HOME, ".claude"); const USER_COMMANDS = join(CLAUDE, "commands"); const USER_SKILLS = join(CLAUDE, "skills"); const INSTALLED = join(CLAUDE, "plugins", "installed_plugins.json"); const SHIM_DIR = join(HOME, ".pi", "agent", ".plugin-command-shims"); const PI_PROMPTS = join(HOME, ".pi", "agent", "prompts"); const PROJECT_PI_PROMPTS = ".pi/prompts"; const INCLUDE_ALL_SCOPES = true; const EXCLUDE_NAMES = new Set(["claude.md", "agents.md", "readme.md", "index.md"]); type Kind = "command" | "skill"; type IndexRecord = { file: string; kind: Kind; baseDir: string }; // name -> original source. Built alongside the shims; consulted by the input // hook to find the file to expand+execute (and how to frame it). const commandIndex = new Map(); type InstallRecord = { scope?: string; installPath?: string; projectPath?: string }; type InstalledPlugins = { plugins?: Record }; type Shim = { name: string; target: string; kind: Kind; baseDir: string }; // ---- discovery ------------------------------------------------------------ function collectCommands(root: string, prefix: string[], rel: string[], out: Shim[]): void { if (!existsSync(root)) return; let entries; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; } for (const e of entries) { const lower = e.name.toLowerCase(); if (e.isFile() && lower.endsWith(".md") && !EXCLUDE_NAMES.has(lower)) { const name = [...prefix, ...rel, e.name.slice(0, -3)].join(":"); out.push({ name, target: join(root, e.name), kind: "command", baseDir: root }); } else if (e.isDirectory() && !e.name.startsWith(".")) { collectCommands(join(root, e.name), prefix, [...rel, e.name], out); } } } // Skills are directories containing SKILL.md. A directory WITHOUT a SKILL.md is // a grouping dir we recurse into; once SKILL.md is found we stop (its subdirs // are scripts/references/assets). Name mirrors collectCommands: path segments // from the skills root, joined with `:`. function collectSkills(root: string, prefix: string[], rel: string[], out: Shim[]): void { if (!existsSync(root)) return; let entries; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (!e.isDirectory() || e.name.startsWith(".")) continue; const dir = join(root, e.name); const skillFile = join(dir, "SKILL.md"); if (existsSync(skillFile)) { const name = [...prefix, ...rel, e.name].join(":"); out.push({ name, target: skillFile, kind: "skill", baseDir: dir }); } else { collectSkills(dir, prefix, [...rel, e.name], out); } } } // Ancestor `.claude` dirs from one project pointer up to (and including) its // git root, skipping the user-level ~/.claude (handled separately). function ancestorClaudeDirs(pointer: string): string[] { const out: string[] = []; let cur = resolve(pointer); while (true) { const claude = join(cur, ".claude"); if (resolve(claude) !== resolve(CLAUDE)) { try { if (existsSync(claude) && statSync(claude).isDirectory()) out.push(claude); } catch { /* ignore */ } } if (existsSync(join(cur, ".git"))) break; // stop at git root const parent = dirname(cur); if (parent === cur) break; cur = parent; } return out; } function canonicalDir(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } } /** Every project `.claude` directory visible to this engine. The cwd pointer * and its ancestors come first; selected-profile project pointers widen the * search without letting a duplicate profile entry displace the local copy. */ export function projectClaudeDirs(cwd: string, profileProjectDirs: readonly string[]): string[] { const out: string[] = []; const seen = new Set(); for (const pointer of [cwd, ...profileProjectDirs]) { for (const claude of ancestorClaudeDirs(pointer)) { const key = canonicalDir(claude); if (seen.has(key)) continue; seen.add(key); out.push(claude); } } return out; } function selectedProfileProjects(): string[] { const profileId = process.env["CRTR_PROFILE_ID"] ?? ""; if (profileId === "") return []; try { return loadProfileManifest(profileId).manifest.projects; } catch { // A deleted or invalid selected profile must not hide cwd-local commands. return []; } } function cwdInProject(cwd: string, projectPath: string): boolean { const a = resolve(cwd); const b = resolve(projectPath); return a === b || a.startsWith(b + sep); } // Installed plugins as { name, installPath } pairs (scope-filtered). function pluginDirs(cwd: string): { name: string; installPath: string }[] { if (!existsSync(INSTALLED)) return []; let data: InstalledPlugins; try { data = JSON.parse(readFileSync(INSTALLED, "utf8")) as InstalledPlugins; } catch { return []; } const out: { name: string; installPath: string }[] = []; for (const [key, records] of Object.entries(data.plugins ?? {})) { const name = key.split("@")[0]; for (const rec of records ?? []) { if (!rec.installPath) continue; if (!INCLUDE_ALL_SCOPES && rec.scope === "project") { if (!rec.projectPath || !cwdInProject(cwd, rec.projectPath)) continue; } out.push({ name, installPath: rec.installPath }); } } return out; } function dirIf(p: string): boolean { try { return existsSync(p) && statSync(p).isDirectory(); } catch { return false; } } // Quote frontmatter scalar values pi's strict YAML would misread (leading // flow/indicator chars), so the shim still loads into autocomplete. function sanitizeFrontmatter(content: string): string { if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) return content; const nl = content.indexOf("\n"); const rest = content.slice(nl + 1); const end = rest.search(/^---\s*$/m); if (end === -1) return content; const block = rest.slice(0, end); const after = rest.slice(end); const RISKY = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "@", "`", "%", "?"]); const fixed = block .split("\n") .map((line) => { const m = line.match(/^([A-Za-z0-9_-]+):[ \t]+(.+?)\s*$/); if (!m) return line; const [, key, value] = m; if (value[0] === '"' || value[0] === "'" || !RISKY.has(value[0])) return line; const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `${key}: "${escaped}"`; }) .join("\n"); return content.slice(0, nl + 1) + fixed + after; } // Prompt stems pi already loads natively from ~/.pi/agent/prompts. A shim of // the same name collides with the native prompt (pi keeps the prompt, skips the // shim, and warns), so we never emit one. This happens because crtr exports its // slash commands to BOTH ~/.claude/commands and ~/.pi/agent/prompts; without // this, we'd re-shim the claude copy back into pi on top of the native prompt. function nativePiPromptStems(): Set { const out = new Set(); try { for (const e of readdirSync(PI_PROMPTS, { withFileTypes: true })) { if (e.isFile() && e.name.toLowerCase().endsWith(".md")) out.add(e.name.slice(0, -3)); } } catch { /* ignore */ } return out; } function buildShims(cwd: string): string { const shims: Shim[] = []; const nativeStems = nativePiPromptStems(); const projectDirs = projectClaudeDirs(cwd, selectedProfileProjects()); const plugins = pluginDirs(cwd); // Commands: user, project ancestors, then plugins. collectCommands(USER_COMMANDS, [], [], shims); for (const cd of projectDirs) collectCommands(join(cd, "commands"), [], [], shims); for (const p of plugins) { const cmds = join(p.installPath, "commands"); if (dirIf(cmds)) collectCommands(cmds, [p.name], [], shims); } // Skills: user, project ancestors, then plugins. All namespaced under // `skill:` so they group separately from commands in autocomplete. collectSkills(USER_SKILLS, ["skill"], [], shims); for (const cd of projectDirs) collectSkills(join(cd, "skills"), ["skill"], [], shims); for (const p of plugins) { const sk = join(p.installPath, "skills"); if (dirIf(sk)) collectSkills(sk, ["skill", p.name], [], shims); } try { rmSync(SHIM_DIR, { recursive: true, force: true }); } catch { /* ignore */ } mkdirSync(SHIM_DIR, { recursive: true }); commandIndex.clear(); const used = new Set(); for (const { name, target, kind, baseDir } of shims) { if (nativeStems.has(name)) continue; // pi serves this prompt natively — a shim would just collide let linkName = name; let n = 1; while (used.has(linkName)) linkName = `${name}~${n++}`; used.add(linkName); commandIndex.set(linkName, { file: target, kind, baseDir }); try { writeFileSync(join(SHIM_DIR, `${linkName}.md`), sanitizeFrontmatter(readFileSync(target, "utf8"))); } catch { /* ignore */ } } return SHIM_DIR; } // ---- argument substitution (mirrors pi's prompt-template engine) ----------- function parseCommandArgs(s: string): string[] { const args: string[] = []; let cur = ""; let q: string | null = null; for (const ch of s) { if (q) { if (ch === q) q = null; else cur += ch; } else if (ch === '"' || ch === "'") q = ch; else if (ch === " " || ch === "\t") { if (cur) { args.push(cur); cur = ""; } } else cur += ch; } if (cur) args.push(cur); return args; } function substituteArgs(content: string, args: string[]): string { const all = args.join(" "); return content.replace(/\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, (_, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { if (defaultNum) return args[parseInt(defaultNum, 10) - 1] || defaultValue; if (sliceStart) { const start = Math.max(0, parseInt(sliceStart, 10) - 1); return sliceLength ? args.slice(start, start + parseInt(sliceLength, 10)).join(" ") : args.slice(start).join(" "); } if (simple === "ARGUMENTS" || simple === "@") return all; return args[parseInt(simple, 10) - 1] ?? ""; }); } const TOKEN_REGEX = /\$(?:\{\d+:-[^}]*\}|\{@:\d+(?::\d+)?\}|\d+|ARGUMENTS|@)/; /** Pi loads user prompts before project prompts. Resolve only those native * Markdown templates here; resource-contributed templates have their own input * handler (such as the command shim path above). */ function nativePromptBody(name: string, cwd: string): string | undefined { if (name.includes("/") || name.includes("\\") || name.includes("\0")) return undefined; for (const dir of [PI_PROMPTS, join(cwd, PROJECT_PI_PROMPTS)]) { try { const raw = readFileSync(join(dir, `${name}.md`), "utf8"); return splitFrontmatter(raw).body; } catch { // A missing/unreadable prompt is Pi's normal fallthrough path. } } return undefined; } // Split leading `---` frontmatter from body WITHOUT parsing YAML. Claude's // loose frontmatter (e.g. `argument-hint: [a] [b]`) makes pi's parseFrontmatter // and stripFrontmatter both throw, so we delimiter-split ourselves. function splitFrontmatter(content: string): { fm: string; body: string } { if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) { return { fm: "", body: content }; } const nl = content.indexOf("\n"); const rest = content.slice(nl + 1); const end = rest.search(/^---\s*$/m); if (end === -1) return { fm: "", body: content }; const fm = rest.slice(0, end); const afterIdx = rest.indexOf("\n", end); const body = afterIdx === -1 ? "" : rest.slice(afterIdx + 1); return { fm, body }; } function shellTimeoutMs(fm: string): number { const m = fm.match(/^shell-timeout:[ \t]*([0-9]+(?:\.[0-9]+)?)\s*$/m); if (!m) return DEFAULT_SHELL_TIMEOUT_MS; const v = parseFloat(m[1]); if (!Number.isFinite(v) || v < 0) return DEFAULT_SHELL_TIMEOUT_MS; return v === 0 ? 0 : v * 1000; } // ---- shell execution (mirrors @juicesharp/rpiv-args) ----------------------- const SHELL_INLINE = /!`([^`\n]+)`/g; const SHELL_BLOCK = /```!\n([\s\S]*?)\n```/g; const DEFAULT_SHELL_TIMEOUT_MS = 120_000; function truncateForLLM(content: string): string { const t = truncateTail(content, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); let out = t.content; if (t.truncated) { const limit = t.truncatedBy === "lines" ? `${t.maxLines} lines` : formatSize(t.maxBytes); out += `\n[truncated: hit ${limit}]`; } return out; } async function runShell(cmd: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise { const [sh, flag] = process.platform === "win32" ? ["powershell.exe", "-Command"] : ["sh", "-c"]; const res: any = await pi.exec(sh, [flag, cmd], { cwd, timeout: timeoutMs }); if (res.killed) return `[Shell error: timed out after ${Math.max(1, Math.round(timeoutMs / 1000))}s]`; if (res.code !== 0) return `[Shell error: exit code ${res.code}]\n${truncateForLLM(res.stderr ?? "")}`; let combined = res.stdout ?? ""; if (res.stderr) { const sep = combined.length === 0 || combined.endsWith("\n") ? "" : "\n"; combined = `${combined}${sep}[stderr]\n${res.stderr}`; } return truncateForLLM(combined); } async function executeShell(body: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise { // blocks first (mask with sentinels), then inlines, then restore. const blockOut: string[] = []; let masked = ""; let last = 0; for (const m of body.matchAll(SHELL_BLOCK)) { const idx = m.index ?? 0; masked += body.slice(last, idx) + `\x00B${blockOut.length}\x00`; blockOut.push(await runShell(m[1] ?? "", pi, cwd, timeoutMs)); last = idx + m[0].length; } masked += body.slice(last); let inlined = ""; last = 0; for (const m of masked.matchAll(SHELL_INLINE)) { const idx = m.index ?? 0; inlined += masked.slice(last, idx) + (await runShell(m[1] ?? "", pi, cwd, timeoutMs)); last = idx + m[0].length; } inlined += masked.slice(last); return inlined.replace(/\x00B(\d+)\x00/g, (_, n) => blockOut[parseInt(n, 10)] ?? ""); } // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { pi.on("resources_discover", async (event: { cwd?: string }) => { const dir = buildShims(event?.cwd ?? process.cwd()); return { promptPaths: [dir] }; }); pi.on("input", async (event: any, ctx: any) => { if (event.source === "extension") return { action: "continue" }; const text: string = event.text ?? ""; if (!text.startsWith("/")) return { action: "continue" }; const sp = text.indexOf(" "); const name = sp === -1 ? text.slice(1) : text.slice(1, sp); const argsString = sp === -1 ? "" : text.slice(sp + 1).trim(); const rec = commandIndex.get(name); if (!rec) return { action: "continue" }; // not one of ours — let pi handle it let raw: string; try { raw = readFileSync(rec.file, "utf8"); } catch { return { action: "continue" }; } const { fm, body: rawBody } = splitFrontmatter(raw); const body = stripMarkdownComments(rawBody).trim(); const hadTokens = TOKEN_REGEX.test(body); let processed = hadTokens ? substituteArgs(body, parseCommandArgs(argsString)) : body; processed = await executeShell(processed, pi, ctx.cwd ?? process.cwd(), shellTimeoutMs(fm)); if (!hadTokens && argsString) processed = `${processed}\n\n${argsString}`; // Skills lean on relative paths (scripts/, references/, assets/). Pin the // base dir so the model can resolve them regardless of cwd. if (rec.kind === "skill") { processed = `[Skill: ${name}. Base directory: ${rec.baseDir}. Resolve relative paths (scripts, references, assets) referenced below against this directory.]\n\n${processed}`; } return { action: "transform", text: processed }; }); // Native pi prompt templates bypass the command index above. Intercept their // source Markdown before Pi expands it so author-only HTML comments never // become a user message. Extension commands execute before input hooks, and // resource-contributed templates are already handled by their own hooks. pi.on("input", async (event: any, ctx: any) => { if (event.source === "extension") return { action: "continue" }; const match = (event.text ?? "").match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); if (match === null || commandIndex.has(match[1]!)) return { action: "continue" }; const body = nativePromptBody(match[1]!, ctx.cwd ?? process.cwd()); if (body === undefined) return { action: "continue" }; return { action: "transform", text: substituteArgs(stripMarkdownComments(body).trim(), parseCommandArgs(match[2] ?? "")), }; }); }