pi-config: interactive picker for /config-add and /config-remove

Adds AssetPickerOverlay (arrow nav, type-to-filter, kind badge, description
snippet, active/global marks). /config-add and /config-remove open the picker
when called without a concrete name; 2-arg forms unchanged. Also filters
node_modules out of listAvailable.
This commit is contained in:
2026-10-13 21:32:33 +11:00
parent d278d18bb3
commit 29cdd13ba4
2 changed files with 467 additions and 94 deletions

View File

@@ -1,13 +1,17 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
import * as fs from "node:fs";
import * as path from "node:path";
import {
AssetPickerOverlay,
type AssetItem,
type PickerMode,
type ResourceKind,
} from "./picker.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ResourceKind = "skill" | "ext";
interface PackageFilter {
source: string;
extensions?: string[];
@@ -52,7 +56,7 @@ function listAvailable(kind: ResourceKind): string[] {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.filter((d) => d.isDirectory() && d.name !== "node_modules" && !d.name.startsWith("."))
.map((d) => d.name)
.sort();
}
@@ -99,6 +103,254 @@ function resolveSkillPath(name: string): string | undefined {
return `skills/${name}`;
}
// ---------------------------------------------------------------------------
// Asset metadata (descriptions + active/global state) for the picker
// ---------------------------------------------------------------------------
const AGENT_HOME = process.env.PI_CODING_AGENT_DIR ?? path.join(process.env.HOME!, ".pi", "agent");
const GLOBAL_SETTINGS_PATH = path.join(AGENT_HOME, "settings.json");
function nameFromEntry(entry: string, kind: ResourceKind): string | undefined {
const parts = entry.split("/");
const idx = parts.indexOf(kindToDir(kind));
if (idx === -1 || idx + 1 >= parts.length) return undefined;
return parts[idx + 1] || undefined;
}
function collectActiveNames(settings: PiSettings): Record<ResourceKind, Set<string>> {
const out: Record<ResourceKind, Set<string>> = { skill: new Set(), ext: new Set() };
for (const p of settings.packages ?? []) {
if (typeof p !== "object" || p === null) continue;
for (const e of p.skills ?? []) {
const n = nameFromEntry(e, "skill");
if (n) out.skill.add(n);
}
for (const e of p.extensions ?? []) {
const n = nameFromEntry(e, "ext");
if (n) out.ext.add(n);
}
}
return out;
}
function loadGlobalSettings(): PiSettings {
try {
if (fs.existsSync(GLOBAL_SETTINGS_PATH)) {
return JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf-8")) as PiSettings;
}
} catch {
// ignore
}
return {};
}
/** Read the `description:` from a skill's SKILL.md frontmatter (block scalars included). */
function readSkillDescription(name: string): string {
const file = path.join(AGENTS_DIR, "skills", name, "SKILL.md");
if (!fs.existsSync(file)) return "";
try {
const text = fs.readFileSync(file, "utf-8");
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) return "";
const lines = fm[1].split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
const m = lines[i].match(/^description:\s*(.*)$/);
if (!m) continue;
let val = m[1].trim();
if (/^[|>][-+]?$/.test(val)) {
const parts: string[] = [];
for (let j = i + 1; j < lines.length; j += 1) {
if (/^[A-Za-z0-9_-]+:/.test(lines[j])) break;
parts.push(lines[j].trim());
}
val = parts.join(" ").trim();
}
return val.replace(/^["']|["']$/g, "").trim();
}
} catch {
// ignore
}
return "";
}
/** Best-effort description for an extension: package.json `description`, else first comment line. */
function readExtDescription(name: string): string {
const dir = path.join(AGENTS_DIR, "extensions", name);
const pkgPath = path.join(dir, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { description?: unknown };
if (typeof pkg.description === "string" && pkg.description.trim()) {
return pkg.description.trim();
}
} catch {
// fall through
}
}
for (const fn of ["index.ts", "index.js"]) {
const f = path.join(dir, fn);
if (!fs.existsSync(f)) continue;
try {
for (const ln of fs.readFileSync(f, "utf-8").split(/\r?\n/).slice(0, 25)) {
const t = ln.trim();
if (t.startsWith("//")) {
const c = t.replace(/^\/\/\/?\s*/, "").trim();
if (c && !c.startsWith("!")) return c;
}
}
} catch {
// ignore
}
}
// Last resort: first prose line of README.md.
const readme = path.join(dir, "README.md");
if (fs.existsSync(readme)) {
try {
for (const ln of fs.readFileSync(readme, "utf-8").split(/\r?\n/)) {
const t = ln.trim();
if (!t || /^[#<!\[|>]/.test(t) || t.startsWith("---")) continue;
return t.replace(/[*_`]/g, "").trim();
}
} catch {
// ignore
}
}
return "";
}
/** Gather every asset in ~/.agents with its metadata, sorted useful-first. */
function gatherAssets(cwd: string): AssetItem[] {
const projectActive = collectActiveNames(loadSettings(cwd));
const globalActive = collectActiveNames(loadGlobalSettings());
const items: AssetItem[] = [];
for (const kind of ["skill", "ext"] as ResourceKind[]) {
for (const name of listAvailable(kind)) {
const entryPaths =
kind === "skill"
? [resolveSkillPath(name)].filter((p): p is string => Boolean(p))
: resolveExtensionPaths(name) ?? [];
items.push({
kind,
name,
description: kind === "skill" ? readSkillDescription(name) : readExtDescription(name),
entryPaths,
activeInProject: projectActive[kind].has(name),
activeGlobally: globalActive[kind].has(name),
});
}
}
items.sort((a, b) => {
if (a.activeInProject !== b.activeInProject) return a.activeInProject ? 1 : -1;
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return items;
}
// ---------------------------------------------------------------------------
// Shared add/remove + picker wiring
// ---------------------------------------------------------------------------
function addAsset(kind: ResourceKind, name: string, ctx: ExtensionCommandContext): void {
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[]) ?? [];
if (resolvedPaths.every((rp) => arr.includes(rp))) {
ctx.ui.notify(`${kind} '${name}' is already active.`, "info");
return;
}
for (const rp of resolvedPaths) {
if (!arr.includes(rp)) arr.push(rp);
}
pkg[key] = arr;
saveSettings(ctx.cwd, settings);
ctx.ui.notify(`✓ Added ${kind} '${name}' (${resolvedPaths.join(", ")}). Run /reload to apply.`, "success");
}
function removeAsset(kind: ResourceKind, name: string, ctx: ExtensionCommandContext): void {
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[]) ?? [];
const filtered = arr.filter((entry) => !entry.split("/").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");
}
async function openAssetPicker(
ctx: ExtensionCommandContext,
mode: PickerMode,
kindFilter?: ResourceKind,
initialQuery = "",
): Promise<void> {
let items = gatherAssets(ctx.cwd);
if (mode === "remove") items = items.filter((i) => i.activeInProject);
if (kindFilter) items = items.filter((i) => i.kind === kindFilter);
if (items.length === 0) {
ctx.ui.notify(
mode === "remove"
? "Nothing from ~/.agents is active in this project."
: "No skills or extensions found in ~/.agents.",
"info",
);
return;
}
const selected = await ctx.ui.custom<AssetItem | undefined>(
(_tui, theme, keybindings, done) =>
new AssetPickerOverlay(theme, keybindings, items, mode, initialQuery, done),
);
if (!selected) return;
if (mode === "add") addAsset(selected.kind, selected.name, ctx);
else removeAsset(selected.kind, selected.name, ctx);
}
function loadSettings(cwd: string): PiSettings {
const p = projectSettingsPath(cwd);
if (!fs.existsSync(p)) return {};
@@ -139,114 +391,46 @@ 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>",
"Add a skill or extension from ~/.agents. Usage: /config-add [skill|ext] [name] — omit the name for an interactive picker",
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 parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
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",
);
addAsset(kindStr as ResourceKind, name, ctx);
return;
}
let resolvedPaths: string[];
if (kind === "skill") {
const p = resolveSkillPath(name);
if (!p) {
ctx.ui.notify(`Could not resolve path for skill '${name}'`, "error");
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
await openAssetPicker(ctx, "add", parts[0] as ResourceKind);
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");
await openAssetPicker(ctx, "add", undefined, parts[0] ?? "");
},
});
// ── /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>",
"Remove a skill or extension from this project. Usage: /config-remove [skill|ext] [name] — omit the name for an interactive picker",
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 parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
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");
removeAsset(kindStr as ResourceKind, name, ctx);
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");
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
await openAssetPicker(ctx, "remove", parts[0] as ResourceKind);
return;
}
pkg[key] = filtered;
saveSettings(ctx.cwd, settings);
ctx.ui.notify(`✓ Removed ${kind} '${name}'. Run /reload to apply.`, "success");
await openAssetPicker(ctx, "remove", undefined, parts[0] ?? "");
},
});

View File

@@ -0,0 +1,189 @@
/**
* Asset picker overlay for the pi-config extension.
*
* A small modal list of skills/extensions from ~/.agents with arrow-key
* navigation, type-to-filter, a description snippet, and active/global marks.
* Used by /config-add and /config-remove when invoked without a concrete name.
*/
import type { Component } from "@mariozechner/pi-tui";
import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
export type ResourceKind = "skill" | "ext";
export type PickerMode = "add" | "remove";
export interface AssetItem {
kind: ResourceKind;
name: string;
description: string;
entryPaths: string[];
activeInProject: boolean;
activeGlobally: boolean;
}
export class AssetPickerOverlay implements Component {
private theme: Theme;
private keybindings: KeybindingsManager;
private items: AssetItem[];
private mode: PickerMode;
private done: (result: AssetItem | undefined) => void;
private query: string;
private selectedIndex = 0;
private readonly maxVisible = 9;
constructor(
theme: Theme,
keybindings: KeybindingsManager,
items: AssetItem[],
mode: PickerMode,
initialQuery: string,
done: (result: AssetItem | undefined) => void,
) {
this.theme = theme;
this.keybindings = keybindings;
this.items = items;
this.mode = mode;
this.query = initialQuery;
this.done = done;
}
private filtered(): AssetItem[] {
const q = this.query.trim().toLowerCase();
if (!q) return this.items;
return this.items.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.kind.toLowerCase().includes(q) ||
i.description.toLowerCase().includes(q),
);
}
private move(delta: number): void {
const n = this.filtered().length;
if (n === 0) return;
this.selectedIndex = (this.selectedIndex + delta + n) % n;
}
private confirm(): void {
const item = this.filtered()[this.selectedIndex];
if (item) this.done(item);
}
invalidate(): void {}
handleInput(data: string): void {
const kb = this.keybindings;
if (kb.matches(data, "tui.select.cancel")) {
this.done(undefined);
return;
}
if (kb.matches(data, "tui.select.up")) {
this.move(-1);
return;
}
if (kb.matches(data, "tui.select.down")) {
this.move(1);
return;
}
if (kb.matches(data, "tui.select.confirm")) {
this.confirm();
return;
}
if (data === "\u007f" || data === "\b") {
if (this.query.length > 0) {
this.query = this.query.slice(0, -1);
this.selectedIndex = 0;
}
return;
}
if (data.length === 1 && data >= " " && data <= "~") {
this.query += data;
this.selectedIndex = 0;
}
}
render(width: number): string[] {
const theme = this.theme;
const innerWidth = Math.max(40, Math.min(width - 2, 88));
const contentWidth = innerWidth - 2;
const border = (t: string) => theme.fg("accent", t);
const row = (text = "") => {
const clipped = truncateToWidth(text, contentWidth, "…", true);
return `${border("│")}${clipped}${border("│")}`;
};
const list = this.filtered();
if (this.selectedIndex >= list.length) {
this.selectedIndex = Math.max(0, list.length - 1);
}
const title = this.mode === "add" ? " Add to project " : " Remove from project ";
const lines: string[] = [];
lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
lines.push(row(theme.bold(title)));
lines.push(row(` Filter: ${this.query}${theme.fg("accent", "█")}`));
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
const hasActive = list.some((i) => i.activeInProject);
const hasGlobal = list.some((i) => i.activeGlobally && !i.activeInProject);
if (list.length === 0) {
lines.push(row(theme.fg("dim", " No matching skills or extensions")));
} else {
const start = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), list.length - this.maxVisible),
);
const end = Math.min(start + this.maxVisible, list.length);
for (let i = start; i < end; i += 1) {
const item = list[i];
const selected = i === this.selectedIndex;
const pointer = selected ? theme.fg("accent", "→ ") : " ";
const badge = theme.fg("dim", `[${item.kind}]`.padEnd(7));
const mark = item.activeInProject
? " " + theme.fg("accent", "✓")
: item.activeGlobally
? theme.fg("dim", " ·")
: "";
const name = selected ? theme.fg("accent", item.name) : item.name;
lines.push(row(`${pointer}${badge}${name}${mark}`));
}
if (start > 0 || end < list.length) {
lines.push(row(theme.fg("dim", ` ${this.selectedIndex + 1}/${list.length}`)));
}
}
// Details for the highlighted item.
const sel = list[this.selectedIndex];
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
if (sel) {
const descLines = sel.description
? wrapTextWithAnsi(sel.description, Math.max(10, contentWidth - 2)).slice(0, 3)
: [theme.fg("dim", "(no description)")];
for (const dl of descLines) lines.push(row(" " + dl));
const status = sel.activeInProject
? "active in this project"
: sel.activeGlobally
? "active globally"
: "available";
lines.push(row(theme.fg("dim", ` status: ${status}`)));
lines.push(row(theme.fg("dim", ` entry: ${sel.entryPaths.join(", ")}`)));
} else {
lines.push(row(theme.fg("dim", " —")));
}
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
const up = this.keybindings.getKeys("tui.select.up").join("/");
const down = this.keybindings.getKeys("tui.select.down").join("/");
const enter = this.keybindings.getKeys("tui.select.confirm").join("/");
const esc = this.keybindings.getKeys("tui.select.cancel").join("/");
const action = this.mode === "add" ? "add" : "remove";
const legend = `${hasActive ? " ✓ active here" : ""}${hasGlobal ? " · global" : ""}`;
lines.push(
row(theme.fg("dim", ` ${up}${down}: move • type to filter • ${enter}: ${action} • ${esc}: cancel${legend}`)),
);
lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
return lines;
}
}