ππ Agent

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";
5
6function 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}
24
25import { 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";
37
38const NETWORK_TIMEOUT_MS = 10000;
39const UPDATE_CHECK_CONCURRENCY = 4;
40const GIT_UPDATE_CONCURRENCY = 4;
41
42function 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}
47
48function isExactNpmVersion(version: string | undefined): boolean {
49 return valid(version ?? "") !== null;
50}
51
52function getNpmVersionRange(version: string | undefined): string | undefined {
53 return version ? (validRange(version) ?? undefined) : undefined;
54}
55
56export interface PathMetadata {
57 source: string;
58 scope: SourceScope;
59 origin: "package" | "top-level";
60 baseDir?: string;
61}
62
63export interface ResolvedResource {
64 path: string;
65 enabled: boolean;
66 metadata: PathMetadata;
67}
68
69export interface ResolvedPaths {
70 extensions: ResolvedResource[];
71 skills: ResolvedResource[];
72 prompts: ResolvedResource[];
73 themes: ResolvedResource[];
74}
75
76export type MissingSourceAction = "install" | "skip" | "error";
77
78export interface ProgressEvent {
79 type: "start" | "progress" | "complete" | "error";
80 action: "install" | "remove" | "update" | "clone" | "pull";
81 source: string;
82 message?: string;
83}
84
85export type ProgressCallback = (event: ProgressEvent) => void;
86
87export interface PackageUpdate {
88 source: string;
89 displayName: string;
90 type: "npm" | "git";
91 scope: Exclude<SourceScope, "temporary">;
92}
93
94export interface ConfiguredPackage {
95 source: string;
96 scope: "user" | "project";
97 filtered: boolean;
98 installedPath?: string;
99}
100
101export 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}
118
119interface PackageManagerOptions {
120 cwd: string;
121 agentDir: string;
122 settingsManager: SettingsManager;
123}
124
125type SourceScope = "user" | "project" | "temporary";
126
127type NpmSource = {
128 type: "npm";
129 spec: string;
130 name: string;
131 version?: string;
132 range?: string;
133 pinned: boolean;
134};
135
136type LocalSource = {
137 type: "local";
138 path: string;
139};
140
141type ParsedSource = NpmSource | GitSource | LocalSource;
142
143type InstalledSourceScope = Exclude<SourceScope, "temporary">;
144
145interface ConfiguredUpdateSource {
146 source: string;
147 scope: InstalledSourceScope;
148}
149
150interface NpmUpdateTarget extends ConfiguredUpdateSource {
151 parsed: NpmSource;
152}
153
154interface GitUpdateTarget extends ConfiguredUpdateSource {
155 parsed: GitSource;
156}
157
158interface PiManifest {
159 extensions?: string[];
160 skills?: string[];
161 prompts?: string[];
162 themes?: string[];
163}
164
165interface 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}
171
172/**
173 * Compute a numeric precedence rank for a resource based on its metadata.
174 * Lower rank = higher precedence. Used to sort resolved resources so that
175 * 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}
189
190interface PackageFilter {
191 autoload?: boolean;
192 extensions?: string[];
193 skills?: string[];
194 prompts?: string[];
195 themes?: string[];
196}
197
198type ResourceType = "extensions" | "skills" | "prompts" | "themes";
199
200const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"];
201
202const FILE_PATTERNS: Record<ResourceType, RegExp> = {
203 extensions: /\.(ts|js)$/,
204 skills: /\.md$/,
205 prompts: /\.md$/,
206 themes: /\.json$/,
207};
208
209const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
210
211type IgnoreMatcher = ReturnType<typeof ignore>;
212
213function toPosixPath(p: string): string {
214 return p.split(sep).join("/");
215}
216
217function getHomeDir(): string {
218 return process.env.HOME || homedir();
219}
220
221export 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}
227
228function 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;
232
233 let pattern = line;
234 let negated = false;
235
236 if (pattern.startsWith("!")) {
237 negated = true;
238 pattern = pattern.slice(1);
239 } else if (pattern.startsWith("\\!")) {
240 pattern = pattern.slice(1);
241 }
242
243 if (pattern.startsWith("/")) {
244 pattern = pattern.slice(1);
245 }
246
247 const prefixed = prefix ? `${prefix}${pattern}` : pattern;
248 return negated ? `!${prefixed}` : prefixed;
249}
250
251function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {
252 const relativeDir = relative(rootDir, dir);
253 const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
254
255 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 = content
261 .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}
270
271function isPattern(s: string): boolean {
272 return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
273}
274
275function isOverridePattern(s: string): boolean {
276 return s.startsWith("!") || s.startsWith("+") || s.startsWith("-");
277}
278
279function hasGlobPattern(s: string): boolean {
280 return s.includes("*") || s.includes("?");
281}
282
283function 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}
295
296function 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;
305
306 const root = rootDir ?? dir;
307 const ig = ignoreMatcher ?? ignore();
308 addIgnoreRules(ig, dir, root);
309
310 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;
315
316 const fullPath = join(dir, entry.name);
317 let isDir = entry.isDirectory();
318 let isFile = entry.isFile();
319
320 if (entry.isSymbolicLink()) {
321 try {
322 const stats = statSync(fullPath);
323 isDir = stats.isDirectory();
324 isFile = stats.isFile();
325 } catch {
326 continue;
327 }
328 }
329
330 const relPath = toPosixPath(relative(root, fullPath));
331 const ignorePath = isDir ? `${relPath}/` : relPath;
332 if (ig.ignores(ignorePath)) continue;
333
334 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 errors
342 }
343
344 return files;
345}
346
347type SkillDiscoveryMode = "pi" | "agents";
348
349function 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;
357
358 const root = rootDir ?? dir;
359 const ig = ignoreMatcher ?? ignore();
360 addIgnoreRules(ig, dir, root);
361
362 try {
363 const dirEntries = readdirSync(dir, { withFileTypes: true });
364
365 for (const entry of dirEntries) {
366 if (entry.name !== "SKILL.md") {
367 continue;
368 }
369
370 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 }
379
380 const relPath = toPosixPath(relative(root, fullPath));
381 if (isFile && !ig.ignores(relPath)) {
382 entries.push(fullPath);
383 return entries;
384 }
385 }
386
387 for (const entry of dirEntries) {
388 if (entry.name.startsWith(".")) continue;
389 if (entry.name === "node_modules") continue;
390
391 const fullPath = join(dir, entry.name);
392 let isDir = entry.isDirectory();
393 let isFile = entry.isFile();
394
395 if (entry.isSymbolicLink()) {
396 try {
397 const stats = statSync(fullPath);
398 isDir = stats.isDirectory();
399 isFile = stats.isFile();
400 } catch {
401 continue;
402 }
403 }
404
405 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 }
410
411 if (!isDir) continue;
412 if (ig.ignores(`${relPath}/`)) continue;
413
414 entries.push(...collectSkillEntries(fullPath, mode, ig, root));
415 }
416 } catch {
417 // Ignore errors
418 }
419
420 return entries;
421}
422
423function collectAutoSkillEntries(dir: string, mode: SkillDiscoveryMode): string[] {
424 return collectSkillEntries(dir, mode);
425}
426
427function 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}
440
441function collectAncestorAgentsSkillDirs(startDir: string): string[] {
442 const skillDirs: string[] = [];
443 const resolvedStartDir = resolve(startDir);
444 const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
445
446 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 }
458
459 return skillDirs;
460}
461
462function collectAutoPromptEntries(dir: string): string[] {
463 const entries: string[] = [];
464 if (!existsSync(dir)) return entries;
465
466 const ig = ignore();
467 addIgnoreRules(ig, dir, dir);
468
469 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;
474
475 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 }
484
485 const relPath = toPosixPath(relative(dir, fullPath));
486 if (ig.ignores(relPath)) continue;
487
488 if (isFile && entry.name.endsWith(".md")) {
489 entries.push(fullPath);
490 }
491 }
492 } catch {
493 // Ignore errors
494 }
495
496 return entries;
497}
498
499function collectAutoThemeEntries(dir: string): string[] {
500 const entries: string[] = [];
501 if (!existsSync(dir)) return entries;
502
503 const ig = ignore();
504 addIgnoreRules(ig, dir, dir);
505
506 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;
511
512 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 }
521
522 const relPath = toPosixPath(relative(dir, fullPath));
523 if (ig.ignores(relPath)) continue;
524
525 if (isFile && entry.name.endsWith(".json")) {
526 entries.push(fullPath);
527 }
528 }
529 } catch {
530 // Ignore errors
531 }
532
533 return entries;
534}
535
536function 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}
545
546function 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 }
563
564 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 }
572
573 return null;
574}
575
576function collectAutoExtensionEntries(dir: string): string[] {
577 const entries: string[] = [];
578 if (!existsSync(dir)) return entries;
579
580 // 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 }
585
586 // Otherwise, discover extensions from directory contents
587 const ig = ignore();
588 addIgnoreRules(ig, dir, dir);
589
590 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;
595
596 const fullPath = join(dir, entry.name);
597 let isDir = entry.isDirectory();
598 let isFile = entry.isFile();
599
600 if (entry.isSymbolicLink()) {
601 try {
602 const stats = statSync(fullPath);
603 isDir = stats.isDirectory();
604 isFile = stats.isFile();
605 } catch {
606 continue;
607 }
608 }
609
610 const relPath = toPosixPath(relative(dir, fullPath));
611 const ignorePath = isDir ? `${relPath}/` : relPath;
612 if (ig.ignores(ignorePath)) continue;
613
614 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 errors
625 }
626
627 return entries;
628}
629
630/**
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}
643
644function 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;
653
654 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}
671
672function normalizeExactPattern(pattern: string): string {
673 const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;
674 return toPosixPath(normalized);
675}
676
677function 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;
686
687 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}
696
697function getOverridePatterns(entries: string[]): string[] {
698 return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
699}
700
701function 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));
706
707 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}
719
720/**
721 * Apply patterns to paths and return a Set of enabled paths.
722 * Pattern types:
723 * - Plain patterns: include matching paths
724 * - `!pattern`: exclude matching paths
725 * - `+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[] = [];
733
734 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 }
745
746 // 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 }
753
754 // Step 2: Apply excludes
755 if (excludes.length > 0) {
756 result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir));
757 }
758
759 // 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 }
767
768 // 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 }
772
773 return new Set(result);
774}
775
776function 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}
794
795export 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;
802
803 constructor(options: PackageManagerOptions) {
804 this.cwd = resolvePath(options.cwd);
805 this.agentDir = resolvePath(options.agentDir);
806 this.settingsManager = options.settingsManager;
807 }
808
809 setProgressCallback(callback: ProgressCallback | undefined): void {
810 this.progressCallback = callback;
811 }
812
813 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 }
843
844 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 }
861
862 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 }
879
880 private emitProgress(event: ProgressEvent): void {
881 this.progressCallback?.(event);
882 }
883
884 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 }
900
901 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();
905
906 // 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 }
914
915 // Dedupe: project scope wins over global for same package identity
916 const packageSources = this.dedupePackages(allPackages);
917 await this.resolvePackageSources(packageSources, accumulator, onMissing);
918
919 const globalBaseDir = this.agentDir;
920 const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
921
922 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 }
949
950 this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
951
952 return this.toResolvedPaths(accumulator);
953 }
954
955 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 }
965
966 listConfiguredPackages(): ConfiguredPackage[] {
967 const globalSettings = this.settingsManager.getGlobalSettings();
968 const projectSettings = this.settingsManager.getProjectSettings();
969 const configuredPackages: ConfiguredPackage[] = [];
970
971 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 }
980
981 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 }
990
991 return configuredPackages;
992 }
993
994 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 }
1017
1018 async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
1019 await this.install(source, options);
1020 this.addSourceToSettings(source, options);
1021 }
1022
1023 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 }
1042
1043 async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
1044 await this.remove(source, options);
1045 return this.removeSourceFromSettings(source, options);
1046 }
1047
1048 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[] = [];
1054
1055 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 }
1067
1068 if (source && !matched) {
1069 throw new Error(
1070 this.buildNoMatchingPackageMessage(source, [
1071 ...(globalSettings.packages ?? []),
1072 ...(projectSettings.packages ?? []),
1073 ]),
1074 );
1075 }
1076
1077 await this.updateConfiguredSources(updateSources);
1078 }
1079
1080 private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise<void> {
1081 if (isOfflineModeEnabled() || sources.length === 0) {
1082 return;
1083 }
1084
1085 const npmCandidates: NpmUpdateTarget[] = [];
1086 const gitCandidates: GitUpdateTarget[] = [];
1087
1088 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 }
1100
1101 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 }
1118
1119 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 }
1135
1136 await Promise.all(tasks);
1137 }
1138
1139 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 }
1145
1146 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 }
1154
1155 private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise<void> {
1156 if (sources.length === 0) {
1157 return;
1158 }
1159
1160 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`));
1163
1164 await this.withProgress("update", sourceLabel, message, async () => {
1165 await this.installNpmBatch(specs, scope);
1166 });
1167 }
1168
1169 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 }
1174
1175 async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
1176 if (isOfflineModeEnabled()) {
1177 return [];
1178 }
1179
1180 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 }
1189
1190 const packageSources = this.dedupePackages(allPackages);
1191 const checks = packageSources
1192 .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 }
1202
1203 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 }
1219
1220 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 });
1235
1236 const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY);
1237 return results.filter((result): result is PackageUpdate => result !== undefined);
1238 }
1239
1240 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" };
1253
1254 if (parsed.type === "local") {
1255 const baseDir = this.getBaseDirForScope(resolvedScope);
1256 this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
1257 continue;
1258 }
1259
1260 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 };
1272
1273 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 }
1286
1287 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 }
1300
1301 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 }
1315
1316 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 }
1327
1328 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 }
1346
1347 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 }
1357
1358 private getPackageSourceString(pkg: PackageSource): string {
1359 return typeof pkg === "string" ? pkg : pkg.source;
1360 }
1361
1362 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 }
1372
1373 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 }
1384
1385 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 }
1392
1393 private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined {
1394 const trimmedSource = source.trim();
1395 const suggestions = new Set<string>();
1396
1397 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 }
1414
1415 return suggestions.values().next().value;
1416 }
1417
1418 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 }
1423
1424 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 }
1434
1435 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 }
1448
1449 if (isLocalPath(source)) {
1450 return { type: "local", path: source };
1451 }
1452
1453 // Try parsing as git URL
1454 const gitParsed = parseGitUrl(source);
1455 if (gitParsed) {
1456 return gitParsed;
1457 }
1458
1459 return { type: "local", path: source };
1460 }
1461
1462 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 }
1469
1470 private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
1471 if (isOfflineModeEnabled()) {
1472 return false;
1473 }
1474
1475 const installedVersion = this.getInstalledNpmVersion(installedPath);
1476 if (!installedVersion) {
1477 return false;
1478 }
1479
1480 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 }
1487
1488 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 }
1499
1500 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 }
1520
1521 private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> {
1522 if (isOfflineModeEnabled()) {
1523 return false;
1524 }
1525
1526 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 }
1537
1538 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 }
1547
1548 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 }
1555
1556 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 }
1618
1619 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 }
1635
1636 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 }
1645
1646 private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
1647 if (tasks.length === 0) {
1648 return [];
1649 }
1650
1651 const results: T[] = new Array(tasks.length);
1652 let nextIndex = 0;
1653 const workerCount = Math.max(1, Math.min(limit, tasks.length));
1654
1655 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 };
1665
1666 await Promise.all(Array.from({ length: workerCount }, () => worker()));
1667 return results;
1668 }
1669
1670 /**
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 URLs
1674 * 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 HTTPS
1683 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 }
1691
1692 /**
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=false
1695 * 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 }
1719
1720 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 }
1729
1730 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 }
1735
1736 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 }
1747
1748 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 }
1755
1756 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 }
1760
1761 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 }
1768
1769 private runNpmCommandSync(args: string[]): string {
1770 const npmCommand = this.getNpmCommand();
1771 return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]);
1772 }
1773
1774 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, and
1778 // equivalent bun/pnpm settings) so package managers do not install or solve host-provided
1779 // @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 }
1796
1797 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 }
1802
1803 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 }
1819
1820 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 });
1836
1837 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 }
1846
1847 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 }
1853
1854 if (source.ref) {
1855 await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
1856 return;
1857 }
1858
1859 const target = await this.getLocalGitUpdateTarget(targetDir);
1860 await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
1861 }
1862
1863 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 });
1866
1867 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 }
1879
1880 await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir });
1881
1882 // Clean untracked files (extensions should be pristine)
1883 await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
1884
1885 const packageJsonPath = join(targetDir, "package.json");
1886 if (existsSync(packageJsonPath)) {
1887 await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir });
1888 }
1889 }
1890
1891 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 }
1903
1904 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 }
1910
1911 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 }
1932
1933 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 }
1945
1946 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 }
1955
1956 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 }
1966
1967 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 }
1982
1983 private getPnpmGlobalPackagePath(packageName: string): string | undefined {
1984 if (this.getPackageManagerName() !== "pnpm") {
1985 return undefined;
1986 }
1987
1988 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 }
1996
1997 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 }
2007
2008 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 }
2015
2016 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 }
2024
2025 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 }
2035
2036 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 }
2046
2047 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 }
2055
2056 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 }
2064
2065 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 }
2075
2076 private resolvePath(input: string): string {
2077 return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });
2078 }
2079
2080 private resolvePathFromBase(input: string, baseDir: string): string {
2081 return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });
2082 }
2083
2084 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 }
2104
2105 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 }
2119
2120 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 }
2134
2135 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 }
2156
2157 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);
2165
2166 if (userPatterns.length === 0) {
2167 // Empty array explicitly disables all resources of this type
2168 for (const f of allFiles) {
2169 this.addResource(target, f, metadata, false);
2170 }
2171 return;
2172 }
2173
2174 // Apply user patterns
2175 const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot);
2176
2177 for (const f of allFiles) {
2178 const enabled = enabledByUser.has(f);
2179 this.addResource(target, f, metadata, enabled);
2180 }
2181 }
2182
2183 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 }
2193
2194 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 }
2200
2201 /**
2202 * Collect all files from a package for a resource type, applying manifest patterns.
2203 * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files
2204 * 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 }
2219
2220 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 }
2227
2228 private readPiManifest(packageRoot: string): PiManifest | null {
2229 const packageJsonPath = join(packageRoot, "package.json");
2230 if (!existsSync(packageJsonPath)) {
2231 return null;
2232 }
2233
2234 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 }
2242
2243 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;
2251
2252 const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType);
2253 const patterns = entries.filter(isOverridePattern);
2254 const enabledPaths = applyPatterns(allFiles, patterns, root);
2255
2256 for (const f of allFiles) {
2257 if (enabledPaths.has(f)) {
2258 this.addResource(target, f, metadata, true);
2259 }
2260 }
2261 }
2262
2263 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 }
2269
2270 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 }
2279
2280 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;
2288
2289 // 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);
2293
2294 // Determine which files are enabled based on patterns
2295 const enabledPaths = applyPatterns(allFiles, patterns, baseDir);
2296
2297 // Add all files with their enabled state
2298 for (const f of allFiles) {
2299 this.addResource(target, f, metadata, enabledPaths.has(f));
2300 }
2301 }
2302
2303 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 };
2322
2323 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 };
2335
2336 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 = projectTrusted
2351 ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
2352 : [];
2353
2354 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 };
2367
2368 if (projectTrusted) {
2369 // Project extensions from .pi/
2370 addResources(
2371 "extensions",
2372 collectAutoExtensionEntries(projectDirs.extensions),
2373 projectMetadata,
2374 projectOverrides.extensions,
2375 projectBaseDir,
2376 );
2377
2378 // Project skills from .pi/
2379 addResources(
2380 "skills",
2381 collectAutoSkillEntries(projectDirs.skills, "pi"),
2382 projectMetadata,
2383 projectOverrides.skills,
2384 projectBaseDir,
2385 );
2386 }
2387
2388 // Project skills from .agents/ (each with its own baseDir)
2389 for (const agentsSkillsDir of projectAgentsSkillDirs) {
2390 const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
2391 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 }
2403
2404 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 }
2420
2421 // User extensions from ~/.pi/agent/
2422 addResources(
2423 "extensions",
2424 collectAutoExtensionEntries(userDirs.extensions),
2425 userMetadata,
2426 userOverrides.extensions,
2427 globalBaseDir,
2428 );
2429
2430 // User skills from ~/.pi/agent/
2431 addResources(
2432 "skills",
2433 collectAutoSkillEntries(userDirs.skills, "pi"),
2434 userMetadata,
2435 userOverrides.skills,
2436 globalBaseDir,
2437 );
2438
2439 // 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 );
2452
2453 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 }
2468
2469 private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {
2470 const files: string[] = [];
2471 for (const p of paths) {
2472 if (!existsSync(p)) continue;
2473
2474 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 errors
2483 }
2484 }
2485 return files;
2486 }
2487
2488 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 }
2505
2506 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 }
2517
2518 private createAccumulator(): ResourceAccumulator {
2519 return {
2520 extensions: new Map(),
2521 skills: new Map(),
2522 prompts: new Map(),
2523 themes: new Map(),
2524 };
2525 }
2526
2527 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));
2537
2538 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 };
2546
2547 return {
2548 extensions: mapToResolved(accumulator.extensions),
2549 skills: mapToResolved(accumulator.skills),
2550 prompts: mapToResolved(accumulator.prompts),
2551 themes: mapToResolved(accumulator.themes),
2552 };
2553 }
2554
2555 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 }
2563
2564 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 }
2577
2578 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;
2595
2596 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 }
2621
2622 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 }
2635
2636 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