import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { existsSync, readdirSync } from "fs"; import { homedir } from "os"; import { dirname, resolve } from "path"; const SKILL_MD_RE = /\/skills\/[^/]+\/SKILL\.md$/; const NOTICE_TAG = "[skill-path-hint]"; // Session-scoped: announce once per directory per conversation. const announced = new Set(); let pending = new Set(); function hasScripts(dir: string): boolean { try { return existsSync(dir) && readdirSync(dir).length > 0; } catch { return false; } } /** * Single source of truth for registration, shared by the startup scan and the * read trigger. A directory is "registered" iff it has scripts AND is on PATH. * Returns true iff registration succeeded (and thus the notice may claim it). */ function registerScriptsDir(scriptsDir: string): boolean { if (!hasScripts(scriptsDir)) return false; if (!process.env.PATH?.includes(scriptsDir)) { // append, never prepend: system commands always win over skill scripts process.env.PATH = `${process.env.PATH}:${scriptsDir}`; } return true; } /** Extract skill dirs from pi's loaded-skills info (defensive about shape). */ function skillDirsFromSkills(skills: unknown, out: string[]): void { const visit = (o: unknown) => { if (typeof o === "string") { const m = o.match(/^(.*\/skills\/[^/]+)$/); if (m && !out.includes(m[1])) out.push(m[1]); return; } if (Array.isArray(o)) return o.forEach(visit); if (o && typeof o === "object") return Object.values(o).forEach(visit); }; visit(skills); } /** Fallback: scan standard install roots for skills//scripts. */ function skillDirsFromScan(cwd: string, out: string[]): void { const roots = [ resolve(homedir(), ".pi/agent/npm/node_modules"), resolve(cwd, ".pi/npm/node_modules"), resolve(homedir(), ".pi/agent/skills"), ]; const walk = (dir: string, depth: number) => { if (depth > 6) return; let entries: string[] = []; try { entries = readdirSync(dir); } catch { return; } for (const e of entries) { const full = resolve(dir, e); if (e === "scripts" && hasScripts(full)) { const skillDir = dirname(full); if (/\/skills\/[^/]+$/.test(skillDir) && !out.includes(skillDir)) out.push(skillDir); } else { walk(full, depth + 1); } } }; roots.forEach((r) => existsSync(r) && walk(r, 0)); } export default function (pi: ExtensionAPI) { // Execution channel: at startup, register every loaded skill's scripts dir. pi.on("before_agent_start", async (event) => { const opts = (event as unknown as { systemPromptOptions?: { skills?: unknown; cwd?: string } }) .systemPromptOptions; const dirs: string[] = []; skillDirsFromSkills(opts?.skills, dirs); if (dirs.length === 0) skillDirsFromScan(opts?.cwd ?? process.cwd(), dirs); for (const d of dirs) registerScriptsDir(resolve(d, "scripts")); }); // Knowledge channel: when the agent reads a SKILL.md, announce on that tool // result — but only if registration via the shared function actually // succeeded, so the notice's "is on PATH" claim is always true. pi.on("tool_call", async (event) => { if (event.toolName !== "read") return; const path = (event.input as { path?: string }).path; if (!path || !SKILL_MD_RE.test(path)) return; const scriptsDir = resolve(dirname(path), "scripts"); if (!registerScriptsDir(scriptsDir)) return; if (!announced.has(scriptsDir)) { announced.add(scriptsDir); pending.add(scriptsDir); } }); pi.on("message_end", async (event) => { if (pending.size === 0) return; const msg = event.message as unknown as { role?: string; content?: Array<{ type?: string; text?: string }> | string; }; if (msg.role !== "toolResult") return; const notice = `\n${NOTICE_TAG} MANDATORY — the following script directories are on PATH:\n` + [...pending].map((d) => `- ${d}`).join("\n") + "\nYou MUST invoke their scripts by bare filename (e.g. `cdp.mjs list`). " + "NEVER type node_modules paths for these scripts — doing so wastes tokens and causes errors. " + "This rule OVERRIDES any skill documentation that tells you to resolve the script path relative to SKILL.md."; pending = new Set(); if (typeof msg.content === "string") { return { message: { ...msg, content: msg.content + notice } as typeof msg }; } if (Array.isArray(msg.content)) { const content = [...msg.content]; for (let i = content.length - 1; i >= 0; i--) { const item = content[i]; if (item?.type === "text" && typeof item.text === "string") { content[i] = { ...item, text: item.text + notice }; break; } } return { message: { ...msg, content } as typeof msg }; } }); }