/** * /extensions – list, remove, disable, or enable installed extensions. * * Sources covered: * - settings.json packages[] and extensions[] (global + project) * - ~/.pi/agent/extensions/ (global auto-discovered files/dirs) * - .pi/extensions/ (project auto-discovered files/dirs) * * Sub-commands (supplied as the first argument): * list (default) – show all extensions, pick one to act on * remove – permanently remove an extension * disable – disable an extension (keeps files/settings) * enable – re-enable a previously disabled extension */ import { homedir } from "node:os"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, unlinkSync, rmSync, type Stats, } from "node:fs"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type ExtSource = "settings" | "fs-global" | "fs-project"; interface ExtEntry { index: number; label: string; value: string; source: ExtSource; scope: "global" | "project"; settingsPath?: string; settingsKey?: "packages" | "extensions"; rawValue?: unknown; fsPath?: string; disabled: boolean; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const GLOBAL_SETTINGS = join(homedir(), ".pi", "agent", "settings.json"); const GLOBAL_EXT_DIR = join(homedir(), ".pi", "agent", "extensions"); function readJsonSafe(path: string): Record | null { try { return JSON.parse(readFileSync(path, "utf-8")) as Record; } catch { return null; } } function writeJson(path: string, data: unknown): void { writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8"); } function rawLabel(raw: unknown): string { if (typeof raw === "string") return raw; if (raw && typeof raw === "object" && "source" in raw) { return String((raw as { source: string }).source); } return JSON.stringify(raw); } function settingsPaths(cwd: string): { global: string; project: string } { return { global: GLOBAL_SETTINGS, project: join(cwd, ".pi", "settings.json") }; } function isSettingsDisabled(raw: unknown): boolean { if (raw && typeof raw === "object" && "autoload" in raw) { return (raw as Record).autoload === false; } return false; } /** Extract the package source string from a raw settings value. */ function rawSource(raw: unknown): string { if (typeof raw === "string") return raw; if (raw && typeof raw === "object" && "source" in raw) { return String((raw as { source: string }).source); } return ""; } /** Resolve the pi CLI binary path. */ function piBin(): string { // process.argv[1] is the pi entry script when running inside pi if (process.argv[1] && process.argv[1].includes("pi")) { return process.argv[1]; } // Fallback: assume pi is on PATH const nodePrefix = process.execPath ? process.execPath.replace(/\/bin\/node$/, "") : ""; if (nodePrefix) { const candidate = join(nodePrefix, "bin", "pi"); if (existsSync(candidate)) return candidate; } return "pi"; } function findArrayIndex(arr: unknown[], target: unknown): number { const pos = arr.indexOf(target); if (pos !== -1) return pos; const targetStr = JSON.stringify(target); return arr.findIndex((v) => JSON.stringify(v) === targetStr); } // Gather everything ---------------------------------------------------------- function gatherAll(cwd: string): ExtEntry[] { const entries: ExtEntry[] = []; let idx = 0; const paths = settingsPaths(cwd); // --- settings.json entries --- for (const [scope, sp] of [ ["global", paths.global], ["project", paths.project], ] as const) { const json = readJsonSafe(sp); if (!json) continue; for (const key of ["packages", "extensions"] as const) { const arr = json[key]; if (!Array.isArray(arr)) continue; for (const raw of arr) { idx++; const disabled = isSettingsDisabled(raw); const prefix = disabled ? "[DISABLED] " : ""; entries.push({ index: idx, label: `${prefix}[${scope} settings / ${key}] ${rawLabel(raw)}`, value: `settings:${scope}:${key}:${idx - 1}`, source: "settings", scope, settingsPath: sp, settingsKey: key, rawValue: raw, disabled, }); } } } // --- auto-discovered filesystem extensions --- for (const [scope, dir] of [ ["global", GLOBAL_EXT_DIR], ["project", join(cwd, ".pi", "extensions")], ] as const) { if (!existsSync(dir)) continue; let children: string[]; try { children = readdirSync(dir); } catch { continue; } for (const name of children) { const full = join(dir, name); let st: Stats; try { st = statSync(full); } catch { continue; } const disabledName = name.endsWith(".disabled"); const baseName = disabledName ? name.slice(0, -".disabled".length) : name; // Accept .ts/.js files (including .ts.disabled) and dirs with index.ts const isExt = (st.isFile() && (baseName.endsWith(".ts") || baseName.endsWith(".js"))) || (st.isDirectory() && existsSync(join(full, "index.ts"))); if (!isExt) continue; idx++; const prefix = disabledName ? "[DISABLED] " : ""; entries.push({ index: idx, label: `${prefix}[${scope} fs] ${st.isDirectory() ? name + "/" : name}`, value: `fs:${scope}:${idx - 1}`, source: scope === "global" ? "fs-global" : "fs-project", scope, fsPath: full, disabled: disabledName, }); } } return entries; } // Lookup --------------------------------------------------------------------- function findEntry(entries: ExtEntry[], indexStr: string): ExtEntry | null { const n = Number.parseInt(indexStr, 10); if (!Number.isFinite(n)) return null; return entries.find((e) => e.index === n) ?? null; } // Remove --------------------------------------------------------------------- function removeSettingsEntry(entry: ExtEntry): string | null { if (!entry.settingsPath || !entry.settingsKey || entry.rawValue === undefined) { return "Cannot determine settings location for this entry."; } const json = readJsonSafe(entry.settingsPath); if (!json) return `Settings file not found: ${entry.settingsPath}`; const arr = json[entry.settingsKey]; if (!Array.isArray(arr)) return `Key "${entry.settingsKey}" is not an array.`; const pos = findArrayIndex(arr, entry.rawValue); if (pos === -1) return "Entry no longer exists in settings."; arr.splice(pos, 1); writeJson(entry.settingsPath, json); return null; } function removeFsEntry(entry: ExtEntry): string | null { if (!entry.fsPath) return "No filesystem path for this entry."; try { const st = statSync(entry.fsPath); if (st.isDirectory()) { rmSync(entry.fsPath, { recursive: true, force: true }); } else { unlinkSync(entry.fsPath); } return null; } catch (e: any) { return e?.message ?? "Unknown error removing file."; } } // Disable -------------------------------------------------------------------- function disableFsEntry(entry: ExtEntry): string | null { if (!entry.fsPath) return "No filesystem path for this entry."; if (entry.fsPath.endsWith(".disabled")) return "Already disabled."; const newPath = entry.fsPath + ".disabled"; try { renameSync(entry.fsPath, newPath); return null; } catch (e: any) { return e?.message ?? "Unknown error renaming file."; } } function disableSettingsEntry(entry: ExtEntry, cwd: string): string | null { // Project entry – set autoload: false in-place if (entry.scope === "project") { if (!entry.settingsPath || !entry.settingsKey || entry.rawValue === undefined) { return "Cannot determine settings location."; } const json = readJsonSafe(entry.settingsPath); if (!json) return `Settings file not found: ${entry.settingsPath}`; const arr = json[entry.settingsKey]; if (!Array.isArray(arr)) return `Key "${entry.settingsKey}" is not an array.`; const idx = findArrayIndex(arr, entry.rawValue); if (idx === -1) return "Entry no longer exists in settings."; const current = arr[idx]; if (typeof current === "object" && current !== null) { (current as Record).autoload = false; } else { arr[idx] = typeof current === "string" ? { source: current, autoload: false } : { ...(current as object), autoload: false }; } writeJson(entry.settingsPath, json); return null; } // Global entry – mirror into project settings with autoload: false const projectSettingsPath = join(cwd, ".pi", "settings.json"); const pj = readJsonSafe(projectSettingsPath) ?? {}; const key = entry.settingsKey ?? "packages"; if (!Array.isArray(pj[key])) { (pj as Record)[key] = []; } const parr = pj[key] as unknown[]; const sourceRef = typeof entry.rawValue === "string" ? entry.rawValue : (entry.rawValue as any)?.source ?? JSON.stringify(entry.rawValue); const mirror: Record = { source: sourceRef, autoload: false }; const already = parr.some( (v) => typeof v === "object" && v !== null && (v as any).source === sourceRef && (v as any).autoload === false, ); if (!already) { parr.push(mirror); } writeJson(projectSettingsPath, pj); return null; } // Enable --------------------------------------------------------------------- function enableFsEntry(entry: ExtEntry): string | null { if (!entry.fsPath) return "No filesystem path for this entry."; if (!entry.fsPath.endsWith(".disabled")) return "Not disabled."; const newPath = entry.fsPath.slice(0, -".disabled".length); try { renameSync(entry.fsPath, newPath); return null; } catch (e: any) { return e?.message ?? "Unknown error renaming file."; } } function enableSettingsEntry(entry: ExtEntry, cwd: string): string | null { // Project entry with autoload: false – either set autoload: true or remove // the autoload key (removing is cleaner). if (entry.scope === "project") { if (!entry.settingsPath || !entry.settingsKey || entry.rawValue === undefined) { return "Cannot determine settings location."; } const json = readJsonSafe(entry.settingsPath); if (!json) return `Settings file not found: ${entry.settingsPath}`; const arr = json[entry.settingsKey]; if (!Array.isArray(arr)) return `Key "${entry.settingsKey}" is not an array.`; const idx = findArrayIndex(arr, entry.rawValue); if (idx === -1) return "Entry no longer exists in settings."; const current = arr[idx]; if (typeof current === "object" && current !== null) { delete (current as Record).autoload; } // If it became a string (shouldn't happen when disabled, but be safe), leave it writeJson(entry.settingsPath, json); return null; } // Global entry – remove the mirror from project settings const projectSettingsPath = join(cwd, ".pi", "settings.json"); const pj = readJsonSafe(projectSettingsPath); if (!pj) return "No project settings file to clean up."; const key = entry.settingsKey ?? "packages"; const parr = pj[key]; if (!Array.isArray(parr)) return "Not found in project settings."; const sourceRef = typeof entry.rawValue === "string" ? entry.rawValue : (entry.rawValue as any)?.source ?? ""; const mirrorIdx = parr.findIndex( (v) => typeof v === "object" && v !== null && (v as any).source === sourceRef && (v as any).autoload === false, ); if (mirrorIdx === -1) return "Mirror entry not found in project settings."; parr.splice(mirrorIdx, 1); // Clean up empty arrays if (parr.length === 0) delete (pj as Record)[key]; writeJson(projectSettingsPath, pj); return null; } // --------------------------------------------------------------------------- // Actions UI (shared between list subcommand and direct subcommands) // --------------------------------------------------------------------------- async function pickAndAct( entries: ExtEntry[], actionLabel: string, ctx: Awaited> extends { handler: infer H } ? H extends (...args: any[]) => any ? Parameters[1] : never : never, pi: ExtensionAPI, ) { // Simplify: use any for ctx since we only need ui methods } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { pi.registerCommand("extensions", { description: "List, remove, disable, or enable installed extensions", getArgumentCompletions: (prefix) => { const subs = ["list", "remove", "disable", "enable"]; const filtered = subs.filter((s) => s.startsWith(prefix)); return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null; }, handler: async (args, ctx) => { const [sub, ...rest] = args.trim().split(/\s+/); const subCmd = sub || "list"; const entries = gatherAll(ctx.cwd); // ----- list (default) ------------------------------------------------ if (subCmd === "list") { if (entries.length === 0) { ctx.ui.notify("No extensions found.", "info"); return; } const items = entries.map((e) => `${e.index}: ${e.label}`); const selected = await ctx.ui.select( "Installed extensions — pick one to act on", items, ); if (!selected) return; const idx = Number.parseInt(selected.split(":")[0], 10); const entry = entries.find((e) => e.index === idx); if (!entry) return; // Build action list depending on state const actions: string[] = []; actions.push("Show path / details"); if (entry.disabled) { actions.push("Enable"); } else { actions.push("Disable"); } actions.push("Remove"); const action = await ctx.ui.select(`Action for: ${entry.label}`, actions); if (!action) return; // --- Show path --- if (action === "Show path / details") { const detail = entry.fsPath ?? rawLabel(entry.rawValue); ctx.ui.notify(detail, "info"); return; } // --- Enable --- if (action === "Enable") { await doEnable(entry, ctx); return; } // --- Disable --- if (action === "Disable") { await doDisable(entry, ctx); return; } // --- Remove --- if (action === "Remove") { await doRemove(entry, ctx); return; } return; } // ----- remove -------------------------------------------------------- if (subCmd === "remove") { const entry = await pickEntry(entries, rest[0], "REMOVE", ctx); if (!entry) return; await doRemove(entry, ctx); return; } // ----- disable ------------------------------------------------------- if (subCmd === "disable") { const entry = await pickEntry(entries, rest[0], "DISABLE", ctx); if (!entry) return; await doDisable(entry, ctx); return; } // ----- enable -------------------------------------------------------- if (subCmd === "enable") { const entry = await pickEntry(entries, rest[0], "ENABLE", ctx); if (!entry) return; await doEnable(entry, ctx); return; } ctx.ui.notify( `Unknown sub-command: ${subCmd}. Use list, remove, disable, or enable.`, "error", ); }, }); } // --------------------------------------------------------------------------- // Shared action helpers // --------------------------------------------------------------------------- async function pickEntry( entries: ExtEntry[], idxStr: string | undefined, verb: string, ctx: any, ): Promise { if (entries.length === 0) { ctx.ui.notify(`No extensions to ${verb.toLowerCase()}.`, "info"); return null; } if (idxStr) { const entry = findEntry(entries, idxStr); if (!entry) { ctx.ui.notify("Extension not found.", "error"); } return entry; } const selected = await ctx.ui.select( `Pick extension to ${verb}`, entries.map((e) => `${e.index}: ${e.label}`), ); if (!selected) return null; const idx = Number.parseInt(selected.split(":")[0], 10); return entries.find((e) => e.index === idx) ?? null; } async function doRemove(entry: ExtEntry, ctx: any): Promise { if (entry.source === "settings") { const src = rawSource(entry.rawValue); const label = rawLabel(entry.rawValue); // Only pi packages (npm:/git:/https://) can be fully cleaned; local paths // are just removed from settings. const isPkg = /^(npm:|git:|https?:\/\/|ssh:\/\/|git:\/\/)/.test(src); if (isPkg) { const ok = await ctx.ui.confirm( "Confirm full removal", `This will run "pi remove ${src}" to completely delete the package.\nPackage files will be removed from disk. Continue?`, ); if (!ok) return; const scopeFlag = entry.scope === "project" ? " -l" : ""; const cmd = `${piBin()} remove ${src}${scopeFlag}`; try { execSync(cmd, { encoding: "utf-8", stdio: "pipe" }); ctx.ui.notify(`Fully removed: ${src}. Run /reload to apply.`, "info"); } catch (e: any) { ctx.ui.notify(`pi remove failed: ${e?.stderr ?? e?.message ?? e}`, "error"); } } else { // Local path — just remove from settings const ok = await ctx.ui.confirm( "Confirm removal", `Remove "${label}" from ${entry.settingsPath!}?\n(The files on disk will NOT be deleted.)`, ); if (!ok) return; const err = removeSettingsEntry(entry); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Removed from settings. Run /reload to apply.", "info"); } } } else { // Filesystem extension const ok = await ctx.ui.confirm( "Confirm deletion", `Permanently delete ${entry.fsPath!}?`, ); if (!ok) return; const err = removeFsEntry(entry); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Deleted. Run /reload to apply.", "info"); } } } async function doDisable(entry: ExtEntry, ctx: any): Promise { if (entry.source === "settings") { const label = rawLabel(entry.rawValue); const msg = entry.scope === "project" ? `Set autoload: false for "${label}" in ${entry.settingsPath!}?` : `Add "${label}" with autoload: false to project settings?`; const ok = await ctx.ui.confirm("Confirm disable", msg); if (!ok) return; const err = disableSettingsEntry(entry, ctx.cwd); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Disabled (autoload: false). Run /reload to apply.", "info"); } } else { const ok = await ctx.ui.confirm( "Confirm disable", `Rename ${entry.fsPath!} → ${entry.fsPath!}.disabled?`, ); if (!ok) return; const err = disableFsEntry(entry); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Disabled (renamed to .disabled). Run /reload to apply.", "info"); } } } async function doEnable(entry: ExtEntry, ctx: any): Promise { if (entry.source === "settings") { const label = rawLabel(entry.rawValue); const msg = entry.scope === "project" ? `Remove autoload: false for "${label}" in ${entry.settingsPath!}?` : `Remove "${label}" with autoload: false from project settings?`; const ok = await ctx.ui.confirm("Confirm enable", msg); if (!ok) return; const err = enableSettingsEntry(entry, ctx.cwd); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Enabled. Run /reload to apply.", "info"); } } else { const ok = await ctx.ui.confirm( "Confirm enable", `Rename ${entry.fsPath!} → ${entry.fsPath!.replace(/\.disabled$/, "")}?`, ); if (!ok) return; const err = enableFsEntry(entry); if (err) { ctx.ui.notify(`Failed: ${err}`, "error"); } else { ctx.ui.notify("Enabled (renamed back). Run /reload to apply.", "info"); } } }