diff --git a/extensions/pi-config/index.ts b/extensions/pi-config/index.ts index 4726c62..c94bdb9 100644 --- a/extensions/pi-config/index.ts +++ b/extensions/pi-config/index.ts @@ -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> { + const out: Record> = { 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 { + 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( + (_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 ────────────────────────────── pi.registerCommand("config-add", { description: - "Add a skill or extension from ~/.agents to this project. Usage: /config-add skill | /config-add ext ", + "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 | /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"); + 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; } - 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"); + addAsset(kindStr as ResourceKind, name, ctx); return; } - - // Add any that aren't already there - for (const rp of resolvedPaths) { - if (!arr.includes(rp)) arr.push(rp); + if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) { + await openAssetPicker(ctx, "add", parts[0] as ResourceKind); + return; } - 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 ────────────────────────── pi.registerCommand("config-remove", { description: - "Remove a skill or extension from this project. Usage: /config-remove skill | /config-remove ext ", + "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 | /config-remove ext ", "error"); + 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; + } + removeAsset(kindStr as ResourceKind, name, ctx); return; } - const [kindStr, name] = parts as [string, string]; - if (kindStr !== "skill" && kindStr !== "ext") { - ctx.ui.notify("First arg must be 'skill' or 'ext'", "error"); + if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) { + await openAssetPicker(ctx, "remove", parts[0] as ResourceKind); 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"); + await openAssetPicker(ctx, "remove", undefined, parts[0] ?? ""); }, }); diff --git a/extensions/pi-config/picker.ts b/extensions/pi-config/picker.ts new file mode 100644 index 0000000..458fded --- /dev/null +++ b/extensions/pi-config/picker.ts @@ -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; + } +}