/** * skill-toggle — interactive enable/disable of loaded skills. * * `/skills` opens a TUI checklist of every loaded skill. Toggle with space. * Unchecked skills are stripped from the system prompt before each turn, * saving context. They remain explicitly invokable via `/skill:` * (pi's built-in skill command continues to work because the skill is still * loaded — only its description is hidden from the agent). * * State is persisted to `~/.pi/agent/disabled-skills.json` (user scope) and * optionally overridden per-project by `.pi/disabled-skills.json`. * * Behavior: * - Effects take place on the very next message — no /reload needed. * - Skill list is discovered on the first `before_agent_start` of the * session. Before then, `/skills` shows a friendly hint. */ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { getSettingsListTheme } from "@earendil-works/pi-coding-agent"; import { Container, type SettingItem, SettingsList, truncateToWidth } from "@earendil-works/pi-tui"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; type SkillInfo = { name: string; description?: string }; const USER_STATE = join(homedir(), ".pi", "agent", "disabled-skills.json"); const PROJECT_STATE = ".pi/disabled-skills.json"; // resolved against cwd function loadDisabled(cwd: string): Set { const merged = new Set(); for (const path of [USER_STATE, join(cwd, PROJECT_STATE)]) { if (!existsSync(path)) continue; try { const parsed = JSON.parse(readFileSync(path, "utf8")); if (Array.isArray(parsed?.disabled)) for (const n of parsed.disabled) if (typeof n === "string") merged.add(n); } catch { // ignore — corrupt file shouldn't break the agent } } return merged; } function saveDisabled(disabled: Set, scope: "user" | "project", cwd: string) { const path = scope === "user" ? USER_STATE : join(cwd, PROJECT_STATE); mkdirSync(dirname(path), { recursive: true }); writeFileSync( path, JSON.stringify({ disabled: [...disabled].sort() }, null, 2), "utf8", ); } /** * Strip blocks whose X matches a disabled name. * Skill blocks live inside . * Each block looks like: * * SKILLNAME * * * */ function stripSkills(prompt: string, disabled: Set): string { if (disabled.size === 0) return prompt; let out = prompt; for (const name of disabled) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp( `\\s*\\s*${escaped}[\\s\\S]*?`, "g", ); out = out.replace(re, ""); } return out; } function parseSkillsFromPrompt(prompt: string): SkillInfo[] { const block = prompt.match(/([\s\S]*?)<\/available_skills>/); if (!block) return []; const skills: SkillInfo[] = []; const skillRe = /\s*([^<]+)<\/name>\s*([\s\S]*?)<\/description>/g; let m: RegExpExecArray | null; while ((m = skillRe.exec(block[1])) !== null) { skills.push({ name: m[1].trim(), description: m[2].trim() }); } return skills; } export default function (pi: ExtensionAPI) { let disabled: Set = new Set(); let knownSkills: SkillInfo[] = []; pi.on("session_start", (_event, ctx) => { disabled = loadDisabled(ctx.cwd ?? process.cwd()); // Populate skills snapshot immediately from the base prompt so /skills // works before the user sends any message. knownSkills = parseSkillsFromPrompt(ctx.getSystemPrompt()); }); pi.on("before_agent_start", (event) => { // Refresh with the structured source if available (more authoritative). if (event.systemPromptOptions?.skills) { knownSkills = event.systemPromptOptions.skills.map((s: any) => ({ name: s.name, description: s.description, })); } if (disabled.size === 0) return undefined; // Keep only disabled names that actually exist (avoid drift after rename). const live = new Set(knownSkills.map((s) => s.name)); const effective = new Set([...disabled].filter((n) => live.has(n))); if (effective.size === 0) return undefined; return { systemPrompt: stripSkills(event.systemPrompt, effective) }; }); pi.registerCommand("skills", { description: "Toggle which loaded skills are visible to the agent", handler: async (_args, ctx: ExtensionContext) => { if (knownSkills.length === 0) { ctx.ui.notify( "No skills found in the system prompt.", "info", ); return; } const cwd = ctx.cwd ?? process.cwd(); await ctx.ui.custom((tui, theme, _kb, done) => { const items: SettingItem[] = knownSkills.map((skill) => { const desc = skill.description?.replace(/\s+/g, " ").trim() ?? ""; const shortDesc = desc.length > 60 ? `${desc.slice(0, 57)}…` : desc; return { id: skill.name, label: shortDesc ? `${skill.name} — ${shortDesc}` : skill.name, currentValue: disabled.has(skill.name) ? "disabled" : "enabled", values: ["enabled", "disabled"], }; }); const container = new Container(); container.addChild( new (class { render(width: number) { const w = Math.max(20, width); return [ truncateToWidth( theme.fg("accent", theme.bold("Skill Visibility")), w, ), truncateToWidth( theme.fg( "muted", "Disabled skills are stripped from the system prompt and stay invokable via /skill:.", ), w, ), truncateToWidth(theme.fg("muted", `State: ${USER_STATE}`), w), "", ]; } invalidate() {} })(), ); const list = new SettingsList( items, Math.min(items.length + 2, 18), getSettingsListTheme(), (id, newValue) => { if (newValue === "disabled") disabled.add(id); else disabled.delete(id); saveDisabled(disabled, "user", cwd); }, () => done(undefined), ); container.addChild(list); return { render(width: number) { const rows = container.render(width); return rows.map((r) => truncateToWidth(r, width)); }, invalidate() { container.invalidate(); }, handleInput(data: string) { list.handleInput?.(data); tui.requestRender(); }, }; }); }, }); pi.registerCommand("skills:reset", { description: "Re-enable all skills (clear disabled-skills state)", handler: async (_args, ctx) => { disabled.clear(); saveDisabled(disabled, "user", ctx.cwd ?? process.cwd()); ctx.ui.notify("All skills re-enabled.", "info"); }, }); }