/** * Ext-Toggle Extension * * Provides `/ext` to list and toggle pi extensions installed under * `~/.pi/agent/extensions/` (the global auto-discovery dir). * * Toggling works by renaming the file (or symlink) between `name.ts` and * `name.ts.off`. Pi discovers `*.ts` only, so a `.ts.off` file is invisible * at load time. `ctx.reload()` is invoked after a toggle so the change takes * effect without a restart. * * Subdirectory-style extensions (`name/index.ts`) are listed as read-only * in v1 — toggling a directory cleanly is more work than the renaming trick * is worth. If you need to disable one, move the directory aside manually. */ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions"); type Entry = { name: string; // bare name without extension (e.g. "notify") fullPath: string; // absolute path on disk enabled: boolean; // .ts (true) or .ts.off (false) kind: "file" | "dir"; // flat .ts file or subdir/index.ts isSymlink: boolean; linkTarget?: string; // resolved target if symlink }; function listEntries(): Entry[] { if (!fs.existsSync(EXT_DIR)) return []; const out: Entry[] = []; for (const ent of fs.readdirSync(EXT_DIR, { withFileTypes: true })) { const full = path.join(EXT_DIR, ent.name); const isSymlink = ent.isSymbolicLink(); let stat: fs.Stats | undefined; try { stat = fs.statSync(full); } catch { continue; } let linkTarget: string | undefined; if (isSymlink) { try { linkTarget = fs.readlinkSync(full); } catch { // ignore } } if (stat.isDirectory()) { // Subdir extension: pi expects index.ts inside. const indexPath = path.join(full, "index.ts"); if (fs.existsSync(indexPath)) { out.push({ name: ent.name, fullPath: full, enabled: true, kind: "dir", isSymlink, linkTarget, }); } continue; } if (ent.name.endsWith(".ts")) { out.push({ name: ent.name.slice(0, -3), fullPath: full, enabled: true, kind: "file", isSymlink, linkTarget, }); } else if (ent.name.endsWith(".ts.off")) { out.push({ name: ent.name.slice(0, -7), fullPath: full, enabled: false, kind: "file", isSymlink, linkTarget, }); } } return out.sort((a, b) => a.name.localeCompare(b.name)); } function formatRow(e: Entry): string { const dot = e.enabled ? "●" : "○"; const kind = e.kind === "dir" ? " [dir]" : ""; const link = e.isSymlink ? " ↳ symlink" : ""; return `${dot} ${e.name}${kind}${link}`; } function toggleFile(e: Entry): { newPath: string; action: "enabled" | "disabled" } { if (e.kind !== "file") { throw new Error(`Cannot toggle directory-style extension "${e.name}" — move it aside manually.`); } const dir = path.dirname(e.fullPath); if (e.enabled) { const target = path.join(dir, `${e.name}.ts.off`); fs.renameSync(e.fullPath, target); return { newPath: target, action: "disabled" }; } else { const target = path.join(dir, `${e.name}.ts`); fs.renameSync(e.fullPath, target); return { newPath: target, action: "enabled" }; } } export default function extToggleExtension(pi: ExtensionAPI) { pi.registerCommand("ext", { description: "List and toggle pi extensions in ~/.pi/agent/extensions/", handler: async (_args, ctx) => { const entries = listEntries(); if (entries.length === 0) { ctx.ui.notify(`No extensions found in ${EXT_DIR}`, "info"); return; } const labels = entries.map(formatRow); const selected = await ctx.ui.select( `Extensions (${entries.filter((e) => e.enabled).length}/${entries.length} active)`, labels, ); if (!selected) return; const idx = labels.indexOf(selected); if (idx < 0) return; const entry = entries[idx]; if (entry.kind === "dir") { ctx.ui.notify( `"${entry.name}" is a directory-style extension. Move it aside manually if you need to disable it.`, "info", ); return; } const verb = entry.enabled ? "Disable" : "Enable"; const ok = await ctx.ui.confirm( `${verb} "${entry.name}"?`, `${entry.fullPath}\n\nWill be renamed to ${ entry.enabled ? entry.name + ".ts.off" : entry.name + ".ts" } and pi will reload.`, ); if (!ok) return; try { const { action } = toggleFile(entry); ctx.ui.notify(`${entry.name}: ${action}. Reloading…`, "info"); await ctx.reload(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); ctx.ui.notify(`Toggle failed: ${msg}`, "error"); } }, }); }