import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, join, normalize, resolve } from "node:path"; const ENTRY_FILENAMES = ["index.ts", "index.js", "index.mts", "index.mjs"]; const ENTRY_EXTENSIONS = new Set([".ts", ".js", ".mts", ".mjs"]); const SELECTOR_PACKAGE_NAME = "pi-extension-selector"; export interface ExtensionRecord { id: string; label: string; path: string; description?: string; } export interface PackageSourceConfig { source: string; autoload?: boolean; extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[]; } export type PackageSource = string | PackageSourceConfig; export interface PackageExtensionRecord { id: string; label: string; source: string; description?: string; enabled: boolean; } export interface ExtensionProfile { enabled: string[]; } export interface SelectorState { version: 1; poolDir?: string; activeProfile: string; profiles: Record; managedPaths: string[]; packageDefaults: Record; } export interface PiSettings { extensions?: string[]; packages?: PackageSource[]; [key: string]: unknown; } export function createDefaultState(): SelectorState { return { version: 1, poolDir: undefined, activeProfile: "default", profiles: { default: { enabled: [] } }, managedPaths: [], packageDefaults: {}, }; } export function expandHome(path: string): string { if (path === "~") return homedir(); if (path.startsWith("~/")) return join(homedir(), path.slice(2)); return path; } export function resolvePoolDir(agentDir: string, configuredPath?: string): string { if (!configuredPath) return join(agentDir, "extension-pool"); const expanded = expandHome(configuredPath); return isAbsolute(expanded) ? normalize(expanded) : resolve(agentDir, expanded); } async function fileExists(path: string): Promise { try { await access(path, constants.F_OK); return true; } catch { return false; } } async function readPackageEntries(directory: string): Promise { const manifestPath = join(directory, "package.json"); if (!(await fileExists(manifestPath))) return []; try { const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { name?: string; description?: string; pi?: { extensions?: unknown }; }; const entries = manifest.pi?.extensions; if (!Array.isArray(entries)) return []; const packageId = basename(directory); const validEntries = entries.filter((entry): entry is string => typeof entry === "string"); return validEntries.map((entry, index) => { const absolutePath = resolve(directory, entry); const suffix = validEntries.length === 1 ? "" : `:${basename(entry, extname(entry)) || index + 1}`; return { id: `${packageId}${suffix}`, label: validEntries.length === 1 ? manifest.name ?? packageId : `${manifest.name ?? packageId} / ${basename(entry)}`, path: absolutePath, description: manifest.description, }; }); } catch { return []; } } export async function scanExtensionPool(poolDir: string): Promise { await mkdir(poolDir, { recursive: true }); const directoryEntries = await readdir(poolDir, { withFileTypes: true }); const records: ExtensionRecord[] = []; for (const entry of directoryEntries.sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name.startsWith(".")) continue; const absolutePath = join(poolDir, entry.name); if (entry.isFile() && ENTRY_EXTENSIONS.has(extname(entry.name))) { records.push({ id: basename(entry.name, extname(entry.name)), label: basename(entry.name, extname(entry.name)), path: absolutePath, }); continue; } if (!entry.isDirectory()) continue; const packageEntries = await readPackageEntries(absolutePath); if (packageEntries.length > 0) { records.push(...packageEntries); continue; } for (const filename of ENTRY_FILENAMES) { const candidate = join(absolutePath, filename); if (await fileExists(candidate)) { records.push({ id: entry.name, label: entry.name, path: candidate }); break; } } } const duplicateIds = records .map((record) => record.id) .filter((id, index, ids) => ids.indexOf(id) !== index); if (duplicateIds.length > 0) { throw new Error(`Duplicate extension ids in pool: ${[...new Set(duplicateIds)].join(", ")}`); } return records; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function isPackageSource(value: unknown): value is PackageSource { if (typeof value === "string") return value.length > 0; return !!value && typeof value === "object" && typeof (value as PackageSourceConfig).source === "string"; } export async function readSelectorState(path: string): Promise { if (!(await fileExists(path))) return createDefaultState(); const raw = JSON.parse(await readFile(path, "utf8")) as Partial; if (raw.version !== 1) throw new Error(`Unsupported selector state version: ${String(raw.version)}`); const profiles: Record = {}; if (raw.profiles && typeof raw.profiles === "object") { for (const [name, profile] of Object.entries(raw.profiles)) { if (!profile || typeof profile !== "object") continue; profiles[name] = { enabled: stringArray((profile as Partial).enabled) }; } } const activeProfile = typeof raw.activeProfile === "string" && raw.activeProfile.trim() ? raw.activeProfile.trim() : "default"; if (!profiles[activeProfile]) profiles[activeProfile] = { enabled: [] }; const packageDefaults: Record = {}; if (raw.packageDefaults && typeof raw.packageDefaults === "object") { for (const [source, entry] of Object.entries(raw.packageDefaults)) { if (isPackageSource(entry)) packageDefaults[source] = entry; } } return { version: 1, poolDir: typeof raw.poolDir === "string" ? raw.poolDir : undefined, activeProfile, profiles, managedPaths: stringArray(raw.managedPaths), packageDefaults, }; } export async function readPiSettings(path: string): Promise { if (!(await fileExists(path))) return {}; const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`${path} must contain a JSON object`); } return parsed as PiSettings; } function normalizeSettingsPath(path: string, agentDir: string): string | undefined { if (!path || path.startsWith("!") || path.startsWith("+") || path.startsWith("-")) return undefined; if (/^(npm:|git:|https?:|ssh:|git\+ssh:)/.test(path)) return undefined; const expanded = expandHome(path); return normalize(isAbsolute(expanded) ? expanded : resolve(agentDir, expanded)); } export function mergeManagedExtensions( settings: PiSettings, agentDir: string, managedPaths: Iterable, enabledPaths: Iterable, ): PiSettings { const managed = new Set([...managedPaths].map((path) => normalize(resolve(path)))); const existing = stringArray(settings.extensions); const preserved = existing.filter((entry) => { const normalized = normalizeSettingsPath(entry, agentDir); return !normalized || !managed.has(normalized); }); const nextExtensions = [...preserved]; for (const path of enabledPaths) { const absolutePath = normalize(resolve(path)); if (!nextExtensions.includes(absolutePath)) nextExtensions.push(absolutePath); } return { ...settings, extensions: nextExtensions }; } function getPackageSource(entry: PackageSource): string { return typeof entry === "string" ? entry : entry.source; } function parseNpmPackageName(source: string): string | undefined { if (!source.startsWith("npm:")) return undefined; const specifier = source.slice(4); if (specifier.startsWith("@")) { const match = /^(@[^/]+\/[^@]+)(?:@.+)?$/.exec(specifier); return match?.[1]; } return /^([^@]+)(?:@.+)?$/.exec(specifier)?.[1]; } function packageExtensionsEnabled(entry: PackageSource): boolean { return typeof entry === "string" || entry.extensions === undefined || entry.extensions.length > 0; } export function getEnabledPackageSource(entry: PackageSource): PackageSource { if (typeof entry === "string") return entry; const enabled = { ...entry }; if (enabled.autoload === false) { enabled.extensions = enabled.extensions?.length ? [...enabled.extensions] : ["+**/*"]; } else if (enabled.extensions?.length === 0) { delete enabled.extensions; } const keys = Object.keys(enabled); return keys.length === 1 && keys[0] === "source" ? enabled.source : enabled; } export async function scanInstalledPackageExtensions( agentDir: string, settings: PiSettings, ): Promise { const packages = Array.isArray(settings.packages) ? settings.packages.filter(isPackageSource) : []; const records: PackageExtensionRecord[] = []; for (const entry of packages) { const source = getPackageSource(entry); const packageName = parseNpmPackageName(source); if (!packageName) continue; const packageDir = join(agentDir, "npm", "node_modules", packageName); const manifestPath = join(packageDir, "package.json"); if (!(await fileExists(manifestPath))) continue; try { const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { name?: string; description?: string; pi?: { extensions?: unknown }; }; if (manifest.name === SELECTOR_PACKAGE_NAME) continue; const declaredExtensions = Array.isArray(manifest.pi?.extensions) && manifest.pi.extensions.length > 0; const conventionalExtensions = await isDirectory(join(packageDir, "extensions")); if (!declaredExtensions && !conventionalExtensions) continue; records.push({ id: `package:${source}`, label: manifest.name ?? packageName, source, description: manifest.description ? `${manifest.description} (${source})` : source, enabled: packageExtensionsEnabled(entry), }); } catch { continue; } } return records; } export function mergePackageSelections( settings: PiSettings, records: PackageExtensionRecord[], enabledIds: ReadonlySet, packageDefaults: Record, ): PiSettings { const bySource = new Map(records.map((record) => [record.source, record])); const packages = Array.isArray(settings.packages) ? settings.packages.filter(isPackageSource) : []; const nextPackages = packages.map((entry): PackageSource => { const source = getPackageSource(entry); const record = bySource.get(source); if (!record) return entry; const enabledForm = packageDefaults[source] ?? getEnabledPackageSource(entry); if (enabledIds.has(record.id)) return enabledForm; return typeof enabledForm === "string" ? { source: enabledForm, extensions: [] } : { ...enabledForm, extensions: [] }; }); return { ...settings, packages: nextPackages }; } export async function writeJsonAtomic(path: string, value: unknown): Promise { await mkdir(dirname(path), { recursive: true }); const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); await rename(tempPath, path); } export function updateManagedHistory(state: SelectorState, records: ExtensionRecord[]): string[] { return [...new Set([...state.managedPaths, ...records.map((record) => normalize(resolve(record.path)))])].sort(); } export async function isDirectory(path: string): Promise { try { return (await stat(path)).isDirectory(); } catch { return false; } }