Add pi-config

This commit is contained in:
2026-05-12 18:44:32 +10:00
parent c77a34f978
commit 03ec627a41

View File

@@ -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/<skills|extensions>/ */
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/<name>/
* 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/<name>/
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/<name>" */
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 <name> ──────────────────────────────
pi.registerCommand("config-add", {
description:
"Add a skill or extension from ~/.agents to this project. Usage: /config-add skill <name> | /config-add ext <name>",
handler: async (args, ctx: ExtensionCommandContext) => {
const parts = (args ?? "").trim().split(/\s+/);
if (parts.length < 2) {
ctx.ui.notify("Usage: /config-add skill <name> | /config-add ext <name>", "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 <name> ──────────────────────────
pi.registerCommand("config-remove", {
description:
"Remove a skill or extension from this project. Usage: /config-remove skill <name> | /config-remove ext <name>",
handler: async (args, ctx: ExtensionCommandContext) => {
const parts = (args ?? "").trim().split(/\s+/);
if (parts.length < 2) {
ctx.ui.notify("Usage: /config-remove skill <name> | /config-remove ext <name>", "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 "/<name>/" or "/<name>." or ends with "/<name>"
// 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 <skill|ext> <name> and /config-remove <skill|ext> <name>",
];
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");
},
});
}