import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { CONFIG_DIR_NAME } from "../config.js"; import type { PackageSource, SettingsManager } from "./settings-manager.js"; export interface ResolvedPaths { extensions: string[]; skills: string[]; prompts: string[]; themes: string[]; } export type MissingSourceAction = "install" | "skip" | "error"; export interface ProgressEvent { type: "start" | "progress" | "complete" | "error"; action: "install" | "remove" | "update" | "clone" | "pull"; source: string; message?: string; } export type ProgressCallback = (event: ProgressEvent) => void; export interface PackageManager { resolve(onMissing?: (source: string) => Promise): Promise; install(source: string, options?: { local?: boolean }): Promise; remove(source: string, options?: { local?: boolean }): Promise; update(source?: string): Promise; resolveExtensionSources( sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise; setProgressCallback(callback: ProgressCallback | undefined): void; } interface PackageManagerOptions { cwd: string; agentDir: string; settingsManager: SettingsManager; } type SourceScope = "global" | "project" | "temporary"; type NpmSource = { type: "npm"; spec: string; name: string; pinned: boolean; }; type GitSource = { type: "git"; repo: string; host: string; path: string; ref?: string; pinned: boolean; }; type LocalSource = { type: "local"; path: string; }; type ParsedSource = NpmSource | GitSource | LocalSource; interface PiManifest { extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[]; } interface ResourceAccumulator { extensions: Set; skills: Set; prompts: Set; themes: Set; } interface PackageFilter { extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[]; } export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; private settingsManager: SettingsManager; private globalNpmRoot: string | undefined; private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { this.cwd = options.cwd; this.agentDir = options.agentDir; this.settingsManager = options.settingsManager; this.ensureGitIgnoreDirs(); } setProgressCallback(callback: ProgressCallback | undefined): void { this.progressCallback = callback; } private emitProgress(event: ProgressEvent): void { this.progressCallback?.(event); } async resolve(onMissing?: (source: string) => Promise): Promise { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); // Resolve packages (npm/git sources) const packageSources: Array<{ pkg: PackageSource; scope: SourceScope }> = []; for (const pkg of globalSettings.packages ?? []) { packageSources.push({ pkg, scope: "global" }); } for (const pkg of projectSettings.packages ?? []) { packageSources.push({ pkg, scope: "project" }); } await this.resolvePackageSources(packageSources, accumulator, onMissing); // Resolve local extensions for (const ext of globalSettings.extensions ?? []) { this.resolveLocalPath(ext, accumulator.extensions); } for (const ext of projectSettings.extensions ?? []) { this.resolveLocalPath(ext, accumulator.extensions); } // Resolve local skills for (const skill of globalSettings.skills ?? []) { this.addPath(accumulator.skills, this.resolvePath(skill)); } for (const skill of projectSettings.skills ?? []) { this.addPath(accumulator.skills, this.resolvePath(skill)); } // Resolve local prompts for (const prompt of globalSettings.prompts ?? []) { this.addPath(accumulator.prompts, this.resolvePath(prompt)); } for (const prompt of projectSettings.prompts ?? []) { this.addPath(accumulator.prompts, this.resolvePath(prompt)); } // Resolve local themes for (const theme of globalSettings.themes ?? []) { this.addPath(accumulator.themes, this.resolvePath(theme)); } for (const theme of projectSettings.themes ?? []) { this.addPath(accumulator.themes, this.resolvePath(theme)); } return this.toResolvedPaths(accumulator); } private resolveLocalPath(path: string, target: Set): void { const resolved = this.resolvePath(path); if (existsSync(resolved)) { this.addPath(target, resolved); } } async resolveExtensionSources( sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise { const accumulator = this.createAccumulator(); const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global"; const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); await this.resolvePackageSources(packageSources, accumulator); return this.toResolvedPaths(accumulator); } async install(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "global"; this.emitProgress({ type: "start", action: "install", source, message: `Installing ${source}...` }); try { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); this.emitProgress({ type: "complete", action: "install", source }); return; } if (parsed.type === "git") { await this.installGit(parsed, scope); this.emitProgress({ type: "complete", action: "install", source }); return; } throw new Error(`Unsupported install source: ${source}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitProgress({ type: "error", action: "install", source, message }); throw error; } } async remove(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "global"; this.emitProgress({ type: "start", action: "remove", source, message: `Removing ${source}...` }); try { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); this.emitProgress({ type: "complete", action: "remove", source }); return; } if (parsed.type === "git") { await this.removeGit(parsed, scope); this.emitProgress({ type: "complete", action: "remove", source }); return; } throw new Error(`Unsupported remove source: ${source}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitProgress({ type: "error", action: "remove", source, message }); throw error; } } async update(source?: string): Promise { if (source) { await this.updateSourceForScope(source, "global"); await this.updateSourceForScope(source, "project"); return; } const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); for (const extension of globalSettings.extensions ?? []) { await this.updateSourceForScope(extension, "global"); } for (const extension of projectSettings.extensions ?? []) { await this.updateSourceForScope(extension, "project"); } } private async updateSourceForScope(source: string, scope: SourceScope): Promise { const parsed = this.parseSource(source); if (parsed.type === "npm") { if (parsed.pinned) return; this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` }); try { await this.installNpm(parsed, scope, false); this.emitProgress({ type: "complete", action: "update", source }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitProgress({ type: "error", action: "update", source, message }); throw error; } return; } if (parsed.type === "git") { if (parsed.pinned) return; this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` }); try { await this.updateGit(parsed, scope); this.emitProgress({ type: "complete", action: "update", source }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitProgress({ type: "error", action: "update", source, message }); throw error; } return; } } private async resolvePackageSources( sources: Array<{ pkg: PackageSource; scope: SourceScope }>, accumulator: ResourceAccumulator, onMissing?: (source: string) => Promise, ): Promise { for (const { pkg, scope } of sources) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; const filter = typeof pkg === "object" ? pkg : undefined; const parsed = this.parseSource(sourceStr); if (parsed.type === "local") { this.resolveLocalExtensionSource(parsed, accumulator); continue; } const installMissing = async (): Promise => { if (!onMissing) { await this.installParsedSource(parsed, scope); return true; } const action = await onMissing(sourceStr); if (action === "skip") return false; if (action === "error") throw new Error(`Missing source: ${sourceStr}`); await this.installParsedSource(parsed, scope); return true; }; if (parsed.type === "npm") { const installedPath = this.getNpmInstallPath(parsed, scope); if (!existsSync(installedPath)) { const installed = await installMissing(); if (!installed) continue; } this.collectPackageResources(installedPath, accumulator, filter); continue; } if (parsed.type === "git") { const installedPath = this.getGitInstallPath(parsed, scope); if (!existsSync(installedPath)) { const installed = await installMissing(); if (!installed) continue; } this.collectPackageResources(installedPath, accumulator, filter); } } } private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void { const resolved = this.resolvePath(source.path); if (!existsSync(resolved)) { return; } try { const stats = statSync(resolved); if (stats.isFile()) { this.addPath(accumulator.extensions, resolved); return; } if (stats.isDirectory()) { const resources = this.collectPackageResources(resolved, accumulator); if (!resources) { this.addPath(accumulator.extensions, resolved); } } } catch { return; } } private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise { if (parsed.type === "npm") { await this.installNpm(parsed, scope, scope === "temporary"); return; } if (parsed.type === "git") { await this.installGit(parsed, scope); return; } } private parseSource(source: string): ParsedSource { if (source.startsWith("npm:")) { const spec = source.slice("npm:".length).trim(); const { name, version } = this.parseNpmSpec(spec); return { type: "npm", spec, name, pinned: Boolean(version), }; } // Accept git: prefix or raw URLs (https://github.com/..., github.com/...) if (source.startsWith("git:") || this.looksLikeGitUrl(source)) { const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source; const [repo, ref] = repoSpec.split("@"); const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, ""); const parts = normalized.split("/"); const host = parts.shift() ?? ""; const repoPath = parts.join("/"); return { type: "git", repo: normalized, host, path: repoPath, ref, pinned: Boolean(ref), }; } return { type: "local", path: source }; } private looksLikeGitUrl(source: string): boolean { // Match URLs like https://github.com/..., github.com/..., gitlab.com/... const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"]; const normalized = source.replace(/^https?:\/\//, ""); return gitHosts.some((host) => normalized.startsWith(`${host}/`)); } private parseNpmSpec(spec: string): { name: string; version?: string } { const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); if (!match) { return { name: spec }; } const name = match[1] ?? spec; const version = match[2]; return { name, version }; } private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { if (scope === "global" && !temporary) { await this.runCommand("npm", ["install", "-g", source.spec]); return; } const installRoot = this.getNpmInstallRoot(scope, temporary); this.ensureNpmProject(installRoot); await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]); } private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise { if (scope === "global") { await this.runCommand("npm", ["uninstall", "-g", source.name]); return; } const installRoot = this.getNpmInstallRoot(scope, false); if (!existsSync(installRoot)) { return; } await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]); } private async installGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); if (existsSync(targetDir)) { return; } mkdirSync(dirname(targetDir), { recursive: true }); const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`; await this.runCommand("git", ["clone", cloneUrl, targetDir]); if (source.ref) { await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir }); } // Install npm dependencies if package.json exists const packageJsonPath = join(targetDir, "package.json"); if (existsSync(packageJsonPath)) { await this.runCommand("npm", ["install"], { cwd: targetDir }); } } private async updateGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); if (!existsSync(targetDir)) { await this.installGit(source, scope); return; } await this.runCommand("git", ["pull"], { cwd: targetDir }); // Reinstall npm dependencies if package.json exists (in case deps changed) const packageJsonPath = join(targetDir, "package.json"); if (existsSync(packageJsonPath)) { await this.runCommand("npm", ["install"], { cwd: targetDir }); } } private async removeGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); if (!existsSync(targetDir)) return; rmSync(targetDir, { recursive: true, force: true }); } private ensureNpmProject(installRoot: string): void { if (!existsSync(installRoot)) { mkdirSync(installRoot, { recursive: true }); } this.ensureGitIgnore(installRoot); const packageJsonPath = join(installRoot, "package.json"); if (!existsSync(packageJsonPath)) { const pkgJson = { name: "pi-extensions", private: true }; writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8"); } } private ensureGitIgnoreDirs(): void { this.ensureGitIgnore(join(this.agentDir, "git")); this.ensureGitIgnore(join(this.agentDir, "npm")); this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "git")); this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "npm")); } private ensureGitIgnore(dir: string): void { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const ignorePath = join(dir, ".gitignore"); if (!existsSync(ignorePath)) { writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8"); } } private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string { if (temporary) { return this.getTemporaryDir("npm"); } if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm"); } return join(this.getGlobalNpmRoot(), ".."); } private getGlobalNpmRoot(): string { if (this.globalNpmRoot) { return this.globalNpmRoot; } const result = this.runCommandSync("npm", ["root", "-g"]); this.globalNpmRoot = result.trim(); return this.globalNpmRoot; } private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { if (scope === "temporary") { return join(this.getTemporaryDir("npm"), "node_modules", source.name); } if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } return join(this.getGlobalNpmRoot(), source.name); } private getGitInstallPath(source: GitSource, scope: SourceScope): string { if (scope === "temporary") { return this.getTemporaryDir(`git-${source.host}`, source.path); } if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path); } return join(this.agentDir, "git", source.host, source.path); } private getTemporaryDir(prefix: string, suffix?: string): string { const hash = createHash("sha256") .update(`${prefix}-${suffix ?? ""}`) .digest("hex") .slice(0, 8); return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? ""); } private resolvePath(input: string): string { const trimmed = input.trim(); if (trimmed === "~") return homedir(); if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); return resolve(this.cwd, trimmed); } private collectPackageResources( packageRoot: string, accumulator: ResourceAccumulator, filter?: PackageFilter, ): boolean { // If filter is provided, use it to selectively load resources if (filter) { // Empty array means "load none", undefined means "load all" if (filter.extensions !== undefined) { for (const entry of filter.extensions) { const resolved = resolve(packageRoot, entry); if (existsSync(resolved)) { this.addPath(accumulator.extensions, resolved); } } } else { this.collectDefaultExtensions(packageRoot, accumulator); } if (filter.skills !== undefined) { for (const entry of filter.skills) { const resolved = resolve(packageRoot, entry); if (existsSync(resolved)) { this.addPath(accumulator.skills, resolved); } } } else { this.collectDefaultSkills(packageRoot, accumulator); } if (filter.prompts !== undefined) { for (const entry of filter.prompts) { const resolved = resolve(packageRoot, entry); if (existsSync(resolved)) { this.addPath(accumulator.prompts, resolved); } } } else { this.collectDefaultPrompts(packageRoot, accumulator); } if (filter.themes !== undefined) { for (const entry of filter.themes) { const resolved = resolve(packageRoot, entry); if (existsSync(resolved)) { this.addPath(accumulator.themes, resolved); } } } else { this.collectDefaultThemes(packageRoot, accumulator); } return true; } // No filter: load everything based on manifest or directory structure const manifest = this.readPiManifest(packageRoot); if (manifest) { this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions); this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills); this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts); this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes); return true; } const extensionsDir = join(packageRoot, "extensions"); const skillsDir = join(packageRoot, "skills"); const promptsDir = join(packageRoot, "prompts"); const themesDir = join(packageRoot, "themes"); const hasAnyDir = existsSync(extensionsDir) || existsSync(skillsDir) || existsSync(promptsDir) || existsSync(themesDir); if (!hasAnyDir) { return false; } if (existsSync(extensionsDir)) { this.addPath(accumulator.extensions, extensionsDir); } if (existsSync(skillsDir)) { this.addPath(accumulator.skills, skillsDir); } if (existsSync(promptsDir)) { this.addPath(accumulator.prompts, promptsDir); } if (existsSync(themesDir)) { this.addPath(accumulator.themes, themesDir); } return true; } private collectDefaultExtensions(packageRoot: string, accumulator: ResourceAccumulator): void { const manifest = this.readPiManifest(packageRoot); if (manifest?.extensions) { this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions); return; } const extensionsDir = join(packageRoot, "extensions"); if (existsSync(extensionsDir)) { this.addPath(accumulator.extensions, extensionsDir); } } private collectDefaultSkills(packageRoot: string, accumulator: ResourceAccumulator): void { const manifest = this.readPiManifest(packageRoot); if (manifest?.skills) { this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills); return; } const skillsDir = join(packageRoot, "skills"); if (existsSync(skillsDir)) { this.addPath(accumulator.skills, skillsDir); } } private collectDefaultPrompts(packageRoot: string, accumulator: ResourceAccumulator): void { const manifest = this.readPiManifest(packageRoot); if (manifest?.prompts) { this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts); return; } const promptsDir = join(packageRoot, "prompts"); if (existsSync(promptsDir)) { this.addPath(accumulator.prompts, promptsDir); } } private collectDefaultThemes(packageRoot: string, accumulator: ResourceAccumulator): void { const manifest = this.readPiManifest(packageRoot); if (manifest?.themes) { this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes); return; } const themesDir = join(packageRoot, "themes"); if (existsSync(themesDir)) { this.addPath(accumulator.themes, themesDir); } } private readPiManifest(packageRoot: string): PiManifest | null { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { return null; } try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content) as { pi?: PiManifest }; return pkg.pi ?? null; } catch { return null; } } private addManifestEntries(entries: string[] | undefined, root: string, target: Set): void { if (!entries) return; for (const entry of entries) { const resolved = resolve(root, entry); this.addPath(target, resolved); } } private addPath(set: Set, value: string): void { if (!value) return; set.add(value); } private createAccumulator(): ResourceAccumulator { return { extensions: new Set(), skills: new Set(), prompts: new Set(), themes: new Set(), }; } private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths { return { extensions: Array.from(accumulator.extensions), skills: Array.from(accumulator.skills), prompts: Array.from(accumulator.prompts), themes: Array.from(accumulator.themes), }; } private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise { return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { cwd: options?.cwd, stdio: "inherit", }); child.on("error", reject); child.on("exit", (code) => { if (code === 0) { resolvePromise(); } else { reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); } }); }); } private runCommandSync(command: string, args: string[]): string { const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }); if (result.status !== 0) { throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`); } return (result.stdout || result.stderr || "").trim(); } }