From 03ec627a4110d76f14ab53f111edfd95f3682c1b Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 12 May 2026 18:44:32 +1000 Subject: [PATCH] Add pi-config --- extensions/pi-config/index.ts | 311 ++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 extensions/pi-config/index.ts diff --git a/extensions/pi-config/index.ts b/extensions/pi-config/index.ts new file mode 100644 index 0000000..554ce27 --- /dev/null +++ b/extensions/pi-config/index.ts @@ -0,0 +1,311 @@ +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ResourceKind = "skill" | "ext"; + +interface PackageFilter { + source: string; + extensions?: string[]; + skills?: string[]; +} + +interface PiSettings { + packages?: (string | PackageFilter)[]; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const AGENTS_DIR = path.join(process.env.HOME!, ".agents"); + +function projectSettingsPath(cwd: string): string { + return path.join(cwd, ".pi", "settings.json"); +} + +/** Find the package filter entry that has source pointing to ~/.agents */ +function findAgentsPackage(settings: PiSettings): PackageFilter | undefined { + const pkgs = settings.packages ?? []; + return pkgs.find( + (p): p is PackageFilter => + typeof p === "object" && p.source === AGENTS_DIR, + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function kindToDir(kind: ResourceKind): string { + return kind === "skill" ? "skills" : "extensions"; +} + +/** List available names under ~/.agents// */ +function listAvailable(kind: ResourceKind): string[] { + const dir = path.join(AGENTS_DIR, kindToDir(kind)); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); +} + +/** + * Resolve the filter path for an extension under ~/.agents/extensions// + * by inspecting its structure (package.json pi manifest, or just index.ts). + * Returns undefined if the directory doesn't exist or has no loadable entry. + */ +function resolveExtensionPaths(name: string): string[] | undefined { + const extDir = path.join(AGENTS_DIR, "extensions", name); + if (!fs.existsSync(extDir) || !fs.statSync(extDir).isDirectory()) return undefined; + + const pkgJsonPath = path.join(extDir, "package.json"); + if (fs.existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + const piExts: string[] | undefined = pkg?.pi?.extensions; + if (Array.isArray(piExts) && piExts.length > 0) { + // Strip leading ./ and prefix with extensions// + return piExts.map((ep: string) => + path.posix.join("extensions", name, ep.replace(/^\.\//, "")), + ); + } + } catch { + // fall through + } + } + + // No pi manifest — look for index.ts or index.js + for (const fn of ["index.ts", "index.js"]) { + if (fs.existsSync(path.join(extDir, fn))) { + return [path.posix.join("extensions", name, fn)]; + } + } + + return undefined; +} + +/** Resolve the filter path for a skill: "skills/" */ +function resolveSkillPath(name: string): string | undefined { + const skillDir = path.join(AGENTS_DIR, "skills", name); + if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) return undefined; + return `skills/${name}`; +} + +function loadSettings(cwd: string): PiSettings { + const p = projectSettingsPath(cwd); + if (!fs.existsSync(p)) return {}; + try { + return JSON.parse(fs.readFileSync(p, "utf-8")) as PiSettings; + } catch { + return {}; + } +} + +function saveSettings(cwd: string, settings: PiSettings): void { + const dir = path.dirname(projectSettingsPath(cwd)); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(projectSettingsPath(cwd), JSON.stringify(settings, null, 2) + "\n"); +} + +/** Ensure the agents package entry exists, creating it if missing */ +function ensureAgentsPackage(settings: PiSettings): PackageFilter { + const existing = findAgentsPackage(settings); + if (existing) return existing; + + const entry: PackageFilter = { source: AGENTS_DIR }; + const pkgs = settings.packages ?? []; + pkgs.push(entry); + settings.packages = pkgs; + return entry; +} + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + +export default function (pi: ExtensionAPI) { + // ── /config-add skill|ext ────────────────────────────── + pi.registerCommand("config-add", { + description: + "Add a skill or extension from ~/.agents to this project. Usage: /config-add skill | /config-add ext ", + handler: async (args, ctx: ExtensionCommandContext) => { + const parts = (args ?? "").trim().split(/\s+/); + if (parts.length < 2) { + ctx.ui.notify("Usage: /config-add skill | /config-add ext ", "error"); + return; + } + const [kindStr, name] = parts as [string, string]; + if (kindStr !== "skill" && kindStr !== "ext") { + ctx.ui.notify("First arg must be 'skill' or 'ext'", "error"); + return; + } + const kind: ResourceKind = kindStr; + + // Check existence + resolve path(s) + const available = listAvailable(kind); + if (!available.includes(name)) { + ctx.ui.notify( + `${kind} '${name}' not found in ~/.agents/${kindToDir(kind)}/. Available: ${available.join(", ") || "(none)"}`, + "error", + ); + return; + } + + let resolvedPaths: string[]; + if (kind === "skill") { + const p = resolveSkillPath(name); + if (!p) { + ctx.ui.notify(`Could not resolve path for skill '${name}'`, "error"); + return; + } + resolvedPaths = [p]; + } else { + const paths = resolveExtensionPaths(name); + if (!paths || paths.length === 0) { + ctx.ui.notify( + `Could not resolve entry point for extension '${name}'. Check ~/.agents/extensions/${name}/ for structure.`, + "error", + ); + return; + } + resolvedPaths = paths; + } + + const settings = loadSettings(ctx.cwd); + const pkg = ensureAgentsPackage(settings); + const key = kind === "skill" ? "skills" : "extensions"; + const arr: string[] = (pkg[key] as string[]) ?? []; + + const alreadyPresent = resolvedPaths.every((rp) => arr.includes(rp)); + if (alreadyPresent) { + ctx.ui.notify(`${kind} '${name}' is already active.`, "info"); + return; + } + + // Add any that aren't already there + for (const rp of resolvedPaths) { + if (!arr.includes(rp)) arr.push(rp); + } + pkg[key] = arr; + saveSettings(ctx.cwd, settings); + const pathsStr = resolvedPaths.join(", "); + ctx.ui.notify(`✓ Added ${kind} '${name}' (${pathsStr}). Run /reload to apply.`, "success"); + }, + }); + + // ── /config-remove skill|ext ────────────────────────── + pi.registerCommand("config-remove", { + description: + "Remove a skill or extension from this project. Usage: /config-remove skill | /config-remove ext ", + handler: async (args, ctx: ExtensionCommandContext) => { + const parts = (args ?? "").trim().split(/\s+/); + if (parts.length < 2) { + ctx.ui.notify("Usage: /config-remove skill | /config-remove ext ", "error"); + return; + } + const [kindStr, name] = parts as [string, string]; + if (kindStr !== "skill" && kindStr !== "ext") { + ctx.ui.notify("First arg must be 'skill' or 'ext'", "error"); + return; + } + const kind: ResourceKind = kindStr; + + const settings = loadSettings(ctx.cwd); + const pkg = findAgentsPackage(settings); + if (!pkg) { + ctx.ui.notify("No ~/.agents package entry found in settings.", "error"); + return; + } + + const key = kind === "skill" ? "skills" : "extensions"; + const arr: string[] = (pkg[key] as string[]) ?? []; + + // Remove any entry that contains "//" or "/." or ends with "/" + // This handles: "skills/caveman", "extensions/pi-config/index.ts", etc. + const filtered = arr.filter((entry) => { + const parts2 = entry.split("/"); + return !parts2.includes(name); + }); + + if (filtered.length === arr.length) { + ctx.ui.notify(`${kind} '${name}' was not found in the active list.`, "info"); + return; + } + + pkg[key] = filtered; + saveSettings(ctx.cwd, settings); + ctx.ui.notify(`✓ Removed ${kind} '${name}'. Run /reload to apply.`, "success"); + }, + }); + + // ── /config-show ───────────────────────────────────────────── + pi.registerCommand("config-show", { + description: "Show which skills and extensions are active in this project", + handler: async (_args, ctx: ExtensionCommandContext) => { + const settings = loadSettings(ctx.cwd); + const pkg = findAgentsPackage(settings); + + const availableSkills = listAvailable("skill"); + const availableExts = listAvailable("ext"); + + const activeSkills: string[] = pkg?.skills ?? []; + const activeExts: string[] = pkg?.extensions ?? []; + + // Derive names from paths + function namesFromPaths(paths: string[], kind: ResourceKind): string[] { + const prefix = kindToDir(kind) + "/"; + return paths + .filter((p) => p.startsWith(prefix)) + .map((p) => p.slice(prefix.length).split("/")[0]) + .filter((n, i, a) => a.indexOf(n) === i) + .sort(); + } + + const includedSkills = namesFromPaths(activeSkills, "skill"); + const includedExts = namesFromPaths(activeExts, "ext"); + + const allSkills = availableSkills + .map((s) => (includedSkills.includes(s) ? ` ✓ ${s}` : ` ${s}`)) + .join("\n"); + const allExts = availableExts + .map((e) => (includedExts.includes(e) ? ` ✓ ${e}` : ` ${e}`)) + .join("\n"); + + const output = [ + "── Skills in ~/.agents/skills/ ──", + allSkills || " (none)", + "", + "── Extensions in ~/.agents/extensions/ ──", + allExts || " (none)", + "", + `Settings: ${projectSettingsPath(ctx.cwd)}`, + "", + "Use /config-add and /config-remove ", + ]; + + ctx.ui.notify(output.join("\n"), "info"); + }, + }); + + // ── /config-list ──────────────────────────────────────────── + pi.registerCommand("config-list", { + description: "List all available skills and extensions in ~/.agents", + handler: async (_args, ctx: ExtensionCommandContext) => { + const skills = listAvailable("skill"); + const exts = listAvailable("ext"); + const output = [ + `Skills (~/.agents/skills/): ${skills.length > 0 ? skills.join(", ") : "(none)"}`, + `Extensions (~/.agents/extensions/): ${exts.length > 0 ? exts.join(", ") : "(none)"}`, + ]; + ctx.ui.notify(output.join("\n"), "info"); + }, + }); +}