import { readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { stripFrontmatter, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AutocompleteProvider } from "@earendil-works/pi-tui"; import { completionPrefix, findMentions } from "./mentions.ts"; export interface Skill { name: string; description?: string; path: string; } function getSkills(pi: ExtensionAPI): Skill[] { return pi.getCommands() .filter((command) => command.source === "skill" && command.name.startsWith("skill:")) .map((command) => ({ name: command.name.slice(6), description: command.description, path: command.sourceInfo.path, })); } function escapeAttribute(value: string): string { return value.replaceAll("&", "&").replaceAll('"', """) .replaceAll("<", "<").replaceAll(">", ">"); } export async function expandSkills(text: string, skills: Skill[]) { const byName = new Map(skills.map((skill) => [skill.name, skill])); const names = [...new Set(findMentions(text).map((mention) => mention.name))]; const blocks: string[] = []; const errors: string[] = []; for (const name of names) { const skill = byName.get(name); if (!skill) continue; try { const body = stripFrontmatter(await readFile(skill.path, "utf8")).trim(); blocks.push(`\nReferences are relative to ${dirname(skill.path)}.\n\n${body}\n`); } catch (error) { errors.push(`Could not load $${name}: ${error instanceof Error ? error.message : String(error)}`); } } if (!blocks.length && !errors.length) return { text, errors }; // Keep the original prompt, including $name and its arguments, intact for // other input hooks (e.g. skill-specific mode guards) and the model. const context = [ ...blocks, ...errors.map((error) => `[pi-dollar-skills: ${error}]`), ].join("\n\n"); return { text: `${text}\n\n${context}`, errors }; } export function skillAutocomplete(current: AutocompleteProvider, skills: () => Skill[]): AutocompleteProvider { return { triggerCharacters: [...new Set([...(current.triggerCharacters ?? []), "$"])], async getSuggestions(lines, line, col, options) { const prefix = completionPrefix(lines, line, col); if (prefix === undefined) return current.getSuggestions(lines, line, col, options); const query = prefix.slice(1).toLowerCase(); const items = skills() .filter((skill) => skill.name.toLowerCase().startsWith(query)) .sort((a, b) => a.name.localeCompare(b.name)) .map((skill) => ({ value: `$${skill.name}`, label: `$${skill.name}`, description: skill.description })); return items.length ? { prefix, items } : null; }, applyCompletion(lines, line, col, item, prefix) { if (!prefix.startsWith("$") || !item.value.startsWith("$")) { return current.applyCompletion(lines, line, col, item, prefix); } const updated = [...lines]; const original = lines[line] ?? ""; const insertion = `${item.value} `; updated[line] = original.slice(0, col - prefix.length) + insertion + original.slice(col); return { lines: updated, cursorLine: line, cursorCol: col - prefix.length + insertion.length }; }, shouldTriggerFileCompletion(lines, line, col) { if (completionPrefix(lines, line, col) !== undefined) return false; return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; }, }; } export default function dollarSkills(pi: ExtensionAPI): void { pi.on("session_start", (_event, ctx) => { if (ctx.mode === "tui") { ctx.ui.addAutocompleteProvider((current) => skillAutocomplete(current, () => getSkills(pi))); } }); pi.on("input", async (event, ctx) => { // Don't recursively expand messages generated by extensions or turn shell // commands into prompts. Ordinary Pi slash-command dispatch is unchanged. if (event.source === "extension" || event.text.trimStart().startsWith("!")) { return { action: "continue" }; } const result = await expandSkills(event.text, getSkills(pi)); if (ctx.hasUI) { for (const error of result.errors) ctx.ui.notify(error, "error"); } if (result.text === event.text) return { action: "continue" }; // Transform in place: Pi retains images and steer/followUp delivery semantics. return { action: "transform", text: result.text }; }); }