/** * ExtensionDiscovery: implicit, read-only discovery of selectable extensions * (ADR-0006), so profiles can reference extensions without any * registration step. * * Two implicit sources, both Pi-native and side-effect free: * - Configured user packages: each package's `package.json#pi.extensions` * declares its extension entry files; the package name (or an alias like * the `npm:` source string) is the profile-facing reference. * - Loose extension files: `/extensions/*.{ts,js}` and, for * trusted projects, `/.pi/extensions/*.{ts,js}`, referenced by * filename stem. * * Discovery never executes extension code and never installs anything: a * package contributes entries only for declared files that exist on disk. */ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { minimatch } from "minimatch"; import { isRecord } from "./json-file.ts"; import type { ConfiguredPackageRoot } from "./settings-generator.ts"; export interface DiscoveredPackage { /** Selectable package name: package.json "name", or the source minus its * npm:/git:/github: prefix (and any version spec) when unreadable. */ name: string; /** The source string exactly as configured in the user's settings * (e.g. "npm:pi-mcp-adapter"); accepted as a reference alias. */ source: string; /** Absolute install/local root of the package. */ root: string; /** Absolute paths of declared `pi.extensions` entries that exist on disk. */ entries: string[]; } export interface DiscoveredLocalExtension { /** Selectable ID: the filename stem ("conventions" for conventions.ts). */ id: string; entry: string; } export interface ImplicitExtensionDiscovery { packages: DiscoveredPackage[]; /** Loose files, project entries already merged over same-ID global ones. */ local: DiscoveredLocalExtension[]; /** Non-fatal notices (skipped packages, shadowed duplicates). */ warnings: string[]; } export class ExtensionError extends Error { constructor(message: string) { super(message); this.name = "ExtensionError"; } } export interface SelectedExtension { id: string; entry: string; origin?: "package" | "local" | "path"; } export interface SelectExtensionsResult { entries: SelectedExtension[]; unmatched: string[]; } function toPosix(filePath: string): string { return filePath.split(path.sep).join("/"); } async function exists(filePath: string): Promise { try { return (await stat(filePath)).isFile(); } catch { return false; } } function looksLikePath(reference: string): boolean { return ( reference.startsWith("/") || reference.startsWith("~/") || reference.startsWith("./") || reference.startsWith("../") || /\.(ts|js)$/.test(reference) ); } export class DiscoveredExtensions { readonly #packages: readonly DiscoveredPackage[]; readonly #local: readonly DiscoveredLocalExtension[]; readonly #warnings: string[]; readonly #entries: ReadonlyMap; constructor(packages: readonly DiscoveredPackage[], local: readonly DiscoveredLocalExtension[], warnings: string[]) { this.#packages = packages; this.#local = local; this.#warnings = [...warnings]; const entries = new Map(); for (const loc of local) { entries.set(loc.id, { id: loc.id, entry: loc.entry, origin: "local" }); } for (const pkg of packages) { for (const entryPath of pkg.entries) { const id = pkg.entries.length === 1 ? pkg.name : `${pkg.name}:${toPosix(path.relative(pkg.root, entryPath))}`; if (entries.has(id)) { this.#warnings.push( `extension id "${id}" is provided by both a local file and package "${pkg.source}"; the local file wins — reference the package as "${pkg.source}"`, ); continue; } entries.set(id, { id, entry: entryPath, packageName: pkg.name, origin: "package" }); } } this.#entries = entries; } get(id: string): { id: string; entry: string; origin: "package" | "local" } | undefined { return this.#entries.get(id); } list(): SelectedExtension[] { return [...this.#entries.values()].map((e) => ({ id: e.id, entry: e.entry, origin: e.origin })); } warnings(): string[] { return [...this.#warnings]; } selectableNames(): string[] { const names = new Set(this.#entries.keys()); for (const pkg of this.#packages) names.add(pkg.name); return [...names].sort(); } #packageByNameOrAlias(reference: string): DiscoveredPackage | undefined { return this.#packages.find((pkg) => pkg.name === reference || pkg.source === reference); } #packageEntries(pkg: DiscoveredPackage): SelectedExtension[] { return pkg.entries.map((entryPath) => { const id = pkg.entries.length === 1 ? pkg.name : `${pkg.name}:${toPosix(path.relative(pkg.root, entryPath))}`; const existing = this.#entries.get(id); if (existing !== undefined && existing.entry === entryPath) return existing; return { id, entry: entryPath, origin: "package" }; }); } #unknownMessage(reference: string, names: string[]): string { const lines = [ `unknown extension: "${reference}" — no installed package, extension file, or path matches it.`, ]; if (names.length > 0) { const shown = names.slice(0, 10); lines.push(`discovered: ${shown.join(", ")}${names.length > shown.length ? ` (+${names.length - shown.length} more)` : ""}`); } const suggestion = names.find( (name) => name.toLowerCase().includes(reference.toLowerCase()) || reference.toLowerCase().includes(name.toLowerCase()), ); if (suggestion !== undefined) { lines.push(`did you mean "${suggestion}"?`); } return lines.join("\n"); } async select(references: string[]): Promise { const byPath = new Map(); const unmatched: string[] = []; const names = this.selectableNames(); const add = (entry: SelectedExtension): void => { if (!byPath.has(entry.entry)) byPath.set(entry.entry, entry); }; for (const reference of references) { if (reference.includes("*") || reference.includes("?")) { let matched = 0; for (const name of names) { if (!minimatch(name, reference)) continue; matched += 1; const pkg = this.#packageByNameOrAlias(name); const entry = this.#entries.get(name); if (pkg !== undefined && pkg.name === name) { for (const pkgEntry of this.#packageEntries(pkg)) add(pkgEntry); } if (entry !== undefined) add(entry); } if (matched === 0) unmatched.push(reference); continue; } const entry = this.#entries.get(reference); if (entry !== undefined) { add(entry); continue; } const pkg = this.#packageByNameOrAlias(reference); if (pkg !== undefined) { const pkgEntries = this.#packageEntries(pkg); if (pkgEntries.length === 0) { throw new ExtensionError( `package "${reference}" declares no extension entries (its pi.extensions files are missing or shadowed by local files)`, ); } for (const pkgEntry of pkgEntries) add(pkgEntry); continue; } if (looksLikePath(reference)) { if (reference.startsWith("./") || reference.startsWith("../")) { throw new ExtensionError( `extension reference "${reference}" is a relative path; profiles take absolute paths (or ~/...) — catalogs live in both global and project scope, so a relative base would be ambiguous`, ); } const resolved = reference.startsWith("~/") ? path.join(process.env.HOME ?? "", reference.slice(1)) : path.resolve(reference); if (!(await exists(resolved))) { throw new ExtensionError(`extension path not found: ${resolved}`); } add({ id: resolved, entry: resolved, origin: "path" }); continue; } throw new ExtensionError(this.#unknownMessage(reference, names)); } return { entries: [...byPath.values()], unmatched }; } } const LOOSE_FILE_PATTERN = /\.(ts|js)$/; /** Derives a package name from its source string when package.json is * unreadable: strips the npm:/git:/github: prefix and any version spec * (scoped names keep their "@scope/" prefix). */ export function packageNameFromSource(source: string): string { let name = source.replace(/^(npm|git|github):/, ""); if (name.startsWith("@")) { // @scope/pkg[@version] — cut a version spec only after the scope path. const slash = name.indexOf("/"); const versionAt = slash === -1 ? -1 : name.indexOf("@", slash); if (versionAt !== -1) name = name.slice(0, versionAt); } else { const versionAt = name.indexOf("@"); if (versionAt !== -1) name = name.slice(0, versionAt); } return name; } /** Reads one installed package's declared extension entries. Packages * without a readable package.json or without `pi.extensions` contribute * nothing (skills-only packages are the common case). Declared entries * missing on disk are skipped — the spawned pi reports load errors itself. */ async function readPackageExtensions(pkg: ConfiguredPackageRoot): Promise { if (pkg.root === undefined) return undefined; let manifest: unknown; try { manifest = JSON.parse(await readFile(path.join(pkg.root, "package.json"), "utf8")); } catch { return undefined; // not installed yet or unreadable — nothing selectable } if (!isRecord(manifest)) return undefined; const pi = manifest.pi; const declared = isRecord(pi) && Array.isArray(pi.extensions) ? pi.extensions.filter((e): e is string => typeof e === "string") : []; if (declared.length === 0) return undefined; const entries: string[] = []; for (const rel of declared) { const entry = path.resolve(pkg.root, rel); if (await exists(entry)) entries.push(entry); } if (entries.length === 0) return undefined; const name = typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : packageNameFromSource(pkg.source); return { name, source: pkg.source, root: pkg.root, entries }; } /** Lists loose extension files in one directory; missing dir → empty. * A `.ts`/`.js` stem pair resolves to the `.ts` file (Pi's convention: * TypeScript sources are the canonical form) and is reported, not silent. */ async function scanLooseDir(dir: string, warnings: string[]): Promise { let files: string[]; try { files = (await readdir(dir)).filter((name) => LOOSE_FILE_PATTERN.test(name)); } catch { return []; } const byStem = new Map(); for (const name of files.sort()) { const stem = name.replace(LOOSE_FILE_PATTERN, ""); const full = path.join(dir, name); const existing = byStem.get(stem); if (existing !== undefined) { if (!existing.endsWith(".ts") && name.endsWith(".ts")) { warnings.push(`extension "${stem}" exists as both .ts and .js in ${dir}; the .ts file is used`); byStem.set(stem, full); } continue; } byStem.set(stem, full); } return [...byStem.entries()].map(([id, entry]) => ({ id, entry })); } export async function discoverImplicitExtensions(options: { agentDir: string; /** Configured user-scope packages with resolved roots (launcher discovery). */ packages: ConfiguredPackageRoot[]; /** Trusted project dir; untrusted projects are never scanned. */ projectDir?: string; }): Promise { const warnings: string[] = []; const packages: DiscoveredPackage[] = []; for (const pkg of options.packages) { const discovered = await readPackageExtensions(pkg); if (discovered !== undefined) packages.push(discovered); } const globalLocal = await scanLooseDir(path.join(options.agentDir, "extensions"), warnings); const merged = new Map(globalLocal.map((entry) => [entry.id, entry])); if (options.projectDir !== undefined) { // Project loose files override same-ID global ones, mirroring the // project-over-global catalog override convention. for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) { merged.set(entry.id, entry); } } return { packages, local: [...merged.values()], warnings }; } export async function discoverExtensions(options: { agentDir: string; packages: ConfiguredPackageRoot[]; projectDir?: string; }): Promise { const raw = await discoverImplicitExtensions(options); return new DiscoveredExtensions(raw.packages, raw.local, raw.warnings); }