package-manager.ts
81 KB2651 lines
package-manager.ts
1import type { ChildProcess, ChildProcessByStdio } from "node:child_process";2import { createHash } from "node:crypto";3import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";4import { homedir } from "node:os";56function getEnv(): NodeJS.ProcessEnv {7 if (process.platform !== "linux" || Object.keys(process.env).length > 0) {8 return process.env;9 }10 try {11 const data = readFileSync("/proc/self/environ", "utf-8");12 const env: NodeJS.ProcessEnv = {};13 for (const entry of data.split("\0")) {14 const idx = entry.indexOf("=");15 if (idx > 0) {16 env[entry.slice(0, idx)] = entry.slice(idx + 1);17 }18 }19 return env;20 } catch {21 return process.env;22 }23}2425import { basename, dirname, join, relative, resolve, sep } from "node:path";26import type { Readable } from "node:stream";27import { globSync } from "glob";28import ignore from "ignore";29import { minimatch } from "minimatch";30import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver";31import { CONFIG_DIR_NAME } from "../config.ts";32import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";33import { type GitSource, parseGitUrl } from "../utils/git.ts";34import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";35import { isStdoutTakenOver } from "./output-guard.ts";36import type { PackageSource, SettingsManager } from "./settings-manager.ts";3738const NETWORK_TIMEOUT_MS = 10000;39const UPDATE_CHECK_CONCURRENCY = 4;40const GIT_UPDATE_CONCURRENCY = 4;4142function isOfflineModeEnabled(): boolean {43 const value = process.env.PI_OFFLINE;44 if (!value) return false;45 return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";46}4748function isExactNpmVersion(version: string | undefined): boolean {49 return valid(version ?? "") !== null;50}5152function getNpmVersionRange(version: string | undefined): string | undefined {53 return version ? (validRange(version) ?? undefined) : undefined;54}5556export interface PathMetadata {57 source: string;58 scope: SourceScope;59 origin: "package" | "top-level";60 baseDir?: string;61}6263export interface ResolvedResource {64 path: string;65 enabled: boolean;66 metadata: PathMetadata;67}6869export interface ResolvedPaths {70 extensions: ResolvedResource[];71 skills: ResolvedResource[];72 prompts: ResolvedResource[];73 themes: ResolvedResource[];74}7576export type MissingSourceAction = "install" | "skip" | "error";7778export interface ProgressEvent {79 type: "start" | "progress" | "complete" | "error";80 action: "install" | "remove" | "update" | "clone" | "pull";81 source: string;82 message?: string;83}8485export type ProgressCallback = (event: ProgressEvent) => void;8687export interface PackageUpdate {88 source: string;89 displayName: string;90 type: "npm" | "git";91 scope: Exclude<SourceScope, "temporary">;92}9394export interface ConfiguredPackage {95 source: string;96 scope: "user" | "project";97 filtered: boolean;98 installedPath?: string;99}100101export interface PackageManager {102 resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;103 install(source: string, options?: { local?: boolean }): Promise<void>;104 installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;105 remove(source: string, options?: { local?: boolean }): Promise<void>;106 removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;107 update(source?: string): Promise<void>;108 listConfiguredPackages(): ConfiguredPackage[];109 resolveExtensionSources(110 sources: string[],111 options?: { local?: boolean; temporary?: boolean },112 ): Promise<ResolvedPaths>;113 addSourceToSettings(source: string, options?: { local?: boolean }): boolean;114 removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean;115 setProgressCallback(callback: ProgressCallback | undefined): void;116 getInstalledPath(source: string, scope: "user" | "project"): string | undefined;117}118119interface PackageManagerOptions {120 cwd: string;121 agentDir: string;122 settingsManager: SettingsManager;123}124125type SourceScope = "user" | "project" | "temporary";126127type NpmSource = {128 type: "npm";129 spec: string;130 name: string;131 version?: string;132 range?: string;133 pinned: boolean;134};135136type LocalSource = {137 type: "local";138 path: string;139};140141type ParsedSource = NpmSource | GitSource | LocalSource;142143type InstalledSourceScope = Exclude<SourceScope, "temporary">;144145interface ConfiguredUpdateSource {146 source: string;147 scope: InstalledSourceScope;148}149150interface NpmUpdateTarget extends ConfiguredUpdateSource {151 parsed: NpmSource;152}153154interface GitUpdateTarget extends ConfiguredUpdateSource {155 parsed: GitSource;156}157158interface PiManifest {159 extensions?: string[];160 skills?: string[];161 prompts?: string[];162 themes?: string[];163}164165interface ResourceAccumulator {166 extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>;167 skills: Map<string, { metadata: PathMetadata; enabled: boolean }>;168 prompts: Map<string, { metadata: PathMetadata; enabled: boolean }>;169 themes: Map<string, { metadata: PathMetadata; enabled: boolean }>;170}171172/**173 * Compute a numeric precedence rank for a resource based on its metadata.174 * Lower rank = higher precedence. Used to sort resolved resources so that175 * name-collision resolution ("first wins") produces the correct outcome.176 *177 * Precedence (highest to lowest):178 * 0 project + settings entry (source: "local", scope: "project")179 * 1 project + auto-discovered (source: "auto", scope: "project")180 * 2 user + settings entry (source: "local", scope: "user")181 * 3 user + auto-discovered (source: "auto", scope: "user")182 * 4 package resource (origin: "package")183 */184function resourcePrecedenceRank(m: PathMetadata): number {185 if (m.origin === "package") return 4;186 const scopeBase = m.scope === "project" ? 0 : 2;187 return scopeBase + (m.source === "local" ? 0 : 1);188}189190interface PackageFilter {191 autoload?: boolean;192 extensions?: string[];193 skills?: string[];194 prompts?: string[];195 themes?: string[];196}197198type ResourceType = "extensions" | "skills" | "prompts" | "themes";199200const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"];201202const FILE_PATTERNS: Record<ResourceType, RegExp> = {203 extensions: /\.(ts|js)$/,204 skills: /\.md$/,205 prompts: /\.md$/,206 themes: /\.json$/,207};208209const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];210211type IgnoreMatcher = ReturnType<typeof ignore>;212213function toPosixPath(p: string): string {214 return p.split(sep).join("/");215}216217function getHomeDir(): string {218 return process.env.HOME || homedir();219}220221export function getExtensionTempFolder(agentDir: string): string {222 const tempFolder = join(agentDir, "tmp", "extensions");223 mkdirSync(tempFolder, { recursive: true, mode: 0o700 });224 chmodSync(tempFolder, 0o700);225 return tempFolder;226}227228function prefixIgnorePattern(line: string, prefix: string): string | null {229 const trimmed = line.trim();230 if (!trimmed) return null;231 if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;232233 let pattern = line;234 let negated = false;235236 if (pattern.startsWith("!")) {237 negated = true;238 pattern = pattern.slice(1);239 } else if (pattern.startsWith("\\!")) {240 pattern = pattern.slice(1);241 }242243 if (pattern.startsWith("/")) {244 pattern = pattern.slice(1);245 }246247 const prefixed = prefix ? `${prefix}${pattern}` : pattern;248 return negated ? `!${prefixed}` : prefixed;249}250251function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {252 const relativeDir = relative(rootDir, dir);253 const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";254255 for (const filename of IGNORE_FILE_NAMES) {256 const ignorePath = join(dir, filename);257 if (!existsSync(ignorePath)) continue;258 try {259 const content = readFileSync(ignorePath, "utf-8");260 const patterns = content261 .split(/\r?\n/)262 .map((line) => prefixIgnorePattern(line, prefix))263 .filter((line): line is string => Boolean(line));264 if (patterns.length > 0) {265 ig.add(patterns);266 }267 } catch {}268 }269}270271function isPattern(s: string): boolean {272 return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");273}274275function isOverridePattern(s: string): boolean {276 return s.startsWith("!") || s.startsWith("+") || s.startsWith("-");277}278279function hasGlobPattern(s: string): boolean {280 return s.includes("*") || s.includes("?");281}282283function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {284 const plain: string[] = [];285 const patterns: string[] = [];286 for (const entry of entries) {287 if (isPattern(entry)) {288 patterns.push(entry);289 } else {290 plain.push(entry);291 }292 }293 return { plain, patterns };294}295296function collectFiles(297 dir: string,298 filePattern: RegExp,299 skipNodeModules = true,300 ignoreMatcher?: IgnoreMatcher,301 rootDir?: string,302): string[] {303 const files: string[] = [];304 if (!existsSync(dir)) return files;305306 const root = rootDir ?? dir;307 const ig = ignoreMatcher ?? ignore();308 addIgnoreRules(ig, dir, root);309310 try {311 const entries = readdirSync(dir, { withFileTypes: true });312 for (const entry of entries) {313 if (entry.name.startsWith(".")) continue;314 if (skipNodeModules && entry.name === "node_modules") continue;315316 const fullPath = join(dir, entry.name);317 let isDir = entry.isDirectory();318 let isFile = entry.isFile();319320 if (entry.isSymbolicLink()) {321 try {322 const stats = statSync(fullPath);323 isDir = stats.isDirectory();324 isFile = stats.isFile();325 } catch {326 continue;327 }328 }329330 const relPath = toPosixPath(relative(root, fullPath));331 const ignorePath = isDir ? `${relPath}/` : relPath;332 if (ig.ignores(ignorePath)) continue;333334 if (isDir) {335 files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root));336 } else if (isFile && filePattern.test(entry.name)) {337 files.push(fullPath);338 }339 }340 } catch {341 // Ignore errors342 }343344 return files;345}346347type SkillDiscoveryMode = "pi" | "agents";348349function collectSkillEntries(350 dir: string,351 mode: SkillDiscoveryMode,352 ignoreMatcher?: IgnoreMatcher,353 rootDir?: string,354): string[] {355 const entries: string[] = [];356 if (!existsSync(dir)) return entries;357358 const root = rootDir ?? dir;359 const ig = ignoreMatcher ?? ignore();360 addIgnoreRules(ig, dir, root);361362 try {363 const dirEntries = readdirSync(dir, { withFileTypes: true });364365 for (const entry of dirEntries) {366 if (entry.name !== "SKILL.md") {367 continue;368 }369370 const fullPath = join(dir, entry.name);371 let isFile = entry.isFile();372 if (entry.isSymbolicLink()) {373 try {374 isFile = statSync(fullPath).isFile();375 } catch {376 continue;377 }378 }379380 const relPath = toPosixPath(relative(root, fullPath));381 if (isFile && !ig.ignores(relPath)) {382 entries.push(fullPath);383 return entries;384 }385 }386387 for (const entry of dirEntries) {388 if (entry.name.startsWith(".")) continue;389 if (entry.name === "node_modules") continue;390391 const fullPath = join(dir, entry.name);392 let isDir = entry.isDirectory();393 let isFile = entry.isFile();394395 if (entry.isSymbolicLink()) {396 try {397 const stats = statSync(fullPath);398 isDir = stats.isDirectory();399 isFile = stats.isFile();400 } catch {401 continue;402 }403 }404405 const relPath = toPosixPath(relative(root, fullPath));406 if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) {407 entries.push(fullPath);408 continue;409 }410411 if (!isDir) continue;412 if (ig.ignores(`${relPath}/`)) continue;413414 entries.push(...collectSkillEntries(fullPath, mode, ig, root));415 }416 } catch {417 // Ignore errors418 }419420 return entries;421}422423function collectAutoSkillEntries(dir: string, mode: SkillDiscoveryMode): string[] {424 return collectSkillEntries(dir, mode);425}426427function findGitRepoRoot(startDir: string): string | null {428 let dir = resolve(startDir);429 while (true) {430 if (existsSync(join(dir, ".git"))) {431 return dir;432 }433 const parent = dirname(dir);434 if (parent === dir) {435 return null;436 }437 dir = parent;438 }439}440441function collectAncestorAgentsSkillDirs(startDir: string): string[] {442 const skillDirs: string[] = [];443 const resolvedStartDir = resolve(startDir);444 const gitRepoRoot = findGitRepoRoot(resolvedStartDir);445446 let dir = resolvedStartDir;447 while (true) {448 skillDirs.push(join(dir, ".agents", "skills"));449 if (gitRepoRoot && dir === gitRepoRoot) {450 break;451 }452 const parent = dirname(dir);453 if (parent === dir) {454 break;455 }456 dir = parent;457 }458459 return skillDirs;460}461462function collectAutoPromptEntries(dir: string): string[] {463 const entries: string[] = [];464 if (!existsSync(dir)) return entries;465466 const ig = ignore();467 addIgnoreRules(ig, dir, dir);468469 try {470 const dirEntries = readdirSync(dir, { withFileTypes: true });471 for (const entry of dirEntries) {472 if (entry.name.startsWith(".")) continue;473 if (entry.name === "node_modules") continue;474475 const fullPath = join(dir, entry.name);476 let isFile = entry.isFile();477 if (entry.isSymbolicLink()) {478 try {479 isFile = statSync(fullPath).isFile();480 } catch {481 continue;482 }483 }484485 const relPath = toPosixPath(relative(dir, fullPath));486 if (ig.ignores(relPath)) continue;487488 if (isFile && entry.name.endsWith(".md")) {489 entries.push(fullPath);490 }491 }492 } catch {493 // Ignore errors494 }495496 return entries;497}498499function collectAutoThemeEntries(dir: string): string[] {500 const entries: string[] = [];501 if (!existsSync(dir)) return entries;502503 const ig = ignore();504 addIgnoreRules(ig, dir, dir);505506 try {507 const dirEntries = readdirSync(dir, { withFileTypes: true });508 for (const entry of dirEntries) {509 if (entry.name.startsWith(".")) continue;510 if (entry.name === "node_modules") continue;511512 const fullPath = join(dir, entry.name);513 let isFile = entry.isFile();514 if (entry.isSymbolicLink()) {515 try {516 isFile = statSync(fullPath).isFile();517 } catch {518 continue;519 }520 }521522 const relPath = toPosixPath(relative(dir, fullPath));523 if (ig.ignores(relPath)) continue;524525 if (isFile && entry.name.endsWith(".json")) {526 entries.push(fullPath);527 }528 }529 } catch {530 // Ignore errors531 }532533 return entries;534}535536function readPiManifestFile(packageJsonPath: string): PiManifest | null {537 try {538 const content = readFileSync(packageJsonPath, "utf-8");539 const pkg = JSON.parse(content) as { pi?: PiManifest };540 return pkg.pi ?? null;541 } catch {542 return null;543 }544}545546function resolveExtensionEntries(dir: string): string[] | null {547 const packageJsonPath = join(dir, "package.json");548 if (existsSync(packageJsonPath)) {549 const manifest = readPiManifestFile(packageJsonPath);550 if (manifest?.extensions?.length) {551 const entries: string[] = [];552 for (const extPath of manifest.extensions) {553 const resolvedExtPath = resolve(dir, extPath);554 if (existsSync(resolvedExtPath)) {555 entries.push(resolvedExtPath);556 }557 }558 if (entries.length > 0) {559 return entries;560 }561 }562 }563564 const indexTs = join(dir, "index.ts");565 const indexJs = join(dir, "index.js");566 if (existsSync(indexTs)) {567 return [indexTs];568 }569 if (existsSync(indexJs)) {570 return [indexJs];571 }572573 return null;574}575576function collectAutoExtensionEntries(dir: string): string[] {577 const entries: string[] = [];578 if (!existsSync(dir)) return entries;579580 // First check if this directory itself has explicit extension entries (package.json or index)581 const rootEntries = resolveExtensionEntries(dir);582 if (rootEntries) {583 return rootEntries;584 }585586 // Otherwise, discover extensions from directory contents587 const ig = ignore();588 addIgnoreRules(ig, dir, dir);589590 try {591 const dirEntries = readdirSync(dir, { withFileTypes: true });592 for (const entry of dirEntries) {593 if (entry.name.startsWith(".")) continue;594 if (entry.name === "node_modules") continue;595596 const fullPath = join(dir, entry.name);597 let isDir = entry.isDirectory();598 let isFile = entry.isFile();599600 if (entry.isSymbolicLink()) {601 try {602 const stats = statSync(fullPath);603 isDir = stats.isDirectory();604 isFile = stats.isFile();605 } catch {606 continue;607 }608 }609610 const relPath = toPosixPath(relative(dir, fullPath));611 const ignorePath = isDir ? `${relPath}/` : relPath;612 if (ig.ignores(ignorePath)) continue;613614 if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {615 entries.push(fullPath);616 } else if (isDir) {617 const resolvedEntries = resolveExtensionEntries(fullPath);618 if (resolvedEntries) {619 entries.push(...resolvedEntries);620 }621 }622 }623 } catch {624 // Ignore errors625 }626627 return entries;628}629630/**631 * Collect resource files from a directory based on resource type.632 * Extensions use smart discovery (index.ts in subdirs), others use recursive collection.633 */634function collectResourceFiles(dir: string, resourceType: ResourceType): string[] {635 if (resourceType === "skills") {636 return collectSkillEntries(dir, "pi");637 }638 if (resourceType === "extensions") {639 return collectAutoExtensionEntries(dir);640 }641 return collectFiles(dir, FILE_PATTERNS[resourceType]);642}643644function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {645 const rel = toPosixPath(relative(baseDir, filePath));646 const name = basename(filePath);647 const filePathPosix = toPosixPath(filePath);648 const isSkillFile = name === "SKILL.md";649 const parentDir = isSkillFile ? dirname(filePath) : undefined;650 const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;651 const parentName = isSkillFile ? basename(parentDir!) : undefined;652 const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;653654 return patterns.some((pattern) => {655 const normalizedPattern = toPosixPath(pattern);656 if (657 minimatch(rel, normalizedPattern) ||658 minimatch(name, normalizedPattern) ||659 minimatch(filePathPosix, normalizedPattern)660 ) {661 return true;662 }663 if (!isSkillFile) return false;664 return (665 minimatch(parentRel!, normalizedPattern) ||666 minimatch(parentName!, normalizedPattern) ||667 minimatch(parentDirPosix!, normalizedPattern)668 );669 });670}671672function normalizeExactPattern(pattern: string): string {673 const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;674 return toPosixPath(normalized);675}676677function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {678 if (patterns.length === 0) return false;679 const rel = toPosixPath(relative(baseDir, filePath));680 const name = basename(filePath);681 const filePathPosix = toPosixPath(filePath);682 const isSkillFile = name === "SKILL.md";683 const parentDir = isSkillFile ? dirname(filePath) : undefined;684 const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;685 const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;686687 return patterns.some((pattern) => {688 const normalized = normalizeExactPattern(pattern);689 if (normalized === rel || normalized === filePathPosix) {690 return true;691 }692 if (!isSkillFile) return false;693 return normalized === parentRel || normalized === parentDirPosix;694 });695}696697function getOverridePatterns(entries: string[]): string[] {698 return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));699}700701function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {702 const overrides = getOverridePatterns(patterns);703 const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));704 const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));705 const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));706707 let enabled = true;708 if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {709 enabled = false;710 }711 if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {712 enabled = true;713 }714 if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {715 enabled = false;716 }717 return enabled;718}719720/**721 * Apply patterns to paths and return a Set of enabled paths.722 * Pattern types:723 * - Plain patterns: include matching paths724 * - `!pattern`: exclude matching paths725 * - `+path`: force-include exact path (overrides exclusions)726 * - `-path`: force-exclude exact path (overrides force-includes)727 */728function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set<string> {729 const includes: string[] = [];730 const excludes: string[] = [];731 const forceIncludes: string[] = [];732 const forceExcludes: string[] = [];733734 for (const p of patterns) {735 if (p.startsWith("+")) {736 forceIncludes.push(p.slice(1));737 } else if (p.startsWith("-")) {738 forceExcludes.push(p.slice(1));739 } else if (p.startsWith("!")) {740 excludes.push(p.slice(1));741 } else {742 includes.push(p);743 }744 }745746 // Step 1: Apply includes (or all if no includes)747 let result: string[];748 if (includes.length === 0) {749 result = [...allPaths];750 } else {751 result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir));752 }753754 // Step 2: Apply excludes755 if (excludes.length > 0) {756 result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir));757 }758759 // Step 3: Force-include (add back from allPaths, overriding exclusions)760 if (forceIncludes.length > 0) {761 for (const filePath of allPaths) {762 if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {763 result.push(filePath);764 }765 }766 }767768 // Step 4: Force-exclude (remove even if included or force-included)769 if (forceExcludes.length > 0) {770 result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir));771 }772773 return new Set(result);774}775776function applyAutoloadDisabledPatterns(allPaths: string[], patterns: string[], baseDir: string): Map<string, boolean> {777 const result = new Map<string, boolean>();778 for (const pattern of patterns) {779 const target = pattern.slice(780 pattern.startsWith("+") || pattern.startsWith("-") || pattern.startsWith("!") ? 1 : 0,781 );782 const enabled = !pattern.startsWith("-") && !pattern.startsWith("!");783 const exact = pattern.startsWith("+") || pattern.startsWith("-");784 for (const filePath of allPaths) {785 if (786 exact ? matchesAnyExactPattern(filePath, [target], baseDir) : matchesAnyPattern(filePath, [target], baseDir)787 ) {788 result.set(filePath, enabled);789 }790 }791 }792 return result;793}794795export class DefaultPackageManager implements PackageManager {796 private cwd: string;797 private agentDir: string;798 private settingsManager: SettingsManager;799 private globalNpmRoot: string | undefined;800 private globalNpmRootCommandKey: string | undefined;801 private progressCallback: ProgressCallback | undefined;802803 constructor(options: PackageManagerOptions) {804 this.cwd = resolvePath(options.cwd);805 this.agentDir = resolvePath(options.agentDir);806 this.settingsManager = options.settingsManager;807 }808809 setProgressCallback(callback: ProgressCallback | undefined): void {810 this.progressCallback = callback;811 }812813 addSourceToSettings(source: string, options?: { local?: boolean }): boolean {814 const scope: SourceScope = options?.local ? "project" : "user";815 const currentSettings =816 scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();817 const currentPackages = currentSettings.packages ?? [];818 const normalizedSource = this.normalizePackageSourceForSettings(source, scope);819 const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));820 if (matchIndex !== -1) {821 const existing = currentPackages[matchIndex];822 if (this.getPackageSourceString(existing) === normalizedSource) {823 return false;824 }825 const nextPackages = [...currentPackages];826 nextPackages[matchIndex] =827 typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };828 if (scope === "project") {829 this.settingsManager.setProjectPackages(nextPackages);830 } else {831 this.settingsManager.setPackages(nextPackages);832 }833 return true;834 }835 const nextPackages = [...currentPackages, normalizedSource];836 if (scope === "project") {837 this.settingsManager.setProjectPackages(nextPackages);838 } else {839 this.settingsManager.setPackages(nextPackages);840 }841 return true;842 }843844 removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean {845 const scope: SourceScope = options?.local ? "project" : "user";846 const currentSettings =847 scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();848 const currentPackages = currentSettings.packages ?? [];849 const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope));850 const changed = nextPackages.length !== currentPackages.length;851 if (!changed) {852 return false;853 }854 if (scope === "project") {855 this.settingsManager.setProjectPackages(nextPackages);856 } else {857 this.settingsManager.setPackages(nextPackages);858 }859 return true;860 }861862 getInstalledPath(source: string, scope: "user" | "project"): string | undefined {863 const parsed = this.parseSource(source);864 if (parsed.type === "npm") {865 const path = this.getNpmInstallPath(parsed, scope);866 return existsSync(path) ? path : undefined;867 }868 if (parsed.type === "git") {869 const path = this.getGitInstallPath(parsed, scope);870 return existsSync(path) ? path : undefined;871 }872 if (parsed.type === "local") {873 const baseDir = this.getBaseDirForScope(scope);874 const path = this.resolvePathFromBase(parsed.path, baseDir);875 return existsSync(path) ? path : undefined;876 }877 return undefined;878 }879880 private emitProgress(event: ProgressEvent): void {881 this.progressCallback?.(event);882 }883884 private async withProgress(885 action: ProgressEvent["action"],886 source: string,887 message: string,888 operation: () => Promise<void>,889 ): Promise<void> {890 this.emitProgress({ type: "start", action, source, message });891 try {892 await operation();893 this.emitProgress({ type: "complete", action, source });894 } catch (error) {895 const errorMessage = error instanceof Error ? error.message : String(error);896 this.emitProgress({ type: "error", action, source, message: errorMessage });897 throw error;898 }899 }900901 async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {902 const accumulator = this.createAccumulator();903 const globalSettings = this.settingsManager.getGlobalSettings();904 const projectSettings = this.settingsManager.getProjectSettings();905906 // Collect all packages with scope (project first so cwd resources win collisions)907 const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];908 for (const pkg of projectSettings.packages ?? []) {909 allPackages.push({ pkg, scope: "project" });910 }911 for (const pkg of globalSettings.packages ?? []) {912 allPackages.push({ pkg, scope: "user" });913 }914915 // Dedupe: project scope wins over global for same package identity916 const packageSources = this.dedupePackages(allPackages);917 await this.resolvePackageSources(packageSources, accumulator, onMissing);918919 const globalBaseDir = this.agentDir;920 const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);921922 for (const resourceType of RESOURCE_TYPES) {923 const target = this.getTargetMap(accumulator, resourceType);924 const globalEntries = (globalSettings[resourceType] ?? []) as string[];925 const projectEntries = (projectSettings[resourceType] ?? []) as string[];926 this.resolveLocalEntries(927 projectEntries,928 resourceType,929 target,930 {931 source: "local",932 scope: "project",933 origin: "top-level",934 },935 projectBaseDir,936 );937 this.resolveLocalEntries(938 globalEntries,939 resourceType,940 target,941 {942 source: "local",943 scope: "user",944 origin: "top-level",945 },946 globalBaseDir,947 );948 }949950 this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);951952 return this.toResolvedPaths(accumulator);953 }954955 async resolveExtensionSources(956 sources: string[],957 options?: { local?: boolean; temporary?: boolean },958 ): Promise<ResolvedPaths> {959 const accumulator = this.createAccumulator();960 const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user";961 const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));962 await this.resolvePackageSources(packageSources, accumulator);963 return this.toResolvedPaths(accumulator);964 }965966 listConfiguredPackages(): ConfiguredPackage[] {967 const globalSettings = this.settingsManager.getGlobalSettings();968 const projectSettings = this.settingsManager.getProjectSettings();969 const configuredPackages: ConfiguredPackage[] = [];970971 for (const pkg of globalSettings.packages ?? []) {972 const source = typeof pkg === "string" ? pkg : pkg.source;973 configuredPackages.push({974 source,975 scope: "user",976 filtered: typeof pkg === "object",977 installedPath: this.getInstalledPath(source, "user"),978 });979 }980981 for (const pkg of projectSettings.packages ?? []) {982 const source = typeof pkg === "string" ? pkg : pkg.source;983 configuredPackages.push({984 source,985 scope: "project",986 filtered: typeof pkg === "object",987 installedPath: this.getInstalledPath(source, "project"),988 });989 }990991 return configuredPackages;992 }993994 async install(source: string, options?: { local?: boolean }): Promise<void> {995 const parsed = this.parseSource(source);996 const scope: SourceScope = options?.local ? "project" : "user";997 this.assertProjectTrustedForScope(scope);998 await this.withProgress("install", source, `Installing ${source}...`, async () => {999 if (parsed.type === "npm") {1000 await this.installNpm(parsed, scope, false);1001 return;1002 }1003 if (parsed.type === "git") {1004 await this.installGit(parsed, scope);1005 return;1006 }1007 if (parsed.type === "local") {1008 const resolved = this.resolvePath(parsed.path);1009 if (!existsSync(resolved)) {1010 throw new Error(`Path does not exist: ${resolved}`);1011 }1012 return;1013 }1014 throw new Error(`Unsupported install source: ${source}`);1015 });1016 }10171018 async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {1019 await this.install(source, options);1020 this.addSourceToSettings(source, options);1021 }10221023 async remove(source: string, options?: { local?: boolean }): Promise<void> {1024 const parsed = this.parseSource(source);1025 const scope: SourceScope = options?.local ? "project" : "user";1026 this.assertProjectTrustedForScope(scope);1027 await this.withProgress("remove", source, `Removing ${source}...`, async () => {1028 if (parsed.type === "npm") {1029 await this.uninstallNpm(parsed, scope);1030 return;1031 }1032 if (parsed.type === "git") {1033 await this.removeGit(parsed, scope);1034 return;1035 }1036 if (parsed.type === "local") {1037 return;1038 }1039 throw new Error(`Unsupported remove source: ${source}`);1040 });1041 }10421043 async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {1044 await this.remove(source, options);1045 return this.removeSourceFromSettings(source, options);1046 }10471048 async update(source?: string): Promise<void> {1049 const globalSettings = this.settingsManager.getGlobalSettings();1050 const projectSettings = this.settingsManager.getProjectSettings();1051 const identity = source ? this.getPackageIdentity(source) : undefined;1052 let matched = false;1053 const updateSources: ConfiguredUpdateSource[] = [];10541055 for (const pkg of globalSettings.packages ?? []) {1056 const sourceStr = typeof pkg === "string" ? pkg : pkg.source;1057 if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue;1058 matched = true;1059 updateSources.push({ source: sourceStr, scope: "user" });1060 }1061 for (const pkg of projectSettings.packages ?? []) {1062 const sourceStr = typeof pkg === "string" ? pkg : pkg.source;1063 if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;1064 matched = true;1065 updateSources.push({ source: sourceStr, scope: "project" });1066 }10671068 if (source && !matched) {1069 throw new Error(1070 this.buildNoMatchingPackageMessage(source, [1071 ...(globalSettings.packages ?? []),1072 ...(projectSettings.packages ?? []),1073 ]),1074 );1075 }10761077 await this.updateConfiguredSources(updateSources);1078 }10791080 private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise<void> {1081 if (isOfflineModeEnabled() || sources.length === 0) {1082 return;1083 }10841085 const npmCandidates: NpmUpdateTarget[] = [];1086 const gitCandidates: GitUpdateTarget[] = [];10871088 for (const entry of sources) {1089 const parsed = this.parseSource(entry.source);1090 // Pinned npm versions are fixed. Pinned git refs are configured checkout targets,1091 // so include them to reconcile an existing clone when the configured ref changes.1092 if (parsed.type === "npm") {1093 if (!parsed.pinned) {1094 npmCandidates.push({ ...entry, parsed });1095 }1096 } else if (parsed.type === "git") {1097 gitCandidates.push({ ...entry, parsed });1098 }1099 }11001101 const npmCheckTasks = npmCandidates.map((entry) => async () => ({1102 entry,1103 shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope),1104 }));1105 const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);1106 const userNpmUpdates: NpmUpdateTarget[] = [];1107 const projectNpmUpdates: NpmUpdateTarget[] = [];1108 for (const result of npmCheckResults) {1109 if (!result.shouldUpdate) {1110 continue;1111 }1112 if (result.entry.scope === "user") {1113 userNpmUpdates.push(result.entry);1114 } else {1115 projectNpmUpdates.push(result.entry);1116 }1117 }11181119 const tasks: Promise<void>[] = [];1120 if (userNpmUpdates.length > 0) {1121 tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));1122 }1123 if (projectNpmUpdates.length > 0) {1124 tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));1125 }1126 if (gitCandidates.length > 0) {1127 const gitTasks = gitCandidates.map(1128 (entry) => async () =>1129 this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => {1130 await this.updateGit(entry.parsed, entry.scope);1131 }),1132 );1133 tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {}));1134 }11351136 await Promise.all(tasks);1137 }11381139 private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> {1140 const installedPath = this.getManagedNpmInstallPath(source, scope);1141 const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;1142 if (!installedVersion) {1143 return true;1144 }11451146 try {1147 const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);1148 return targetVersion !== installedVersion;1149 } catch {1150 // Preserve existing update behavior when version lookup fails.1151 return true;1152 }1153 }11541155 private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise<void> {1156 if (sources.length === 0) {1157 return;1158 }11591160 const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;1161 const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;1162 const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`));11631164 await this.withProgress("update", sourceLabel, message, async () => {1165 await this.installNpmBatch(specs, scope);1166 });1167 }11681169 private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> {1170 const installRoot = this.getNpmInstallRoot(scope, false);1171 this.ensureNpmProject(installRoot);1172 await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot));1173 }11741175 async checkForAvailableUpdates(): Promise<PackageUpdate[]> {1176 if (isOfflineModeEnabled()) {1177 return [];1178 }11791180 const globalSettings = this.settingsManager.getGlobalSettings();1181 const projectSettings = this.settingsManager.getProjectSettings();1182 const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];1183 for (const pkg of projectSettings.packages ?? []) {1184 allPackages.push({ pkg, scope: "project" });1185 }1186 for (const pkg of globalSettings.packages ?? []) {1187 allPackages.push({ pkg, scope: "user" });1188 }11891190 const packageSources = this.dedupePackages(allPackages);1191 const checks = packageSources1192 .filter(1193 (entry): entry is { pkg: PackageSource; scope: Exclude<SourceScope, "temporary"> } =>1194 entry.scope !== "temporary",1195 )1196 .map((entry) => async (): Promise<PackageUpdate | undefined> => {1197 const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;1198 const parsed = this.parseSource(source);1199 if (parsed.type === "local" || parsed.pinned) {1200 return undefined;1201 }12021203 if (parsed.type === "npm") {1204 const installedPath = this.getNpmInstallPath(parsed, entry.scope);1205 if (!existsSync(installedPath)) {1206 return undefined;1207 }1208 const hasUpdate = await this.npmHasAvailableUpdate(parsed, installedPath);1209 if (!hasUpdate) {1210 return undefined;1211 }1212 return {1213 source,1214 displayName: parsed.name,1215 type: "npm",1216 scope: entry.scope,1217 };1218 }12191220 const installedPath = this.getGitInstallPath(parsed, entry.scope);1221 if (!existsSync(installedPath)) {1222 return undefined;1223 }1224 const hasUpdate = await this.gitHasAvailableUpdate(installedPath);1225 if (!hasUpdate) {1226 return undefined;1227 }1228 return {1229 source,1230 displayName: `${parsed.host}/${parsed.path}`,1231 type: "git",1232 scope: entry.scope,1233 };1234 });12351236 const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY);1237 return results.filter((result): result is PackageUpdate => result !== undefined);1238 }12391240 private async resolvePackageSources(1241 sources: Array<{ pkg: PackageSource; scope: SourceScope }>,1242 accumulator: ResourceAccumulator,1243 onMissing?: (source: string) => Promise<MissingSourceAction>,1244 ): Promise<void> {1245 for (const { pkg, scope } of sources) {1246 const sourceStr = typeof pkg === "string" ? pkg : pkg.source;1247 const filter = typeof pkg === "object" ? pkg : undefined;1248 const deltaBase = this.findAutoloadDeltaBase(pkg, scope, sources);1249 const resolvedSource = deltaBase?.source ?? sourceStr;1250 const resolvedScope = deltaBase?.scope ?? scope;1251 const parsed = this.parseSource(resolvedSource);1252 const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };12531254 if (parsed.type === "local") {1255 const baseDir = this.getBaseDirForScope(resolvedScope);1256 this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);1257 continue;1258 }12591260 const installMissing = async (): Promise<boolean> => {1261 if (isOfflineModeEnabled()) return false;1262 if (!onMissing) {1263 await this.installParsedSource(parsed, resolvedScope);1264 return true;1265 }1266 const action = await onMissing(resolvedSource);1267 if (action === "skip") return false;1268 if (action === "error") throw new Error(`Missing source: ${resolvedSource}`);1269 await this.installParsedSource(parsed, resolvedScope);1270 return true;1271 };12721273 if (parsed.type === "npm") {1274 let installedPath = this.getNpmInstallPath(parsed, resolvedScope);1275 const needsInstall =1276 !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));1277 if (needsInstall) {1278 const installed = await installMissing();1279 if (!installed) continue;1280 installedPath = this.getNpmInstallPath(parsed, resolvedScope);1281 }1282 metadata.baseDir = installedPath;1283 this.collectPackageResources(installedPath, accumulator, filter, metadata);1284 continue;1285 }12861287 if (parsed.type === "git") {1288 const installedPath = this.getGitInstallPath(parsed, resolvedScope);1289 if (!existsSync(installedPath)) {1290 const installed = await installMissing();1291 if (!installed) continue;1292 } else if (resolvedScope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {1293 await this.refreshTemporaryGitSource(parsed, resolvedSource);1294 }1295 metadata.baseDir = installedPath;1296 this.collectPackageResources(installedPath, accumulator, filter, metadata);1297 }1298 }1299 }13001301 private findAutoloadDeltaBase(1302 pkg: PackageSource,1303 scope: SourceScope,1304 sources: Array<{ pkg: PackageSource; scope: SourceScope }>,1305 ): { source: string; scope: SourceScope } | undefined {1306 if (scope !== "project" || typeof pkg !== "object" || pkg.autoload !== false) return undefined;1307 const identity = this.getPackageIdentity(pkg.source, scope);1308 const userEntry = sources.find(1309 (entry) =>1310 entry.scope === "user" &&1311 this.getPackageIdentity(this.getPackageSourceString(entry.pkg), "user") === identity,1312 );1313 return userEntry ? { source: this.getPackageSourceString(userEntry.pkg), scope: "user" } : undefined;1314 }13151316 private resolveLocalExtensionSource(1317 source: LocalSource,1318 accumulator: ResourceAccumulator,1319 filter: PackageFilter | undefined,1320 metadata: PathMetadata,1321 baseDir: string,1322 ): void {1323 const resolved = this.resolvePathFromBase(source.path, baseDir);1324 if (!existsSync(resolved)) {1325 return;1326 }13271328 try {1329 const stats = statSync(resolved);1330 if (stats.isFile()) {1331 metadata.baseDir = dirname(resolved);1332 this.addResource(accumulator.extensions, resolved, metadata, true);1333 return;1334 }1335 if (stats.isDirectory()) {1336 metadata.baseDir = resolved;1337 const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);1338 if (!resources) {1339 this.addResource(accumulator.extensions, resolved, metadata, true);1340 }1341 }1342 } catch {1343 return;1344 }1345 }13461347 private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {1348 if (parsed.type === "npm") {1349 await this.installNpm(parsed, scope, scope === "temporary");1350 return;1351 }1352 if (parsed.type === "git") {1353 await this.installGit(parsed, scope);1354 return;1355 }1356 }13571358 private getPackageSourceString(pkg: PackageSource): string {1359 return typeof pkg === "string" ? pkg : pkg.source;1360 }13611362 private getSourceMatchKeyForInput(source: string): string {1363 const parsed = this.parseSource(source);1364 if (parsed.type === "npm") {1365 return `npm:${parsed.name}`;1366 }1367 if (parsed.type === "git") {1368 return `git:${parsed.host}/${parsed.path}`;1369 }1370 return `local:${this.resolvePath(parsed.path)}`;1371 }13721373 private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string {1374 const parsed = this.parseSource(source);1375 if (parsed.type === "npm") {1376 return `npm:${parsed.name}`;1377 }1378 if (parsed.type === "git") {1379 return `git:${parsed.host}/${parsed.path}`;1380 }1381 const baseDir = this.getBaseDirForScope(scope);1382 return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;1383 }13841385 private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string {1386 const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages);1387 if (!suggestion) {1388 return `No matching package found for ${source}`;1389 }1390 return `No matching package found for ${source}. Did you mean ${suggestion}?`;1391 }13921393 private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined {1394 const trimmedSource = source.trim();1395 const suggestions = new Set<string>();13961397 for (const pkg of configuredPackages) {1398 const sourceStr = this.getPackageSourceString(pkg);1399 const parsed = this.parseSource(sourceStr);1400 if (parsed.type === "npm") {1401 if (trimmedSource === parsed.name || trimmedSource === parsed.spec) {1402 suggestions.add(sourceStr);1403 }1404 continue;1405 }1406 if (parsed.type === "git") {1407 const shorthand = `${parsed.host}/${parsed.path}`;1408 const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined;1409 if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) {1410 suggestions.add(sourceStr);1411 }1412 }1413 }14141415 return suggestions.values().next().value;1416 }14171418 private packageSourcesMatch(existing: PackageSource, inputSource: string, scope: SourceScope): boolean {1419 const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope);1420 const right = this.getSourceMatchKeyForInput(inputSource);1421 return left === right;1422 }14231424 private normalizePackageSourceForSettings(source: string, scope: SourceScope): string {1425 const parsed = this.parseSource(source);1426 if (parsed.type !== "local") {1427 return source;1428 }1429 const baseDir = this.getBaseDirForScope(scope);1430 const resolved = this.resolvePath(parsed.path);1431 const rel = relative(baseDir, resolved);1432 return rel || ".";1433 }14341435 private parseSource(source: string): ParsedSource {1436 if (source.startsWith("npm:")) {1437 const spec = source.slice("npm:".length).trim();1438 const { name, version } = this.parseNpmSpec(spec);1439 return {1440 type: "npm",1441 spec,1442 name,1443 version,1444 range: getNpmVersionRange(version),1445 pinned: isExactNpmVersion(version),1446 };1447 }14481449 if (isLocalPath(source)) {1450 return { type: "local", path: source };1451 }14521453 // Try parsing as git URL1454 const gitParsed = parseGitUrl(source);1455 if (gitParsed) {1456 return gitParsed;1457 }14581459 return { type: "local", path: source };1460 }14611462 private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise<boolean> {1463 const installedVersion = this.getInstalledNpmVersion(installedPath);1464 if (!installedVersion) {1465 return false;1466 }1467 return source.range ? satisfies(installedVersion, source.range) : true;1468 }14691470 private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> {1471 if (isOfflineModeEnabled()) {1472 return false;1473 }14741475 const installedVersion = this.getInstalledNpmVersion(installedPath);1476 if (!installedVersion) {1477 return false;1478 }14791480 try {1481 const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);1482 return targetVersion !== installedVersion;1483 } catch {1484 return false;1485 }1486 }14871488 private getInstalledNpmVersion(installedPath: string): string | undefined {1489 const packageJsonPath = join(installedPath, "package.json");1490 if (!existsSync(packageJsonPath)) return undefined;1491 try {1492 const content = readFileSync(packageJsonPath, "utf-8");1493 const pkg = JSON.parse(content) as { version?: string };1494 return pkg.version;1495 } catch {1496 return undefined;1497 }1498 }14991500 private async getLatestNpmVersion(packageSpec: string, range?: string): Promise<string> {1501 const npmCommand = this.getNpmCommand();1502 const stdout = await this.runCommandCapture(1503 npmCommand.command,1504 [...npmCommand.args, "view", packageSpec, "version", "--json"],1505 { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS },1506 );1507 const raw = stdout.trim();1508 if (!raw) throw new Error("Empty response from npm view");1509 const parsed = JSON.parse(raw) as unknown;1510 if (typeof parsed === "string") {1511 return parsed;1512 }1513 if (Array.isArray(parsed)) {1514 const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0);1515 const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0];1516 if (latest) return latest;1517 }1518 throw new Error("Unexpected response from npm view");1519 }15201521 private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> {1522 if (isOfflineModeEnabled()) {1523 return false;1524 }15251526 try {1527 const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {1528 cwd: installedPath,1529 timeoutMs: NETWORK_TIMEOUT_MS,1530 });1531 const remoteHead = await this.getRemoteGitHead(installedPath);1532 return localHead.trim() !== remoteHead.trim();1533 } catch {1534 return false;1535 }1536 }15371538 private async getRemoteGitHead(installedPath: string): Promise<string> {1539 const upstreamRef = await this.getGitUpstreamRef(installedPath);1540 if (upstreamRef) {1541 const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", upstreamRef]);1542 const match = remoteHead.match(/^([0-9a-f]{40})\s+/m);1543 if (match?.[1]) {1544 return match[1];1545 }1546 }15471548 const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", "HEAD"]);1549 const match = remoteHead.match(/^([0-9a-f]{40})\s+HEAD$/m);1550 if (!match?.[1]) {1551 throw new Error("Failed to determine remote HEAD");1552 }1553 return match[1];1554 }15551556 private async getLocalGitUpdateTarget(1557 installedPath: string,1558 ): Promise<{ ref: string; head: string; fetchArgs: string[] }> {1559 try {1560 const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {1561 cwd: installedPath,1562 timeoutMs: NETWORK_TIMEOUT_MS,1563 });1564 const trimmedUpstream = upstream.trim();1565 if (!trimmedUpstream.startsWith("origin/")) {1566 throw new Error(`Unsupported upstream remote: ${trimmedUpstream}`);1567 }1568 const branch = trimmedUpstream.slice("origin/".length);1569 if (!branch) {1570 throw new Error("Missing upstream branch name");1571 }1572 const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], {1573 cwd: installedPath,1574 timeoutMs: NETWORK_TIMEOUT_MS,1575 });1576 return {1577 ref: "@{upstream}",1578 head,1579 fetchArgs: [1580 "fetch",1581 "--prune",1582 "--no-tags",1583 "origin",1584 `+refs/heads/${branch}:refs/remotes/origin/${branch}`,1585 ],1586 };1587 } catch {1588 await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: installedPath }).catch(() => {});1589 const head = await this.runCommandCapture("git", ["rev-parse", "origin/HEAD"], {1590 cwd: installedPath,1591 timeoutMs: NETWORK_TIMEOUT_MS,1592 });1593 const originHeadRef = await this.runCommandCapture("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {1594 cwd: installedPath,1595 timeoutMs: NETWORK_TIMEOUT_MS,1596 }).catch(() => "");1597 const branch = originHeadRef.trim().replace(/^refs\/remotes\/origin\//, "");1598 if (branch) {1599 return {1600 ref: "origin/HEAD",1601 head,1602 fetchArgs: [1603 "fetch",1604 "--prune",1605 "--no-tags",1606 "origin",1607 `+refs/heads/${branch}:refs/remotes/origin/${branch}`,1608 ],1609 };1610 }1611 return {1612 ref: "origin/HEAD",1613 head,1614 fetchArgs: ["fetch", "--prune", "--no-tags", "origin", "+HEAD:refs/remotes/origin/HEAD"],1615 };1616 }1617 }16181619 private async getGitUpstreamRef(installedPath: string): Promise<string | undefined> {1620 try {1621 const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {1622 cwd: installedPath,1623 timeoutMs: NETWORK_TIMEOUT_MS,1624 });1625 const trimmed = upstream.trim();1626 if (!trimmed.startsWith("origin/")) {1627 return undefined;1628 }1629 const branch = trimmed.slice("origin/".length);1630 return branch ? `refs/heads/${branch}` : undefined;1631 } catch {1632 return undefined;1633 }1634 }16351636 private runGitRemoteCommand(installedPath: string, args: string[]): Promise<string> {1637 return this.runCommandCapture("git", args, {1638 cwd: installedPath,1639 timeoutMs: NETWORK_TIMEOUT_MS,1640 env: {1641 GIT_TERMINAL_PROMPT: "0",1642 },1643 });1644 }16451646 private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {1647 if (tasks.length === 0) {1648 return [];1649 }16501651 const results: T[] = new Array(tasks.length);1652 let nextIndex = 0;1653 const workerCount = Math.max(1, Math.min(limit, tasks.length));16541655 const worker = async () => {1656 while (true) {1657 const index = nextIndex;1658 nextIndex += 1;1659 if (index >= tasks.length) {1660 return;1661 }1662 results[index] = await tasks[index]();1663 }1664 };16651666 await Promise.all(Array.from({ length: workerCount }, () => worker()));1667 return results;1668 }16691670 /**1671 * Get a unique identity for a package, ignoring version/ref.1672 * Used to detect when the same package is in both global and project settings.1673 * For git packages, uses normalized host/path to ensure SSH and HTTPS URLs1674 * for the same repository are treated as identical.1675 */1676 private getPackageIdentity(source: string, scope?: SourceScope): string {1677 const parsed = this.parseSource(source);1678 if (parsed.type === "npm") {1679 return `npm:${parsed.name}`;1680 }1681 if (parsed.type === "git") {1682 // Use host/path for identity to normalize SSH and HTTPS1683 return `git:${parsed.host}/${parsed.path}`;1684 }1685 if (scope) {1686 const baseDir = this.getBaseDirForScope(scope);1687 return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;1688 }1689 return `local:${this.resolvePath(parsed.path)}`;1690 }16911692 /**1693 * Dedupe packages: if same package identity appears in both global and project,1694 * keep only the project one (project wins). A project entry with autoload=false1695 * is a delta over the global entry, so both are kept (delta first).1696 */1697 private dedupePackages(1698 packages: Array<{ pkg: PackageSource; scope: SourceScope }>,1699 ): Array<{ pkg: PackageSource; scope: SourceScope }> {1700 const result: Array<{ pkg: PackageSource; scope: SourceScope }> = [];1701 const seen = new Map<string, number>();1702 for (const entry of packages) {1703 const identity = this.getPackageIdentity(this.getPackageSourceString(entry.pkg), entry.scope);1704 const index = seen.get(identity);1705 if (index === undefined) {1706 seen.set(identity, result.length);1707 result.push(entry);1708 continue;1709 }1710 const existing = result[index];1711 if (existing?.scope === "project" && entry.scope === "user") {1712 if (typeof existing.pkg === "object" && existing.pkg.autoload === false) result.push(entry);1713 } else if (entry.scope === "project") {1714 result[index] = entry;1715 }1716 }1717 return result;1718 }17191720 private parseNpmSpec(spec: string): { name: string; version?: string } {1721 const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);1722 if (!match) {1723 return { name: spec };1724 }1725 const name = match[1] ?? spec;1726 const version = match[2];1727 return { name, version };1728 }17291730 private assertProjectTrustedForScope(scope: SourceScope): void {1731 if (scope === "project" && !this.settingsManager.isProjectTrusted()) {1732 throw new Error("Project is not trusted; refusing to access project package storage");1733 }1734 }17351736 private getNpmCommand(): { command: string; args: string[] } {1737 const configuredCommand = this.settingsManager.getNpmCommand();1738 if (!configuredCommand || configuredCommand.length === 0) {1739 return { command: "npm", args: [] };1740 }1741 const [command, ...args] = configuredCommand;1742 if (!command) {1743 throw new Error("Invalid npmCommand: first array entry must be a non-empty command");1744 }1745 return { command, args };1746 }17471748 private getPackageManagerName(): string {1749 const npmCommand = this.getNpmCommand();1750 const commandParts = [npmCommand.command, ...npmCommand.args];1751 const separatorIndex = commandParts.lastIndexOf("--");1752 const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command;1753 return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : "";1754 }17551756 private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise<void> {1757 const npmCommand = this.getNpmCommand();1758 await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options);1759 }17601761 private getGitDependencyInstallArgs(): string[] {1762 const configuredCommand = this.settingsManager.getNpmCommand();1763 if (configuredCommand && configuredCommand.length > 0) {1764 return ["install"];1765 }1766 return ["install", "--omit=dev"];1767 }17681769 private runNpmCommandSync(args: string[]): string {1770 const npmCommand = this.getNpmCommand();1771 return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]);1772 }17731774 private getNpmInstallArgs(specs: string[], installRoot: string): string[] {1775 const packageManagerName = this.getPackageManagerName();1776 // Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules.1777 // Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and1778 // equivalent bun/pnpm settings) so package managers do not install or solve host-provided1779 // @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates.1780 if (packageManagerName === "bun") {1781 return ["install", ...specs, "--cwd", installRoot, "--omit=peer"];1782 }1783 if (packageManagerName === "pnpm") {1784 return [1785 "install",1786 ...specs,1787 "--prefix",1788 installRoot,1789 "--config.auto-install-peers=false",1790 "--config.strict-peer-dependencies=false",1791 "--config.strict-dep-builds=false",1792 ];1793 }1794 return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"];1795 }17961797 private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {1798 const installRoot = this.getNpmInstallRoot(scope, temporary);1799 this.ensureNpmProject(installRoot);1800 await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot));1801 }18021803 private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {1804 const installRoot = this.getNpmInstallRoot(scope, false);1805 if (!existsSync(installRoot)) {1806 return;1807 }1808 const packageManagerName = this.getPackageManagerName();1809 if (packageManagerName === "bun") {1810 await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);1811 return;1812 }1813 const args = ["uninstall", source.name, "--prefix", installRoot];1814 if (packageManagerName !== "pnpm") {1815 args.push("--legacy-peer-deps");1816 }1817 await this.runNpmCommand(args);1818 }18191820 private async installGit(source: GitSource, scope: SourceScope): Promise<void> {1821 const targetDir = this.getGitInstallPath(source, scope);1822 if (existsSync(targetDir)) {1823 if (source.ref) {1824 await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");1825 return;1826 }1827 const target = await this.getLocalGitUpdateTarget(targetDir);1828 await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);1829 return;1830 }1831 const gitRoot = this.getGitInstallRoot(scope);1832 if (gitRoot) {1833 this.ensureGitIgnore(gitRoot);1834 }1835 mkdirSync(dirname(targetDir), { recursive: true });18361837 await this.runCommand("git", ["clone", source.repo, targetDir]);1838 if (source.ref) {1839 await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });1840 }1841 const packageJsonPath = join(targetDir, "package.json");1842 if (existsSync(packageJsonPath)) {1843 await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir });1844 }1845 }18461847 private async updateGit(source: GitSource, scope: SourceScope): Promise<void> {1848 const targetDir = this.getGitInstallPath(source, scope);1849 if (!existsSync(targetDir)) {1850 await this.installGit(source, scope);1851 return;1852 }18531854 if (source.ref) {1855 await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");1856 return;1857 }18581859 const target = await this.getLocalGitUpdateTarget(targetDir);1860 await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);1861 }18621863 private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> {1864 // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.1865 await this.runCommand("git", fetchArgs, { cwd: targetDir });18661867 const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {1868 cwd: targetDir,1869 timeoutMs: NETWORK_TIMEOUT_MS,1870 });1871 const commitRef = `${ref}^{commit}`;1872 const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], {1873 cwd: targetDir,1874 timeoutMs: NETWORK_TIMEOUT_MS,1875 });1876 if (localHead.trim() === targetHead.trim()) {1877 return;1878 }18791880 await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir });18811882 // Clean untracked files (extensions should be pristine)1883 await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });18841885 const packageJsonPath = join(targetDir, "package.json");1886 if (existsSync(packageJsonPath)) {1887 await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir });1888 }1889 }18901891 private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> {1892 if (isOfflineModeEnabled()) {1893 return;1894 }1895 try {1896 await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => {1897 await this.updateGit(source, "temporary");1898 });1899 } catch {1900 // Keep cached temporary checkout if refresh fails.1901 }1902 }19031904 private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {1905 const targetDir = this.getGitInstallPath(source, scope);1906 if (!existsSync(targetDir)) return;1907 rmSync(targetDir, { recursive: true, force: true });1908 this.pruneEmptyGitParents(targetDir, this.getGitInstallRoot(scope));1909 }19101911 private pruneEmptyGitParents(targetDir: string, installRoot: string | undefined): void {1912 if (!installRoot) return;1913 const resolvedRoot = resolve(installRoot);1914 let current = dirname(targetDir);1915 while (current.startsWith(resolvedRoot) && current !== resolvedRoot) {1916 if (!existsSync(current)) {1917 current = dirname(current);1918 continue;1919 }1920 const entries = readdirSync(current);1921 if (entries.length > 0) {1922 break;1923 }1924 try {1925 rmSync(current, { recursive: true, force: true });1926 } catch {1927 break;1928 }1929 current = dirname(current);1930 }1931 }19321933 private ensureNpmProject(installRoot: string): void {1934 if (!existsSync(installRoot)) {1935 mkdirSync(installRoot, { recursive: true });1936 }1937 markPathIgnoredByCloudSync(installRoot);1938 this.ensureGitIgnore(installRoot);1939 const packageJsonPath = join(installRoot, "package.json");1940 if (!existsSync(packageJsonPath)) {1941 const pkgJson = { name: "pi-extensions", private: true };1942 writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");1943 }1944 }19451946 private ensureGitIgnore(dir: string): void {1947 if (!existsSync(dir)) {1948 mkdirSync(dir, { recursive: true });1949 }1950 const ignorePath = join(dir, ".gitignore");1951 if (!existsSync(ignorePath)) {1952 writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8");1953 }1954 }19551956 private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {1957 if (temporary) {1958 return this.getTemporaryDir("npm");1959 }1960 if (scope === "project") {1961 this.assertProjectTrustedForScope(scope);1962 return join(this.cwd, CONFIG_DIR_NAME, "npm");1963 }1964 return join(this.agentDir, "npm");1965 }19661967 private getGlobalNpmRoot(): string {1968 const npmCommand = this.getNpmCommand();1969 const commandKey = [npmCommand.command, ...npmCommand.args].join("\0");1970 if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) {1971 return this.globalNpmRoot;1972 }1973 if (this.getPackageManagerName() === "bun") {1974 const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim();1975 this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules");1976 } else {1977 this.globalNpmRoot = this.runNpmCommandSync(["root", "-g"]).trim();1978 }1979 this.globalNpmRootCommandKey = commandKey;1980 return this.globalNpmRoot;1981 }19821983 private getPnpmGlobalPackagePath(packageName: string): string | undefined {1984 if (this.getPackageManagerName() !== "pnpm") {1985 return undefined;1986 }19871988 const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]);1989 const entries = JSON.parse(output) as Array<{ dependencies?: Record<string, { path?: string }> }>;1990 for (const entry of entries) {1991 const path = entry.dependencies?.[packageName]?.path;1992 if (path) return path;1993 }1994 return undefined;1995 }19961997 private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string {1998 if (scope === "temporary") {1999 return join(this.getTemporaryDir("npm"), "node_modules", source.name);2000 }2001 if (scope === "project") {2002 this.assertProjectTrustedForScope(scope);2003 return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);2004 }2005 return join(this.agentDir, "npm", "node_modules", source.name);2006 }20072008 private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined {2009 try {2010 return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name);2011 } catch {2012 return undefined;2013 }2014 }20152016 private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {2017 const managedPath = this.getManagedNpmInstallPath(source, scope);2018 if (scope !== "user" || existsSync(managedPath)) {2019 return managedPath;2020 }2021 const legacyPath = this.getLegacyGlobalNpmInstallPath(source);2022 return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath;2023 }20242025 private getGitInstallPath(source: GitSource, scope: SourceScope): string {2026 if (scope === "temporary") {2027 return this.getTemporaryDir(`git-${source.host}`, source.path);2028 }2029 const installRoot = this.getGitInstallRoot(scope);2030 if (!installRoot) {2031 throw new Error("Missing git install root");2032 }2033 return this.resolveManagedPath(installRoot, source.host, source.path);2034 }20352036 private getGitInstallRoot(scope: SourceScope): string | undefined {2037 if (scope === "temporary") {2038 return undefined;2039 }2040 if (scope === "project") {2041 this.assertProjectTrustedForScope(scope);2042 return join(this.cwd, CONFIG_DIR_NAME, "git");2043 }2044 return join(this.agentDir, "git");2045 }20462047 private getTemporaryDir(prefix: string, suffix?: string): string {2048 const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix);2049 const hash = createHash("sha256")2050 .update(`${prefix}-${suffix ?? ""}`)2051 .digest("hex")2052 .slice(0, 8);2053 return this.resolveManagedPath(root, hash, suffix ?? "");2054 }20552056 private resolveManagedPath(root: string, ...parts: string[]): string {2057 const resolvedRoot = resolve(root);2058 const resolvedPath = resolve(resolvedRoot, ...parts);2059 if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {2060 throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`);2061 }2062 return resolvedPath;2063 }20642065 private getBaseDirForScope(scope: SourceScope): string {2066 if (scope === "project") {2067 this.assertProjectTrustedForScope(scope);2068 return join(this.cwd, CONFIG_DIR_NAME);2069 }2070 if (scope === "user") {2071 return this.agentDir;2072 }2073 return this.cwd;2074 }20752076 private resolvePath(input: string): string {2077 return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });2078 }20792080 private resolvePathFromBase(input: string, baseDir: string): string {2081 return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });2082 }20832084 private collectPackageResources(2085 packageRoot: string,2086 accumulator: ResourceAccumulator,2087 filter: PackageFilter | undefined,2088 metadata: PathMetadata,2089 ): boolean {2090 if (filter) {2091 for (const resourceType of RESOURCE_TYPES) {2092 const patterns = filter[resourceType];2093 const target = this.getTargetMap(accumulator, resourceType);2094 if (filter.autoload === false) {2095 this.applyPackageDeltaFilter(packageRoot, patterns ?? [], resourceType, target, metadata);2096 } else if (patterns !== undefined) {2097 this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata);2098 } else {2099 this.collectDefaultResources(packageRoot, resourceType, target, metadata);2100 }2101 }2102 return true;2103 }21042105 const manifest = this.readPiManifest(packageRoot);2106 if (manifest) {2107 for (const resourceType of RESOURCE_TYPES) {2108 const entries = manifest[resourceType as keyof PiManifest];2109 this.addManifestEntries(2110 entries,2111 packageRoot,2112 resourceType,2113 this.getTargetMap(accumulator, resourceType),2114 metadata,2115 );2116 }2117 return true;2118 }21192120 let hasAnyDir = false;2121 for (const resourceType of RESOURCE_TYPES) {2122 const dir = join(packageRoot, resourceType);2123 if (existsSync(dir)) {2124 // Collect all files from the directory (all enabled by default)2125 const files = collectResourceFiles(dir, resourceType);2126 for (const f of files) {2127 this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true);2128 }2129 hasAnyDir = true;2130 }2131 }2132 return hasAnyDir;2133 }21342135 private collectDefaultResources(2136 packageRoot: string,2137 resourceType: ResourceType,2138 target: Map<string, { metadata: PathMetadata; enabled: boolean }>,2139 metadata: PathMetadata,2140 ): void {2141 const manifest = this.readPiManifest(packageRoot);2142 const entries = manifest?.[resourceType as keyof PiManifest];2143 if (entries) {2144 this.addManifestEntries(entries, packageRoot, resourceType, target, metadata);2145 return;2146 }2147 const dir = join(packageRoot, resourceType);2148 if (existsSync(dir)) {2149 // Collect all files from the directory (all enabled by default)2150 const files = collectResourceFiles(dir, resourceType);2151 for (const f of files) {2152 this.addResource(target, f, metadata, true);2153 }2154 }2155 }21562157 private applyPackageFilter(2158 packageRoot: string,2159 userPatterns: string[],2160 resourceType: ResourceType,2161 target: Map<string, { metadata: PathMetadata; enabled: boolean }>,2162 metadata: PathMetadata,2163 ): void {2164 const { allFiles } = this.collectManifestFiles(packageRoot, resourceType);21652166 if (userPatterns.length === 0) {2167 // Empty array explicitly disables all resources of this type2168 for (const f of allFiles) {2169 this.addResource(target, f, metadata, false);2170 }2171 return;2172 }21732174 // Apply user patterns2175 const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot);21762177 for (const f of allFiles) {2178 const enabled = enabledByUser.has(f);2179 this.addResource(target, f, metadata, enabled);2180 }2181 }21822183 private applyPackageDeltaFilter(2184 packageRoot: string,2185 userPatterns: string[],2186 resourceType: ResourceType,2187 target: Map<string, { metadata: PathMetadata; enabled: boolean }>,2188 metadata: PathMetadata,2189 ): void {2190 if (userPatterns.length === 0) {2191 return;2192 }21932194 const { allFiles } = this.collectManifestFiles(packageRoot, resourceType);2195 const enabledByUser = applyAutoloadDisabledPatterns(allFiles, userPatterns, packageRoot);2196 for (const [filePath, enabled] of enabledByUser) {2197 this.addResource(target, filePath, metadata, enabled);2198 }2199 }22002201 /**2202 * Collect all files from a package for a resource type, applying manifest patterns.2203 * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files2204 * that pass the manifest's own patterns.2205 */2206 private collectManifestFiles(2207 packageRoot: string,2208 resourceType: ResourceType,2209 ): { allFiles: string[]; enabledByManifest: Set<string> } {2210 const manifest = this.readPiManifest(packageRoot);2211 const entries = manifest?.[resourceType as keyof PiManifest];2212 if (entries && entries.length > 0) {2213 const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType);2214 const manifestPatterns = entries.filter(isOverridePattern);2215 const enabledByManifest =2216 manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles);2217 return { allFiles: Array.from(enabledByManifest), enabledByManifest };2218 }22192220 const conventionDir = join(packageRoot, resourceType);2221 if (!existsSync(conventionDir)) {2222 return { allFiles: [], enabledByManifest: new Set() };2223 }2224 const allFiles = collectResourceFiles(conventionDir, resourceType);2225 return { allFiles, enabledByManifest: new Set(allFiles) };2226 }22272228 private readPiManifest(packageRoot: string): PiManifest | null {2229 const packageJsonPath = join(packageRoot, "package.json");2230 if (!existsSync(packageJsonPath)) {2231 return null;2232 }22332234 try {2235 const content = readFileSync(packageJsonPath, "utf-8");2236 const pkg = JSON.parse(content) as { pi?: PiManifest };2237 return pkg.pi ?? null;2238 } catch {2239 return null;2240 }2241 }22422243 private addManifestEntries(2244 entries: string[] | undefined,2245 root: string,2246 resourceType: ResourceType,2247 target: Map<string, { metadata: PathMetadata; enabled: boolean }>,2248 metadata: PathMetadata,2249 ): void {2250 if (!entries) return;22512252 const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType);2253 const patterns = entries.filter(isOverridePattern);2254 const enabledPaths = applyPatterns(allFiles, patterns, root);22552256 for (const f of allFiles) {2257 if (enabledPaths.has(f)) {2258 this.addResource(target, f, metadata, true);2259 }2260 }2261 }22622263 private collectFilesFromManifestEntries(entries: string[], root: string, resourceType: ResourceType): string[] {2264 const sourceEntries = entries.filter((entry) => !isOverridePattern(entry));2265 const resolved = sourceEntries.flatMap((entry) => {2266 if (!hasGlobPattern(entry)) {2267 return [resolve(root, entry)];2268 }22692270 return globSync(entry, {2271 cwd: root,2272 absolute: true,2273 dot: false,2274 nodir: false,2275 }).map((match) => resolve(match));2276 });2277 return this.collectFilesFromPaths(resolved, resourceType);2278 }22792280 private resolveLocalEntries(2281 entries: string[],2282 resourceType: ResourceType,2283 target: Map<string, { metadata: PathMetadata; enabled: boolean }>,2284 metadata: PathMetadata,2285 baseDir: string,2286 ): void {2287 if (entries.length === 0) return;22882289 // Collect all files from plain entries (non-pattern entries)2290 const { plain, patterns } = splitPatterns(entries);2291 const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir));2292 const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);22932294 // Determine which files are enabled based on patterns2295 const enabledPaths = applyPatterns(allFiles, patterns, baseDir);22962297 // Add all files with their enabled state2298 for (const f of allFiles) {2299 this.addResource(target, f, metadata, enabledPaths.has(f));2300 }2301 }23022303 private addAutoDiscoveredResources(2304 accumulator: ResourceAccumulator,2305 globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>,2306 projectSettings: ReturnType<SettingsManager["getProjectSettings"]>,2307 globalBaseDir: string,2308 projectBaseDir: string,2309 ): void {2310 const userMetadata: PathMetadata = {2311 source: "auto",2312 scope: "user",2313 origin: "top-level",2314 baseDir: globalBaseDir,2315 };2316 const projectMetadata: PathMetadata = {2317 source: "auto",2318 scope: "project",2319 origin: "top-level",2320 baseDir: projectBaseDir,2321 };23222323 const userOverrides = {2324 extensions: (globalSettings.extensions ?? []) as string[],2325 skills: (globalSettings.skills ?? []) as string[],2326 prompts: (globalSettings.prompts ?? []) as string[],2327 themes: (globalSettings.themes ?? []) as string[],2328 };2329 const projectOverrides = {2330 extensions: (projectSettings.extensions ?? []) as string[],2331 skills: (projectSettings.skills ?? []) as string[],2332 prompts: (projectSettings.prompts ?? []) as string[],2333 themes: (projectSettings.themes ?? []) as string[],2334 };23352336 const userDirs = {2337 extensions: join(globalBaseDir, "extensions"),2338 skills: join(globalBaseDir, "skills"),2339 prompts: join(globalBaseDir, "prompts"),2340 themes: join(globalBaseDir, "themes"),2341 };2342 const projectDirs = {2343 extensions: join(projectBaseDir, "extensions"),2344 skills: join(projectBaseDir, "skills"),2345 prompts: join(projectBaseDir, "prompts"),2346 themes: join(projectBaseDir, "themes"),2347 };2348 const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");2349 const projectTrusted = this.settingsManager.isProjectTrusted();2350 const projectAgentsSkillDirs = projectTrusted2351 ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))2352 : [];23532354 const addResources = (2355 resourceType: ResourceType,2356 paths: string[],2357 metadata: PathMetadata,2358 overrides: string[],2359 baseDir: string,2360 ) => {2361 const target = this.getTargetMap(accumulator, resourceType);2362 for (const path of paths) {2363 const enabled = isEnabledByOverrides(path, overrides, baseDir);2364 this.addResource(target, path, metadata, enabled);2365 }2366 };23672368 if (projectTrusted) {2369 // Project extensions from .pi/2370 addResources(2371 "extensions",2372 collectAutoExtensionEntries(projectDirs.extensions),2373 projectMetadata,2374 projectOverrides.extensions,2375 projectBaseDir,2376 );23772378 // Project skills from .pi/2379 addResources(2380 "skills",2381 collectAutoSkillEntries(projectDirs.skills, "pi"),2382 projectMetadata,2383 projectOverrides.skills,2384 projectBaseDir,2385 );2386 }23872388 // Project skills from .agents/ (each with its own baseDir)2389 for (const agentsSkillsDir of projectAgentsSkillDirs) {2390 const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory2391 const agentsMetadata: PathMetadata = {2392 ...projectMetadata,2393 baseDir: agentsBaseDir,2394 };2395 addResources(2396 "skills",2397 collectAutoSkillEntries(agentsSkillsDir, "agents"),2398 agentsMetadata,2399 projectOverrides.skills,2400 agentsBaseDir,2401 );2402 }24032404 if (projectTrusted) {2405 addResources(2406 "prompts",2407 collectAutoPromptEntries(projectDirs.prompts),2408 projectMetadata,2409 projectOverrides.prompts,2410 projectBaseDir,2411 );2412 addResources(2413 "themes",2414 collectAutoThemeEntries(projectDirs.themes),2415 projectMetadata,2416 projectOverrides.themes,2417 projectBaseDir,2418 );2419 }24202421 // User extensions from ~/.pi/agent/2422 addResources(2423 "extensions",2424 collectAutoExtensionEntries(userDirs.extensions),2425 userMetadata,2426 userOverrides.extensions,2427 globalBaseDir,2428 );24292430 // User skills from ~/.pi/agent/2431 addResources(2432 "skills",2433 collectAutoSkillEntries(userDirs.skills, "pi"),2434 userMetadata,2435 userOverrides.skills,2436 globalBaseDir,2437 );24382439 // User skills from ~/.agents/ (with its own baseDir)2440 const userAgentsBaseDir = dirname(userAgentsSkillsDir);2441 const userAgentsMetadata: PathMetadata = {2442 ...userMetadata,2443 baseDir: userAgentsBaseDir,2444 };2445 addResources(2446 "skills",2447 collectAutoSkillEntries(userAgentsSkillsDir, "agents"),2448 userAgentsMetadata,2449 userOverrides.skills,2450 userAgentsBaseDir,2451 );24522453 addResources(2454 "prompts",2455 collectAutoPromptEntries(userDirs.prompts),2456 userMetadata,2457 userOverrides.prompts,2458 globalBaseDir,2459 );2460 addResources(2461 "themes",2462 collectAutoThemeEntries(userDirs.themes),2463 userMetadata,2464 userOverrides.themes,2465 globalBaseDir,2466 );2467 }24682469 private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {2470 const files: string[] = [];2471 for (const p of paths) {2472 if (!existsSync(p)) continue;24732474 try {2475 const stats = statSync(p);2476 if (stats.isFile()) {2477 files.push(p);2478 } else if (stats.isDirectory()) {2479 files.push(...collectResourceFiles(p, resourceType));2480 }2481 } catch {2482 // Ignore errors2483 }2484 }2485 return files;2486 }24872488 private getTargetMap(2489 accumulator: ResourceAccumulator,2490 resourceType: ResourceType,2491 ): Map<string, { metadata: PathMetadata; enabled: boolean }> {2492 switch (resourceType) {2493 case "extensions":2494 return accumulator.extensions;2495 case "skills":2496 return accumulator.skills;2497 case "prompts":2498 return accumulator.prompts;2499 case "themes":2500 return accumulator.themes;2501 default:2502 throw new Error(`Unknown resource type: ${resourceType}`);2503 }2504 }25052506 private addResource(2507 map: Map<string, { metadata: PathMetadata; enabled: boolean }>,2508 path: string,2509 metadata: PathMetadata,2510 enabled: boolean,2511 ): void {2512 if (!path) return;2513 if (!map.has(path)) {2514 map.set(path, { metadata, enabled });2515 }2516 }25172518 private createAccumulator(): ResourceAccumulator {2519 return {2520 extensions: new Map(),2521 skills: new Map(),2522 prompts: new Map(),2523 themes: new Map(),2524 };2525 }25262527 private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {2528 const mapToResolved = (2529 entries: Map<string, { metadata: PathMetadata; enabled: boolean }>,2530 ): ResolvedResource[] => {2531 const resolved = Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({2532 path,2533 enabled,2534 metadata,2535 }));2536 resolved.sort((a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata));25372538 const seen = new Set<string>();2539 return resolved.filter((entry) => {2540 const canonicalPath = canonicalizePath(entry.path);2541 if (seen.has(canonicalPath)) return false;2542 seen.add(canonicalPath);2543 return true;2544 });2545 };25462547 return {2548 extensions: mapToResolved(accumulator.extensions),2549 skills: mapToResolved(accumulator.skills),2550 prompts: mapToResolved(accumulator.prompts),2551 themes: mapToResolved(accumulator.themes),2552 };2553 }25542555 private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess {2556 const env = getEnv();2557 return spawnProcess(command, args, {2558 cwd: options?.cwd,2559 stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",2560 env,2561 });2562 }25632564 private spawnCaptureCommand(2565 command: string,2566 args: string[],2567 options?: { cwd?: string; env?: Record<string, string> },2568 ): ChildProcessByStdio<null, Readable, Readable> {2569 const baseEnv = getEnv();2570 const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv;2571 return spawnProcess(command, args, {2572 cwd: options?.cwd,2573 stdio: ["ignore", "pipe", "pipe"],2574 env,2575 });2576 }25772578 private runCommandCapture(2579 command: string,2580 args: string[],2581 options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },2582 ): Promise<string> {2583 return new Promise((resolvePromise, reject) => {2584 const child = this.spawnCaptureCommand(command, args, options);2585 let stdout = "";2586 let stderr = "";2587 let timedOut = false;2588 const timeout =2589 typeof options?.timeoutMs === "number"2590 ? setTimeout(() => {2591 timedOut = true;2592 child.kill();2593 }, options.timeoutMs)2594 : undefined;25952596 child.stdout?.on("data", (data) => {2597 stdout += data.toString();2598 });2599 child.stderr?.on("data", (data) => {2600 stderr += data.toString();2601 });2602 child.once("error", (error) => {2603 if (timeout) clearTimeout(timeout);2604 reject(error);2605 });2606 child.once("close", (code, signal) => {2607 if (timeout) clearTimeout(timeout);2608 if (timedOut) {2609 reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`));2610 return;2611 }2612 if (code === 0) {2613 resolvePromise(stdout.trim());2614 return;2615 }2616 const exitStatus = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;2617 reject(new Error(`${command} ${args.join(" ")} failed with ${exitStatus}: ${stderr || stdout}`));2618 });2619 });2620 }26212622 private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {2623 return new Promise((resolvePromise, reject) => {2624 const child = this.spawnCommand(command, args, options);2625 child.on("error", reject);2626 child.on("exit", (code) => {2627 if (code === 0) {2628 resolvePromise();2629 } else {2630 reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));2631 }2632 });2633 });2634 }26352636 private runCommandSync(command: string, args: string[]): string {2637 const env = getEnv();2638 const result = spawnProcessSync(command, args, {2639 stdio: ["ignore", "pipe", "pipe"],2640 encoding: "utf-8",2641 env,2642 });2643 if (result.error || result.status !== 0) {2644 throw new Error(2645 `Failed to run ${command} ${args.join(" ")}: ${result.error?.message || result.stderr || result.stdout}`,2646 );2647 }2648 return (result.stdout || result.stderr || "").trim();2649 }2650}2651