import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { isValidPluginId, type PluginRow } from "./plugin-contract"; export { PLUGIN_TEMPLATE, isValidPluginId, type PluginRow } from "./plugin-contract"; /** * The store behind the Plugins tab. * * There is no store to invent here: pi already discovers extensions from * `/extensions`, one level deep, and loads every `.ts`/`.js` file it * finds plus any subdirectory with an `index` or a `pi` manifest. So this * module reads and writes exactly that directory. A separate registry file * would have meant the page could show a plugin the runtime never loads, or * miss one it does — the two most confusing failures a page like this can have. * * Disabling renames `foo.ts` to `foo.ts.off`. pi's discovery filter is a suffix * test on `.ts`/`.js`, so a renamed file is invisible to it while staying * plainly visible to the user in a file listing — which beats a hidden "which * of these is on" list that only this app can read. */ const DISABLED_SUFFIX = ".off"; const EXTENSION_SUFFIXES = [".ts", ".js"] as const; /** Generous for a plugin, small enough that a paste accident cannot fill a disk. */ const MAX_SOURCE_BYTES = 256 * 1024; function expandHome(target: string): string { if (target === "~") return homedir(); if (target.startsWith("~/")) return path.join(homedir(), target.slice(2)); return target; } /** * The same directory `getAgentDir()` inside pi resolves. * * Deliberately re-derived from the environment rather than imported from the * SDK: this module is loaded by Next route handlers, and pulling the agent * package into that bundle for one `join(homedir(), ".pi", "agent")` costs far * more than restating the two-line rule. The rule is stable — it is the * package's public config contract — and a drift would show up immediately as * an empty plugin list. */ export function resolvePiAgentDir(): string { const configured = process.env.PI_CODING_AGENT_DIR?.trim(); if (configured) return expandHome(configured); return path.join(homedir(), ".pi", "agent"); } export function resolveUserPluginsDir(): string { return path.join(resolvePiAgentDir(), "extensions"); } const isExtensionFile = (name: string): boolean => EXTENSION_SUFFIXES.some((suffix) => name.endsWith(suffix)); const isDisabledFile = (name: string): boolean => name.endsWith(DISABLED_SUFFIX) && isExtensionFile(name.slice(0, -DISABLED_SUFFIX.length)); /** `foo.ts` and `foo.ts.off` are the same plugin in two states. */ function pluginIdForFile(name: string): string { const base = isDisabledFile(name) ? name.slice(0, -DISABLED_SUFFIX.length) : name; const suffix = EXTENSION_SUFFIXES.find((candidate) => base.endsWith(candidate)); return suffix ? base.slice(0, -suffix.length) : base; } async function describe(directory: string, file: string, readOnly: boolean): Promise { const target = path.join(directory, file); const info = await stat(target).catch(() => null); return { id: pluginIdForFile(file), file, path: target, enabled: !isDisabledFile(file), bytes: info?.size ?? 0, updated_at: (info?.mtime ?? new Date(0)).toISOString(), read_only: readOnly, }; } /** * Does pi load this subdirectory? Mirrors `resolveExtensionEntries`: a * package.json carrying a `pi` field, or an `index` file. */ function directoryLoads(target: string): boolean { const manifest = path.join(target, "package.json"); if (existsSync(manifest)) { try { const parsed: unknown = JSON.parse(readFileSync(manifest, "utf-8")); if (parsed && typeof parsed === "object" && "pi" in parsed) return true; } catch { // A malformed package.json is not a manifest; fall through to index. } } return EXTENSION_SUFFIXES.some((suffix) => existsSync(path.join(target, `index${suffix}`))); } export async function listUserPlugins(): Promise { const directory = resolveUserPluginsDir(); if (!existsSync(directory)) return []; const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); const rows: PluginRow[] = []; for (const entry of entries) { if (entry.isFile() || entry.isSymbolicLink()) { if (isExtensionFile(entry.name) || isDisabledFile(entry.name)) { rows.push(await describe(directory, entry.name, false)); } continue; } if (entry.isDirectory() && directoryLoads(path.join(directory, entry.name))) { rows.push(await describe(directory, entry.name, true)); } } return rows.sort((a, b) => a.id.localeCompare(b.id)); } async function findPlugin(id: string): Promise { return (await listUserPlugins()).find((plugin) => plugin.id === id) ?? null; } export async function readUserPlugin(id: string): Promise<{ plugin: PluginRow; source: string }> { const plugin = await findPlugin(id); if (!plugin) throw new Error(`Unknown plugin "${id}"`); if (plugin.read_only) throw new Error(`Plugin "${id}" is a directory, not a single file`); return { plugin, source: await readFile(plugin.path, "utf-8") }; } async function ensureDirectory(): Promise { const directory = resolveUserPluginsDir(); await mkdir(directory, { recursive: true }); await chmod(directory, 0o700).catch(() => undefined); return directory; } /** * Create or overwrite one plugin. * * The path is never built from the caller's string: an existing plugin is * matched by id against the directory listing and written back to the file it * was found at, and a new one is only created after the id passes the pattern, * which admits no separator, no dot, and no `..`. */ export async function writeUserPlugin(id: string, source: string): Promise { if (Buffer.byteLength(source, "utf-8") > MAX_SOURCE_BYTES) { throw new Error(`Plugin source exceeds ${Math.floor(MAX_SOURCE_BYTES / 1024)} KB`); } const existing = await findPlugin(id); if (existing?.read_only) throw new Error(`Plugin "${id}" is a directory, not a single file`); if (!existing && !isValidPluginId(id)) { throw new Error("A plugin name may use lowercase letters, digits, and hyphens"); } const directory = await ensureDirectory(); const file = existing?.file ?? `${id}.ts`; const target = path.join(directory, file); await writeFile(target, source, "utf-8"); await chmod(target, 0o600).catch(() => undefined); return describe(directory, file, false); } export async function setUserPluginEnabled(id: string, enabled: boolean): Promise { const plugin = await findPlugin(id); if (!plugin) throw new Error(`Unknown plugin "${id}"`); if (plugin.read_only) throw new Error(`Plugin "${id}" is a directory, not a single file`); if (plugin.enabled === enabled) return plugin; const directory = resolveUserPluginsDir(); const file = enabled ? plugin.file.slice(0, -DISABLED_SUFFIX.length) : `${plugin.file}${DISABLED_SUFFIX}`; await rename(plugin.path, path.join(directory, file)); return describe(directory, file, false); } export async function removeUserPlugin(id: string): Promise { const plugin = await findPlugin(id); if (!plugin) throw new Error(`Unknown plugin "${id}"`); if (plugin.read_only) throw new Error(`Plugin "${id}" is a directory, not a single file`); await unlink(plugin.path); } /** * A cheap change-detector for the runtime fingerprint. * * pi snapshots its extension inventory when a session starts, so an edited * plugin only reaches the model once the session is rebuilt. Folding this * string into the fingerprint is what makes "save, then send your next * message" true — the same contract connectors already have. Only enabled * files count: renaming one to `.off` changes the set and therefore the * string, which is exactly the restart we want. */ export function userPluginsRevisionSync(): string { const directory = resolveUserPluginsDir(); try { return readdirSync(directory) .filter((name) => isExtensionFile(name)) .sort() .map((name) => { const info = statSync(path.join(directory, name)); return `${name}:${info.mtimeMs}:${info.size}`; }) .join("|"); } catch { return "none"; } }