Improve project-local pi config (#6309)
* feat(coding-agent): improve config resource overrides * fix(coding-agent): simplify config resource overrides
This commit is contained in:
@@ -188,6 +188,7 @@ function resourcePrecedenceRank(m: PathMetadata): number {
|
||||
}
|
||||
|
||||
interface PackageFilter {
|
||||
autoload?: boolean;
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
prompts?: string[];
|
||||
@@ -772,6 +773,25 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
|
||||
return new Set(result);
|
||||
}
|
||||
|
||||
function applyAutoloadDisabledPatterns(allPaths: string[], patterns: string[], baseDir: string): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const pattern of patterns) {
|
||||
const target = pattern.slice(
|
||||
pattern.startsWith("+") || pattern.startsWith("-") || pattern.startsWith("!") ? 1 : 0,
|
||||
);
|
||||
const enabled = !pattern.startsWith("-") && !pattern.startsWith("!");
|
||||
const exact = pattern.startsWith("+") || pattern.startsWith("-");
|
||||
for (const filePath of allPaths) {
|
||||
if (
|
||||
exact ? matchesAnyExactPattern(filePath, [target], baseDir) : matchesAnyPattern(filePath, [target], baseDir)
|
||||
) {
|
||||
result.set(filePath, enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class DefaultPackageManager implements PackageManager {
|
||||
private cwd: string;
|
||||
private agentDir: string;
|
||||
@@ -1225,38 +1245,39 @@ export class DefaultPackageManager implements PackageManager {
|
||||
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 deltaBase = this.findAutoloadDeltaBase(pkg, scope, sources);
|
||||
const resolvedSource = deltaBase?.source ?? sourceStr;
|
||||
const resolvedScope = deltaBase?.scope ?? scope;
|
||||
const parsed = this.parseSource(resolvedSource);
|
||||
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
|
||||
|
||||
if (parsed.type === "local") {
|
||||
const baseDir = this.getBaseDirForScope(scope);
|
||||
const baseDir = this.getBaseDirForScope(resolvedScope);
|
||||
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
const installMissing = async (): Promise<boolean> => {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
if (isOfflineModeEnabled()) return false;
|
||||
if (!onMissing) {
|
||||
await this.installParsedSource(parsed, scope);
|
||||
await this.installParsedSource(parsed, resolvedScope);
|
||||
return true;
|
||||
}
|
||||
const action = await onMissing(sourceStr);
|
||||
const action = await onMissing(resolvedSource);
|
||||
if (action === "skip") return false;
|
||||
if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
|
||||
await this.installParsedSource(parsed, scope);
|
||||
if (action === "error") throw new Error(`Missing source: ${resolvedSource}`);
|
||||
await this.installParsedSource(parsed, resolvedScope);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (parsed.type === "npm") {
|
||||
let installedPath = this.getNpmInstallPath(parsed, scope);
|
||||
let installedPath = this.getNpmInstallPath(parsed, resolvedScope);
|
||||
const needsInstall =
|
||||
!existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));
|
||||
if (needsInstall) {
|
||||
const installed = await installMissing();
|
||||
if (!installed) continue;
|
||||
installedPath = this.getNpmInstallPath(parsed, scope);
|
||||
installedPath = this.getNpmInstallPath(parsed, resolvedScope);
|
||||
}
|
||||
metadata.baseDir = installedPath;
|
||||
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
||||
@@ -1264,12 +1285,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
if (parsed.type === "git") {
|
||||
const installedPath = this.getGitInstallPath(parsed, scope);
|
||||
const installedPath = this.getGitInstallPath(parsed, resolvedScope);
|
||||
if (!existsSync(installedPath)) {
|
||||
const installed = await installMissing();
|
||||
if (!installed) continue;
|
||||
} else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
|
||||
await this.refreshTemporaryGitSource(parsed, sourceStr);
|
||||
} else if (resolvedScope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
|
||||
await this.refreshTemporaryGitSource(parsed, resolvedSource);
|
||||
}
|
||||
metadata.baseDir = installedPath;
|
||||
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
||||
@@ -1277,6 +1298,21 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
private findAutoloadDeltaBase(
|
||||
pkg: PackageSource,
|
||||
scope: SourceScope,
|
||||
sources: Array<{ pkg: PackageSource; scope: SourceScope }>,
|
||||
): { source: string; scope: SourceScope } | undefined {
|
||||
if (scope !== "project" || typeof pkg !== "object" || pkg.autoload !== false) return undefined;
|
||||
const identity = this.getPackageIdentity(pkg.source, scope);
|
||||
const userEntry = sources.find(
|
||||
(entry) =>
|
||||
entry.scope === "user" &&
|
||||
this.getPackageIdentity(this.getPackageSourceString(entry.pkg), "user") === identity,
|
||||
);
|
||||
return userEntry ? { source: this.getPackageSourceString(userEntry.pkg), scope: "user" } : undefined;
|
||||
}
|
||||
|
||||
private resolveLocalExtensionSource(
|
||||
source: LocalSource,
|
||||
accumulator: ResourceAccumulator,
|
||||
@@ -1655,29 +1691,30 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
/**
|
||||
* Dedupe packages: if same package identity appears in both global and project,
|
||||
* keep only the project one (project wins).
|
||||
* keep only the project one (project wins). A project entry with autoload=false
|
||||
* is a delta over the global entry, so both are kept (delta first).
|
||||
*/
|
||||
private dedupePackages(
|
||||
packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
|
||||
): Array<{ pkg: PackageSource; scope: SourceScope }> {
|
||||
const seen = new Map<string, { pkg: PackageSource; scope: SourceScope }>();
|
||||
|
||||
const result: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
|
||||
const seen = new Map<string, number>();
|
||||
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);
|
||||
const identity = this.getPackageIdentity(this.getPackageSourceString(entry.pkg), entry.scope);
|
||||
const index = seen.get(identity);
|
||||
if (index === undefined) {
|
||||
seen.set(identity, result.length);
|
||||
result.push(entry);
|
||||
continue;
|
||||
}
|
||||
const existing = result[index];
|
||||
if (existing?.scope === "project" && entry.scope === "user") {
|
||||
if (typeof existing.pkg === "object" && existing.pkg.autoload === false) result.push(entry);
|
||||
} else if (entry.scope === "project") {
|
||||
result[index] = 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());
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseNpmSpec(spec: string): { name: string; version?: string } {
|
||||
@@ -2047,9 +2084,11 @@ export class DefaultPackageManager implements PackageManager {
|
||||
): boolean {
|
||||
if (filter) {
|
||||
for (const resourceType of RESOURCE_TYPES) {
|
||||
const patterns = filter[resourceType as keyof PackageFilter];
|
||||
const patterns = filter[resourceType];
|
||||
const target = this.getTargetMap(accumulator, resourceType);
|
||||
if (patterns !== undefined) {
|
||||
if (filter.autoload === false) {
|
||||
this.applyPackageDeltaFilter(packageRoot, patterns ?? [], resourceType, target, metadata);
|
||||
} else if (patterns !== undefined) {
|
||||
this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata);
|
||||
} else {
|
||||
this.collectDefaultResources(packageRoot, resourceType, target, metadata);
|
||||
@@ -2136,6 +2175,24 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
private applyPackageDeltaFilter(
|
||||
packageRoot: string,
|
||||
userPatterns: string[],
|
||||
resourceType: ResourceType,
|
||||
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||
metadata: PathMetadata,
|
||||
): void {
|
||||
if (userPatterns.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { allFiles } = this.collectManifestFiles(packageRoot, resourceType);
|
||||
const enabledByUser = applyAutoloadDisabledPatterns(allFiles, userPatterns, packageRoot);
|
||||
for (const [filePath, enabled] of enabledByUser) {
|
||||
this.addResource(target, filePath, 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
|
||||
|
||||
@@ -66,11 +66,13 @@ export type TransportSetting = Transport;
|
||||
* Package source for npm/git packages.
|
||||
* - String form: load all resources from the package
|
||||
* - Object form: filter which resources to load
|
||||
* - autoload=false: start empty and only apply explicit resource patterns
|
||||
*/
|
||||
export type PackageSource =
|
||||
| string
|
||||
| {
|
||||
source: string;
|
||||
autoload?: boolean;
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
prompts?: string[];
|
||||
|
||||
Reference in New Issue
Block a user