Files
pi/packages/coding-agent/src/core/package-manager.ts
T
2026-04-20 22:56:26 +02:00

2409 lines
71 KiB
TypeScript

import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { type GitSource, parseGitUrl } from "../utils/git.js";
import { isLocalPath } from "../utils/paths.js";
import { isStdoutTakenOver } from "./output-guard.js";
import type { PackageSource, SettingsManager } from "./settings-manager.js";
const NETWORK_TIMEOUT_MS = 10000;
const UPDATE_CHECK_CONCURRENCY = 4;
const GIT_UPDATE_CONCURRENCY = 4;
function isOfflineModeEnabled(): boolean {
const value = process.env.PI_OFFLINE;
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
export interface PathMetadata {
source: string;
scope: SourceScope;
origin: "package" | "top-level";
baseDir?: string;
}
export interface ResolvedResource {
path: string;
enabled: boolean;
metadata: PathMetadata;
}
export interface ResolvedPaths {
extensions: ResolvedResource[];
skills: ResolvedResource[];
prompts: ResolvedResource[];
themes: ResolvedResource[];
}
export type MissingSourceAction = "install" | "skip" | "error";
export interface ProgressEvent {
type: "start" | "progress" | "complete" | "error";
action: "install" | "remove" | "update" | "clone" | "pull";
source: string;
message?: string;
}
export type ProgressCallback = (event: ProgressEvent) => void;
export interface PackageUpdate {
source: string;
displayName: string;
type: "npm" | "git";
scope: Exclude<SourceScope, "temporary">;
}
export interface ConfiguredPackage {
source: string;
scope: "user" | "project";
filtered: boolean;
installedPath?: string;
}
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
listConfiguredPackages(): ConfiguredPackage[];
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
addSourceToSettings(source: string, options?: { local?: boolean }): boolean;
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean;
setProgressCallback(callback: ProgressCallback | undefined): void;
getInstalledPath(source: string, scope: "user" | "project"): string | undefined;
}
interface PackageManagerOptions {
cwd: string;
agentDir: string;
settingsManager: SettingsManager;
}
type SourceScope = "user" | "project" | "temporary";
type NpmSource = {
type: "npm";
spec: string;
name: string;
pinned: boolean;
};
type LocalSource = {
type: "local";
path: string;
};
type ParsedSource = NpmSource | GitSource | LocalSource;
type InstalledSourceScope = Exclude<SourceScope, "temporary">;
interface ConfiguredUpdateSource {
source: string;
scope: InstalledSourceScope;
}
interface NpmUpdateTarget extends ConfiguredUpdateSource {
parsed: NpmSource;
}
interface GitUpdateTarget extends ConfiguredUpdateSource {
parsed: GitSource;
}
interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
interface ResourceAccumulator {
extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>;
skills: Map<string, { metadata: PathMetadata; enabled: boolean }>;
prompts: Map<string, { metadata: PathMetadata; enabled: boolean }>;
themes: Map<string, { metadata: PathMetadata; enabled: boolean }>;
}
/**
* Compute a numeric precedence rank for a resource based on its metadata.
* Lower rank = higher precedence. Used to sort resolved resources so that
* name-collision resolution ("first wins") produces the correct outcome.
*
* Precedence (highest to lowest):
* 0 project + settings entry (source: "local", scope: "project")
* 1 project + auto-discovered (source: "auto", scope: "project")
* 2 user + settings entry (source: "local", scope: "user")
* 3 user + auto-discovered (source: "auto", scope: "user")
* 4 package resource (origin: "package")
*/
function resourcePrecedenceRank(m: PathMetadata): number {
if (m.origin === "package") return 4;
const scopeBase = m.scope === "project" ? 0 : 2;
return scopeBase + (m.source === "local" ? 0 : 1);
}
interface PackageFilter {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"];
const FILE_PATTERNS: Record<ResourceType, RegExp> = {
extensions: /\.(ts|js)$/,
skills: /\.md$/,
prompts: /\.md$/,
themes: /\.json$/,
};
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
type IgnoreMatcher = ReturnType<typeof ignore>;
function toPosixPath(p: string): string {
return p.split(sep).join("/");
}
function getHomeDir(): string {
return process.env.HOME || homedir();
}
function prefixIgnorePattern(line: string, prefix: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;
let pattern = line;
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = pattern.slice(1);
} else if (pattern.startsWith("\\!")) {
pattern = pattern.slice(1);
}
if (pattern.startsWith("/")) {
pattern = pattern.slice(1);
}
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
return negated ? `!${prefixed}` : prefixed;
}
function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {
const relativeDir = relative(rootDir, dir);
const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = join(dir, filename);
if (!existsSync(ignorePath)) continue;
try {
const content = readFileSync(ignorePath, "utf-8");
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) {
ig.add(patterns);
}
} catch {}
}
}
function isPattern(s: string): boolean {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
}
function isOverridePattern(s: string): boolean {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-");
}
function hasGlobPattern(s: string): boolean {
return s.includes("*") || s.includes("?");
}
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
const plain: string[] = [];
const patterns: string[] = [];
for (const entry of entries) {
if (isPattern(entry)) {
patterns.push(entry);
} else {
plain.push(entry);
}
}
return { plain, patterns };
}
function collectFiles(
dir: string,
filePattern: RegExp,
skipNodeModules = true,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): string[] {
const files: string[] = [];
if (!existsSync(dir)) return files;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (skipNodeModules && entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath)) continue;
if (isDir) {
files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root));
} else if (isFile && filePattern.test(entry.name)) {
files.push(fullPath);
}
}
} catch {
// Ignore errors
}
return files;
}
type SkillDiscoveryMode = "pi" | "agents";
function collectSkillEntries(
dir: string,
mode: SkillDiscoveryMode,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name !== "SKILL.md") {
continue;
}
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
if (isFile && !ig.ignores(relPath)) {
entries.push(fullPath);
return entries;
}
}
for (const entry of dirEntries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) {
entries.push(fullPath);
continue;
}
if (!isDir) continue;
if (ig.ignores(`${relPath}/`)) continue;
entries.push(...collectSkillEntries(fullPath, mode, ig, root));
}
} catch {
// Ignore errors
}
return entries;
}
function collectAutoSkillEntries(dir: string, mode: SkillDiscoveryMode): string[] {
return collectSkillEntries(dir, mode);
}
function findGitRepoRoot(startDir: string): string | null {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, ".git"))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
function collectAncestorAgentsSkillDirs(startDir: string): string[] {
const skillDirs: string[] = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
skillDirs.push(join(dir, ".agents", "skills"));
if (gitRepoRoot && dir === gitRepoRoot) {
break;
}
const parent = dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return skillDirs;
}
function collectAutoPromptEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath)) continue;
if (isFile && entry.name.endsWith(".md")) {
entries.push(fullPath);
}
}
} catch {
// Ignore errors
}
return entries;
}
function collectAutoThemeEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath)) continue;
if (isFile && entry.name.endsWith(".json")) {
entries.push(fullPath);
}
}
} catch {
// Ignore errors
}
return entries;
}
function readPiManifestFile(packageJsonPath: string): PiManifest | null {
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { pi?: PiManifest };
return pkg.pi ?? null;
} catch {
return null;
}
}
function resolveExtensionEntries(dir: string): string[] | null {
const packageJsonPath = join(dir, "package.json");
if (existsSync(packageJsonPath)) {
const manifest = readPiManifestFile(packageJsonPath);
if (manifest?.extensions?.length) {
const entries: string[] = [];
for (const extPath of manifest.extensions) {
const resolvedExtPath = resolve(dir, extPath);
if (existsSync(resolvedExtPath)) {
entries.push(resolvedExtPath);
}
}
if (entries.length > 0) {
return entries;
}
}
}
const indexTs = join(dir, "index.ts");
const indexJs = join(dir, "index.js");
if (existsSync(indexTs)) {
return [indexTs];
}
if (existsSync(indexJs)) {
return [indexJs];
}
return null;
}
function collectAutoExtensionEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
// First check if this directory itself has explicit extension entries (package.json or index)
const rootEntries = resolveExtensionEntries(dir);
if (rootEntries) {
return rootEntries;
}
// Otherwise, discover extensions from directory contents
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath)) continue;
if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
entries.push(fullPath);
} else if (isDir) {
const resolvedEntries = resolveExtensionEntries(fullPath);
if (resolvedEntries) {
entries.push(...resolvedEntries);
}
}
}
} catch {
// Ignore errors
}
return entries;
}
/**
* Collect resource files from a directory based on resource type.
* Extensions use smart discovery (index.ts in subdirs), others use recursive collection.
*/
function collectResourceFiles(dir: string, resourceType: ResourceType): string[] {
if (resourceType === "skills") {
return collectSkillEntries(dir, "pi");
}
if (resourceType === "extensions") {
return collectAutoExtensionEntries(dir);
}
return collectFiles(dir, FILE_PATTERNS[resourceType]);
}
function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
const parentName = isSkillFile ? basename(parentDir!) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
return patterns.some((pattern) => {
const normalizedPattern = toPosixPath(pattern);
if (
minimatch(rel, normalizedPattern) ||
minimatch(name, normalizedPattern) ||
minimatch(filePathPosix, normalizedPattern)
) {
return true;
}
if (!isSkillFile) return false;
return (
minimatch(parentRel!, normalizedPattern) ||
minimatch(parentName!, normalizedPattern) ||
minimatch(parentDirPosix!, normalizedPattern)
);
});
}
function normalizeExactPattern(pattern: string): string {
const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;
return toPosixPath(normalized);
}
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
if (patterns.length === 0) return false;
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
return patterns.some((pattern) => {
const normalized = normalizeExactPattern(pattern);
if (normalized === rel || normalized === filePathPosix) {
return true;
}
if (!isSkillFile) return false;
return normalized === parentRel || normalized === parentDirPosix;
});
}
function getOverridePatterns(entries: string[]): string[] {
return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
}
function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {
const overrides = getOverridePatterns(patterns);
const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));
const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));
let enabled = true;
if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {
enabled = false;
}
if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
enabled = true;
}
if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
enabled = false;
}
return enabled;
}
/**
* Apply patterns to paths and return a Set of enabled paths.
* Pattern types:
* - Plain patterns: include matching paths
* - `!pattern`: exclude matching paths
* - `+path`: force-include exact path (overrides exclusions)
* - `-path`: force-exclude exact path (overrides force-includes)
*/
function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set<string> {
const includes: string[] = [];
const excludes: string[] = [];
const forceIncludes: string[] = [];
const forceExcludes: string[] = [];
for (const p of patterns) {
if (p.startsWith("+")) {
forceIncludes.push(p.slice(1));
} else if (p.startsWith("-")) {
forceExcludes.push(p.slice(1));
} else if (p.startsWith("!")) {
excludes.push(p.slice(1));
} else {
includes.push(p);
}
}
// Step 1: Apply includes (or all if no includes)
let result: string[];
if (includes.length === 0) {
result = [...allPaths];
} else {
result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir));
}
// Step 2: Apply excludes
if (excludes.length > 0) {
result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir));
}
// Step 3: Force-include (add back from allPaths, overriding exclusions)
if (forceIncludes.length > 0) {
for (const filePath of allPaths) {
if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
result.push(filePath);
}
}
}
// Step 4: Force-exclude (remove even if included or force-included)
if (forceExcludes.length > 0) {
result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir));
}
return new Set(result);
}
export class DefaultPackageManager implements PackageManager {
private cwd: string;
private agentDir: string;
private settingsManager: SettingsManager;
private globalNpmRoot: string | undefined;
private globalNpmRootCommandKey: string | undefined;
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.settingsManager = options.settingsManager;
}
setProgressCallback(callback: ProgressCallback | undefined): void {
this.progressCallback = callback;
}
addSourceToSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope));
if (exists) {
return false;
}
const nextPackages = [...currentPackages, normalizedSource];
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope));
const changed = nextPackages.length !== currentPackages.length;
if (!changed) {
return false;
}
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope);
return existsSync(path) ? path : undefined;
}
if (parsed.type === "git") {
const path = this.getGitInstallPath(parsed, scope);
return existsSync(path) ? path : undefined;
}
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
const path = this.resolvePathFromBase(parsed.path, baseDir);
return existsSync(path) ? path : undefined;
}
return undefined;
}
private emitProgress(event: ProgressEvent): void {
this.progressCallback?.(event);
}
private async withProgress(
action: ProgressEvent["action"],
source: string,
message: string,
operation: () => Promise<void>,
): Promise<void> {
this.emitProgress({ type: "start", action, source, message });
try {
await operation();
this.emitProgress({ type: "complete", action, source });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action, source, message: errorMessage });
throw error;
}
}
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
// Collect all packages with scope (project first so cwd resources win collisions)
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
// Dedupe: project scope wins over global for same package identity
const packageSources = this.dedupePackages(allPackages);
await this.resolvePackageSources(packageSources, accumulator, onMissing);
const globalBaseDir = this.agentDir;
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: "project",
origin: "top-level",
},
projectBaseDir,
);
this.resolveLocalEntries(
globalEntries,
resourceType,
target,
{
source: "local",
scope: "user",
origin: "top-level",
},
globalBaseDir,
);
}
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
return this.toResolvedPaths(accumulator);
}
async resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user";
const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));
await this.resolvePackageSources(packageSources, accumulator);
return this.toResolvedPaths(accumulator);
}
listConfiguredPackages(): ConfiguredPackage[] {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const configuredPackages: ConfiguredPackage[] = [];
for (const pkg of globalSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "user",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "user"),
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
}
return configuredPackages;
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope);
return;
}
if (parsed.type === "local") {
const resolved = this.resolvePath(parsed.path);
if (!existsSync(resolved)) {
throw new Error(`Path does not exist: ${resolved}`);
}
return;
}
throw new Error(`Unsupported install source: ${source}`);
});
}
async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
return;
}
if (parsed.type === "git") {
await this.removeGit(parsed, scope);
return;
}
if (parsed.type === "local") {
return;
}
throw new Error(`Unsupported remove source: ${source}`);
});
}
async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
await this.remove(source, options);
return this.removeSourceFromSettings(source, options);
}
async update(source?: string): Promise<void> {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const identity = source ? this.getPackageIdentity(source) : undefined;
let matched = false;
const updateSources: ConfiguredUpdateSource[] = [];
for (const pkg of globalSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "user" });
}
for (const pkg of projectSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "project" });
}
if (source && !matched) {
throw new Error(
this.buildNoMatchingPackageMessage(source, [
...(globalSettings.packages ?? []),
...(projectSettings.packages ?? []),
]),
);
}
await this.updateConfiguredSources(updateSources);
}
private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise<void> {
if (isOfflineModeEnabled() || sources.length === 0) {
return;
}
const npmCandidates: NpmUpdateTarget[] = [];
const gitCandidates: GitUpdateTarget[] = [];
for (const entry of sources) {
const parsed = this.parseSource(entry.source);
if (parsed.type === "local" || parsed.pinned) {
continue;
}
if (parsed.type === "npm") {
npmCandidates.push({ ...entry, parsed });
continue;
}
gitCandidates.push({ ...entry, parsed });
}
const npmCheckTasks = npmCandidates.map((entry) => async () => ({
entry,
shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope),
}));
const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);
const userNpmUpdates: NpmUpdateTarget[] = [];
const projectNpmUpdates: NpmUpdateTarget[] = [];
for (const result of npmCheckResults) {
if (!result.shouldUpdate) {
continue;
}
if (result.entry.scope === "user") {
userNpmUpdates.push(result.entry);
} else {
projectNpmUpdates.push(result.entry);
}
}
const tasks: Promise<void>[] = [];
if (userNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));
}
if (projectNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
}
if (gitCandidates.length > 0) {
const gitTasks = gitCandidates.map(
(entry) => async () =>
this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => {
await this.updateGit(entry.parsed, entry.scope);
}),
);
tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {}));
}
await Promise.all(tasks);
}
private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> {
const installedPath = this.getNpmInstallPath(source, scope);
const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
if (!installedVersion) {
return true;
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
} catch {
// Preserve existing update behavior when version lookup fails.
return true;
}
}
private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise<void> {
if (sources.length === 0) {
return;
}
const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;
const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;
const specs = sources.map((entry) => `${entry.parsed.name}@latest`);
await this.withProgress("update", sourceLabel, message, async () => {
await this.installNpmBatch(specs, scope);
});
}
private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["install", "-g", ...specs]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]);
}
async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
if (isOfflineModeEnabled()) {
return [];
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
const packageSources = this.dedupePackages(allPackages);
const checks = packageSources
.filter(
(entry): entry is { pkg: PackageSource; scope: Exclude<SourceScope, "temporary"> } =>
entry.scope !== "temporary",
)
.map((entry) => async (): Promise<PackageUpdate | undefined> => {
const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const parsed = this.parseSource(source);
if (parsed.type === "local" || parsed.pinned) {
return undefined;
}
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, entry.scope);
if (!existsSync(installedPath)) {
return undefined;
}
const hasUpdate = await this.npmHasAvailableUpdate(parsed, installedPath);
if (!hasUpdate) {
return undefined;
}
return {
source,
displayName: parsed.name,
type: "npm",
scope: entry.scope,
};
}
const installedPath = this.getGitInstallPath(parsed, entry.scope);
if (!existsSync(installedPath)) {
return undefined;
}
const hasUpdate = await this.gitHasAvailableUpdate(installedPath);
if (!hasUpdate) {
return undefined;
}
return {
source,
displayName: `${parsed.host}/${parsed.path}`,
type: "git",
scope: entry.scope,
};
});
const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY);
return results.filter((result): result is PackageUpdate => result !== undefined);
}
private async resolvePackageSources(
sources: Array<{ pkg: PackageSource; scope: SourceScope }>,
accumulator: ResourceAccumulator,
onMissing?: (source: string) => Promise<MissingSourceAction>,
): Promise<void> {
for (const { pkg, scope } of sources) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const parsed = this.parseSource(sourceStr);
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
continue;
}
const installMissing = async (): Promise<boolean> => {
if (isOfflineModeEnabled()) {
return false;
}
if (!onMissing) {
await this.installParsedSource(parsed, scope);
return true;
}
const action = await onMissing(sourceStr);
if (action === "skip") return false;
if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
await this.installParsedSource(parsed, scope);
return true;
};
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, scope);
const needsInstall =
!existsSync(installedPath) ||
(parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath)));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
continue;
}
if (parsed.type === "git") {
const installedPath = this.getGitInstallPath(parsed, scope);
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
} else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
await this.refreshTemporaryGitSource(parsed, sourceStr);
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
}
}
}
private resolveLocalExtensionSource(
source: LocalSource,
accumulator: ResourceAccumulator,
filter: PackageFilter | undefined,
metadata: PathMetadata,
baseDir: string,
): void {
const resolved = this.resolvePathFromBase(source.path, baseDir);
if (!existsSync(resolved)) {
return;
}
try {
const stats = statSync(resolved);
if (stats.isFile()) {
metadata.baseDir = dirname(resolved);
this.addResource(accumulator.extensions, resolved, metadata, true);
return;
}
if (stats.isDirectory()) {
metadata.baseDir = resolved;
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
if (!resources) {
this.addResource(accumulator.extensions, resolved, metadata, true);
}
}
} catch {
return;
}
}
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, scope === "temporary");
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope);
return;
}
}
private getPackageSourceString(pkg: PackageSource): string {
return typeof pkg === "string" ? pkg : pkg.source;
}
private getSourceMatchKeyForInput(source: string): string {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.host}/${parsed.path}`;
}
return `local:${this.resolvePath(parsed.path)}`;
}
private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.host}/${parsed.path}`;
}
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
}
private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string {
const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages);
if (!suggestion) {
return `No matching package found for ${source}`;
}
return `No matching package found for ${source}. Did you mean ${suggestion}?`;
}
private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined {
const trimmedSource = source.trim();
const suggestions = new Set<string>();
for (const pkg of configuredPackages) {
const sourceStr = this.getPackageSourceString(pkg);
const parsed = this.parseSource(sourceStr);
if (parsed.type === "npm") {
if (trimmedSource === parsed.name || trimmedSource === parsed.spec) {
suggestions.add(sourceStr);
}
continue;
}
if (parsed.type === "git") {
const shorthand = `${parsed.host}/${parsed.path}`;
const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined;
if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) {
suggestions.add(sourceStr);
}
}
}
return suggestions.values().next().value;
}
private packageSourcesMatch(existing: PackageSource, inputSource: string, scope: SourceScope): boolean {
const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope);
const right = this.getSourceMatchKeyForInput(inputSource);
return left === right;
}
private normalizePackageSourceForSettings(source: string, scope: SourceScope): string {
const parsed = this.parseSource(source);
if (parsed.type !== "local") {
return source;
}
const baseDir = this.getBaseDirForScope(scope);
const resolved = this.resolvePath(parsed.path);
const rel = relative(baseDir, resolved);
return rel || ".";
}
private parseSource(source: string): ParsedSource {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const { name, version } = this.parseNpmSpec(spec);
return {
type: "npm",
spec,
name,
pinned: Boolean(version),
};
}
if (isLocalPath(source)) {
return { type: "local", path: source };
}
// Try parsing as git URL
const gitParsed = parseGitUrl(source);
if (gitParsed) {
return gitParsed;
}
return { type: "local", path: source };
}
private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise<boolean> {
const installedVersion = this.getInstalledNpmVersion(installedPath);
if (!installedVersion) {
return false;
}
const { version: pinnedVersion } = this.parseNpmSpec(source.spec);
if (!pinnedVersion) {
return true;
}
return installedVersion === pinnedVersion;
}
private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
if (isOfflineModeEnabled()) {
return false;
}
const installedVersion = this.getInstalledNpmVersion(installedPath);
if (!installedVersion) {
return false;
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
} catch {
return false;
}
}
private getInstalledNpmVersion(installedPath: string): string | undefined {
const packageJsonPath = join(installedPath, "package.json");
if (!existsSync(packageJsonPath)) return undefined;
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { version?: string };
return pkg.version;
} catch {
return undefined;
}
}
private async getLatestNpmVersion(packageName: string): Promise<string> {
const npmCommand = this.getNpmCommand();
const stdout = await this.runCommandCapture(
npmCommand.command,
[...npmCommand.args, "view", packageName, "version", "--json"],
{ cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS },
);
const raw = stdout.trim();
if (!raw) throw new Error("Empty response from npm view");
return JSON.parse(raw);
}
private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> {
if (isOfflineModeEnabled()) {
return false;
}
try {
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const remoteHead = await this.getRemoteGitHead(installedPath);
return localHead.trim() !== remoteHead.trim();
} catch {
return false;
}
}
private async getRemoteGitHead(installedPath: string): Promise<string> {
const upstreamRef = await this.getGitUpstreamRef(installedPath);
if (upstreamRef) {
const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", upstreamRef]);
const match = remoteHead.match(/^([0-9a-f]{40})\s+/m);
if (match?.[1]) {
return match[1];
}
}
const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", "HEAD"]);
const match = remoteHead.match(/^([0-9a-f]{40})\s+HEAD$/m);
if (!match?.[1]) {
throw new Error("Failed to determine remote HEAD");
}
return match[1];
}
private async getLocalGitUpdateTarget(
installedPath: string,
): Promise<{ ref: string; head: string; fetchArgs: string[] }> {
try {
const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const trimmedUpstream = upstream.trim();
if (!trimmedUpstream.startsWith("origin/")) {
throw new Error(`Unsupported upstream remote: ${trimmedUpstream}`);
}
const branch = trimmedUpstream.slice("origin/".length);
if (!branch) {
throw new Error("Missing upstream branch name");
}
const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
return {
ref: "@{upstream}",
head,
fetchArgs: [
"fetch",
"--prune",
"--no-tags",
"origin",
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
],
};
} catch {
await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: installedPath }).catch(() => {});
const head = await this.runCommandCapture("git", ["rev-parse", "origin/HEAD"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const originHeadRef = await this.runCommandCapture("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
}).catch(() => "");
const branch = originHeadRef.trim().replace(/^refs\/remotes\/origin\//, "");
if (branch) {
return {
ref: "origin/HEAD",
head,
fetchArgs: [
"fetch",
"--prune",
"--no-tags",
"origin",
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
],
};
}
return {
ref: "origin/HEAD",
head,
fetchArgs: ["fetch", "--prune", "--no-tags", "origin", "+HEAD:refs/remotes/origin/HEAD"],
};
}
}
private async getGitUpstreamRef(installedPath: string): Promise<string | undefined> {
try {
const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const trimmed = upstream.trim();
if (!trimmed.startsWith("origin/")) {
return undefined;
}
const branch = trimmed.slice("origin/".length);
return branch ? `refs/heads/${branch}` : undefined;
} catch {
return undefined;
}
}
private runGitRemoteCommand(installedPath: string, args: string[]): Promise<string> {
return this.runCommandCapture("git", args, {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
env: {
GIT_TERMINAL_PROMPT: "0",
},
});
}
private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) {
return [];
}
const results: T[] = new Array(tasks.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(limit, tasks.length));
const worker = async () => {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= tasks.length) {
return;
}
results[index] = await tasks[index]();
}
};
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
/**
* Get a unique identity for a package, ignoring version/ref.
* Used to detect when the same package is in both global and project settings.
* For git packages, uses normalized host/path to ensure SSH and HTTPS URLs
* for the same repository are treated as identical.
*/
private getPackageIdentity(source: string, scope?: SourceScope): string {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
// Use host/path for identity to normalize SSH and HTTPS
return `git:${parsed.host}/${parsed.path}`;
}
if (scope) {
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
}
return `local:${this.resolvePath(parsed.path)}`;
}
/**
* Dedupe packages: if same package identity appears in both global and project,
* keep only the project one (project wins).
*/
private dedupePackages(
packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
): Array<{ pkg: PackageSource; scope: SourceScope }> {
const seen = new Map<string, { pkg: PackageSource; scope: SourceScope }>();
for (const entry of packages) {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const identity = this.getPackageIdentity(sourceStr, entry.scope);
const existing = seen.get(identity);
if (!existing) {
seen.set(identity, entry);
} else if (entry.scope === "project" && existing.scope === "user") {
// Project wins over user
seen.set(identity, entry);
}
// If existing is project and new is global, keep existing (project)
// If both are same scope, keep first one
}
return Array.from(seen.values());
}
private parseNpmSpec(spec: string): { name: string; version?: string } {
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
if (!match) {
return { name: spec };
}
const name = match[1] ?? spec;
const version = match[2];
return { name, version };
}
private getNpmCommand(): { command: string; args: string[] } {
const configuredCommand = this.settingsManager.getNpmCommand();
if (!configuredCommand || configuredCommand.length === 0) {
return { command: "npm", args: [] };
}
const [command, ...args] = configuredCommand;
if (!command) {
throw new Error("Invalid npmCommand: first array entry must be a non-empty command");
}
return { command, args };
}
private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise<void> {
const npmCommand = this.getNpmCommand();
await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options);
}
private runNpmCommandSync(args: string[]): string {
const npmCommand = this.getNpmCommand();
return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]);
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "user" && !temporary) {
await this.runNpmCommand(["install", "-g", source.spec]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", source.spec, "--prefix", installRoot]);
}
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) {
return;
}
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
return;
}
const gitRoot = this.getGitInstallRoot(scope);
if (gitRoot) {
this.ensureGitIgnore(gitRoot);
}
mkdirSync(dirname(targetDir), { recursive: true });
await this.runCommand("git", ["clone", source.repo, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}
const packageJsonPath = join(targetDir, "package.json");
if (existsSync(packageJsonPath)) {
await this.runNpmCommand(["install", "--omit=dev"], { cwd: targetDir });
}
}
private async updateGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (!existsSync(targetDir)) {
await this.installGit(source, scope);
return;
}
const target = await this.getLocalGitUpdateTarget(targetDir);
// Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.
await this.runCommand("git", target.fetchArgs, { cwd: targetDir });
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
if (localHead.trim() === refreshedTargetHead.trim()) {
return;
}
await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir });
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
const packageJsonPath = join(targetDir, "package.json");
if (existsSync(packageJsonPath)) {
await this.runNpmCommand(["install", "--omit=dev"], { cwd: targetDir });
}
}
private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> {
if (isOfflineModeEnabled()) {
return;
}
try {
await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => {
await this.updateGit(source, "temporary");
});
} catch {
// Keep cached temporary checkout if refresh fails.
}
}
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (!existsSync(targetDir)) return;
rmSync(targetDir, { recursive: true, force: true });
this.pruneEmptyGitParents(targetDir, this.getGitInstallRoot(scope));
}
private pruneEmptyGitParents(targetDir: string, installRoot: string | undefined): void {
if (!installRoot) return;
const resolvedRoot = resolve(installRoot);
let current = dirname(targetDir);
while (current.startsWith(resolvedRoot) && current !== resolvedRoot) {
if (!existsSync(current)) {
current = dirname(current);
continue;
}
const entries = readdirSync(current);
if (entries.length > 0) {
break;
}
try {
rmSync(current, { recursive: true, force: true });
} catch {
break;
}
current = dirname(current);
}
}
private ensureNpmProject(installRoot: string): void {
if (!existsSync(installRoot)) {
mkdirSync(installRoot, { recursive: true });
}
this.ensureGitIgnore(installRoot);
const packageJsonPath = join(installRoot, "package.json");
if (!existsSync(packageJsonPath)) {
const pkgJson = { name: "pi-extensions", private: true };
writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
}
}
private ensureGitIgnore(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const ignorePath = join(dir, ".gitignore");
if (!existsSync(ignorePath)) {
writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8");
}
}
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
if (temporary) {
return this.getTemporaryDir("npm");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.getGlobalNpmRoot(), "..");
}
private getGlobalNpmRoot(): string {
const npmCommand = this.getNpmCommand();
const commandKey = [npmCommand.command, ...npmCommand.args].join("\0");
if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) {
return this.globalNpmRoot;
}
const result = this.runNpmCommandSync(["root", "-g"]);
this.globalNpmRoot = result.trim();
this.globalNpmRootCommandKey = commandKey;
return this.globalNpmRoot;
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.getGlobalNpmRoot(), source.name);
}
private getGitInstallPath(source: GitSource, scope: SourceScope): string {
if (scope === "temporary") {
return this.getTemporaryDir(`git-${source.host}`, source.path);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
}
return join(this.agentDir, "git", source.host, source.path);
}
private getGitInstallRoot(scope: SourceScope): string | undefined {
if (scope === "temporary") {
return undefined;
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git");
}
return join(this.agentDir, "git");
}
private getTemporaryDir(prefix: string, suffix?: string): string {
const hash = createHash("sha256")
.update(`${prefix}-${suffix ?? ""}`)
.digest("hex")
.slice(0, 8);
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
}
private getBaseDirForScope(scope: SourceScope): string {
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME);
}
if (scope === "user") {
return this.agentDir;
}
return this.cwd;
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
}
private collectPackageResources(
packageRoot: string,
accumulator: ResourceAccumulator,
filter: PackageFilter | undefined,
metadata: PathMetadata,
): boolean {
if (filter) {
for (const resourceType of RESOURCE_TYPES) {
const patterns = filter[resourceType as keyof PackageFilter];
const target = this.getTargetMap(accumulator, resourceType);
if (patterns !== undefined) {
this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata);
} else {
this.collectDefaultResources(packageRoot, resourceType, target, metadata);
}
}
return true;
}
const manifest = this.readPiManifest(packageRoot);
if (manifest) {
for (const resourceType of RESOURCE_TYPES) {
const entries = manifest[resourceType as keyof PiManifest];
this.addManifestEntries(
entries,
packageRoot,
resourceType,
this.getTargetMap(accumulator, resourceType),
metadata,
);
}
return true;
}
let hasAnyDir = false;
for (const resourceType of RESOURCE_TYPES) {
const dir = join(packageRoot, resourceType);
if (existsSync(dir)) {
// Collect all files from the directory (all enabled by default)
const files = collectResourceFiles(dir, resourceType);
for (const f of files) {
this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true);
}
hasAnyDir = true;
}
}
return hasAnyDir;
}
private collectDefaultResources(
packageRoot: string,
resourceType: ResourceType,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
metadata: PathMetadata,
): void {
const manifest = this.readPiManifest(packageRoot);
const entries = manifest?.[resourceType as keyof PiManifest];
if (entries) {
this.addManifestEntries(entries, packageRoot, resourceType, target, metadata);
return;
}
const dir = join(packageRoot, resourceType);
if (existsSync(dir)) {
// Collect all files from the directory (all enabled by default)
const files = collectResourceFiles(dir, resourceType);
for (const f of files) {
this.addResource(target, f, metadata, true);
}
}
}
private applyPackageFilter(
packageRoot: string,
userPatterns: string[],
resourceType: ResourceType,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
metadata: PathMetadata,
): void {
const { allFiles } = this.collectManifestFiles(packageRoot, resourceType);
if (userPatterns.length === 0) {
// Empty array explicitly disables all resources of this type
for (const f of allFiles) {
this.addResource(target, f, metadata, false);
}
return;
}
// Apply user patterns
const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot);
for (const f of allFiles) {
const enabled = enabledByUser.has(f);
this.addResource(target, f, metadata, enabled);
}
}
/**
* Collect all files from a package for a resource type, applying manifest patterns.
* Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files
* that pass the manifest's own patterns.
*/
private collectManifestFiles(
packageRoot: string,
resourceType: ResourceType,
): { allFiles: string[]; enabledByManifest: Set<string> } {
const manifest = this.readPiManifest(packageRoot);
const entries = manifest?.[resourceType as keyof PiManifest];
if (entries && entries.length > 0) {
const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType);
const manifestPatterns = entries.filter(isOverridePattern);
const enabledByManifest =
manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles);
return { allFiles: Array.from(enabledByManifest), enabledByManifest };
}
const conventionDir = join(packageRoot, resourceType);
if (!existsSync(conventionDir)) {
return { allFiles: [], enabledByManifest: new Set() };
}
const allFiles = collectResourceFiles(conventionDir, resourceType);
return { allFiles, enabledByManifest: new Set(allFiles) };
}
private readPiManifest(packageRoot: string): PiManifest | null {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { pi?: PiManifest };
return pkg.pi ?? null;
} catch {
return null;
}
}
private addManifestEntries(
entries: string[] | undefined,
root: string,
resourceType: ResourceType,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
metadata: PathMetadata,
): void {
if (!entries) return;
const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType);
const patterns = entries.filter(isOverridePattern);
const enabledPaths = applyPatterns(allFiles, patterns, root);
for (const f of allFiles) {
if (enabledPaths.has(f)) {
this.addResource(target, f, metadata, true);
}
}
}
private collectFilesFromManifestEntries(entries: string[], root: string, resourceType: ResourceType): string[] {
const sourceEntries = entries.filter((entry) => !isOverridePattern(entry));
const resolved = sourceEntries.flatMap((entry) => {
if (!hasGlobPattern(entry)) {
return [resolve(root, entry)];
}
return globSync(entry, {
cwd: root,
absolute: true,
dot: false,
nodir: false,
}).map((match) => resolve(match));
});
return this.collectFilesFromPaths(resolved, resourceType);
}
private resolveLocalEntries(
entries: string[],
resourceType: ResourceType,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
metadata: PathMetadata,
baseDir: string,
): void {
if (entries.length === 0) return;
// Collect all files from plain entries (non-pattern entries)
const { plain, patterns } = splitPatterns(entries);
const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir));
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
// Determine which files are enabled based on patterns
const enabledPaths = applyPatterns(allFiles, patterns, baseDir);
// Add all files with their enabled state
for (const f of allFiles) {
this.addResource(target, f, metadata, enabledPaths.has(f));
}
}
private addAutoDiscoveredResources(
accumulator: ResourceAccumulator,
globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>,
projectSettings: ReturnType<SettingsManager["getProjectSettings"]>,
globalBaseDir: string,
projectBaseDir: string,
): void {
const userMetadata: PathMetadata = {
source: "auto",
scope: "user",
origin: "top-level",
baseDir: globalBaseDir,
};
const projectMetadata: PathMetadata = {
source: "auto",
scope: "project",
origin: "top-level",
baseDir: projectBaseDir,
};
const userOverrides = {
extensions: (globalSettings.extensions ?? []) as string[],
skills: (globalSettings.skills ?? []) as string[],
prompts: (globalSettings.prompts ?? []) as string[],
themes: (globalSettings.themes ?? []) as string[],
};
const projectOverrides = {
extensions: (projectSettings.extensions ?? []) as string[],
skills: (projectSettings.skills ?? []) as string[],
prompts: (projectSettings.prompts ?? []) as string[],
themes: (projectSettings.themes ?? []) as string[],
};
const userDirs = {
extensions: join(globalBaseDir, "extensions"),
skills: join(globalBaseDir, "skills"),
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
);
const addResources = (
resourceType: ResourceType,
paths: string[],
metadata: PathMetadata,
overrides: string[],
baseDir: string,
) => {
const target = this.getTargetMap(accumulator, resourceType);
for (const path of paths) {
const enabled = isEnabledByOverrides(path, overrides, baseDir);
this.addResource(target, path, metadata, enabled);
}
};
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
addResources(
"skills",
[
...collectAutoSkillEntries(projectDirs.skills, "pi"),
...projectAgentsSkillDirs.flatMap((dir) => collectAutoSkillEntries(dir, "agents")),
],
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
projectOverrides.themes,
projectBaseDir,
);
addResources(
"extensions",
collectAutoExtensionEntries(userDirs.extensions),
userMetadata,
userOverrides.extensions,
globalBaseDir,
);
addResources(
"skills",
[...collectAutoSkillEntries(userDirs.skills, "pi"), ...collectAutoSkillEntries(userAgentsSkillsDir, "agents")],
userMetadata,
userOverrides.skills,
globalBaseDir,
);
addResources(
"prompts",
collectAutoPromptEntries(userDirs.prompts),
userMetadata,
userOverrides.prompts,
globalBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(userDirs.themes),
userMetadata,
userOverrides.themes,
globalBaseDir,
);
}
private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {
const files: string[] = [];
for (const p of paths) {
if (!existsSync(p)) continue;
try {
const stats = statSync(p);
if (stats.isFile()) {
files.push(p);
} else if (stats.isDirectory()) {
files.push(...collectResourceFiles(p, resourceType));
}
} catch {
// Ignore errors
}
}
return files;
}
private getTargetMap(
accumulator: ResourceAccumulator,
resourceType: ResourceType,
): Map<string, { metadata: PathMetadata; enabled: boolean }> {
switch (resourceType) {
case "extensions":
return accumulator.extensions;
case "skills":
return accumulator.skills;
case "prompts":
return accumulator.prompts;
case "themes":
return accumulator.themes;
default:
throw new Error(`Unknown resource type: ${resourceType}`);
}
}
private addResource(
map: Map<string, { metadata: PathMetadata; enabled: boolean }>,
path: string,
metadata: PathMetadata,
enabled: boolean,
): void {
if (!path) return;
if (!map.has(path)) {
map.set(path, { metadata, enabled });
}
}
private createAccumulator(): ResourceAccumulator {
return {
extensions: new Map(),
skills: new Map(),
prompts: new Map(),
themes: new Map(),
};
}
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
const toResolved = (entries: Map<string, { metadata: PathMetadata; enabled: boolean }>): ResolvedResource[] => {
const resolved = Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({
path,
enabled,
metadata,
}));
resolved.sort((a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata));
return resolved;
};
const seenCanonicalSkillPaths = new Set<string>();
const resolvedSkills = toResolved(accumulator.skills).filter((entry) => {
let canonicalPath: string;
try {
// Resolve symlink aliases to detect duplicate files.
canonicalPath = realpathSync(entry.path);
} catch {
// Fallback to raw path to match loadSkills() behavior.
canonicalPath = entry.path;
}
if (seenCanonicalSkillPaths.has(canonicalPath)) {
return false;
}
seenCanonicalSkillPaths.add(canonicalPath);
return true;
});
return {
extensions: toResolved(accumulator.extensions),
skills: resolvedSkills,
prompts: toResolved(accumulator.prompts),
themes: toResolved(accumulator.themes),
};
}
private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess {
return spawn(command, args, {
cwd: options?.cwd,
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
shell: process.platform === "win32",
});
}
private spawnCaptureCommand(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> },
): ChildProcessByStdio<null, Readable, Readable> {
return spawn(command, args, {
cwd: options?.cwd,
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
env: options?.env ? { ...process.env, ...options.env } : process.env,
});
}
private runCommandCapture(
command: string,
args: string[],
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
): Promise<string> {
return new Promise((resolvePromise, reject) => {
const child = this.spawnCaptureCommand(command, args, options);
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout =
typeof options?.timeoutMs === "number"
? setTimeout(() => {
timedOut = true;
child.kill();
}, options.timeoutMs)
: undefined;
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.once("error", (error) => {
if (timeout) clearTimeout(timeout);
reject(error);
});
child.once("close", (code, signal) => {
if (timeout) clearTimeout(timeout);
if (timedOut) {
reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`));
return;
}
if (code === 0) {
resolvePromise(stdout.trim());
return;
}
const exitStatus = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
reject(new Error(`${command} ${args.join(" ")} failed with ${exitStatus}: ${stderr || stdout}`));
});
});
}
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
return new Promise((resolvePromise, reject) => {
const child = this.spawnCommand(command, args, options);
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
} else {
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
}
});
});
}
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
shell: process.platform === "win32",
});
if (result.status !== 0) {
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
}
return (result.stdout || result.stderr || "").trim();
}
}