Add pi-config
This commit is contained in:
311
extensions/pi-config/index.ts
Normal file
311
extensions/pi-config/index.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user