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:
Armin Ronacher
2026-07-06 20:50:30 +02:00
committed by GitHub
parent 8c0ccd14b3
commit c8ada4e76e
9 changed files with 599 additions and 81 deletions
+2 -2
View File
@@ -216,11 +216,11 @@ Filter what a package loads using the object form in settings:
## Enable and Disable Resources
Use `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. Works for both global (`~/.pi/agent`) and project (`.pi/`) scopes.
Use `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. `pi config` starts in global settings (`~/.pi/agent/settings.json`); press Tab to switch between global and project-local modes. Use `pi config -l` to start in project overrides (`.pi/settings.json`) with inherited global resources dimmed.
## Scope and Deduplication
Packages can appear in both global and project settings. If the same package appears in both, the project entry wins. Identity is determined by:
Packages can appear in both global and project settings. If the same package appears in both, the project entry wins unless the project entry has `autoload: false`, in which case it is applied as a delta over the global entry. Identity is determined by:
- npm: package name
- git: repository URL without ref
+2 -2
View File
@@ -231,8 +231,8 @@ ${chalk.bold("Commands:")}
${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions)
${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
@@ -3,16 +3,17 @@
*/
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
import type { ResolvedPaths } from "../core/package-manager.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.ts";
import { ConfigSelectorComponent, type ScopedResolvedPaths } from "../modes/interactive/components/config-selector.ts";
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts";
export interface ConfigSelectorOptions {
resolvedPaths: ResolvedPaths;
resolvedPaths: ScopedResolvedPaths;
settingsManager: SettingsManager;
cwd: string;
agentDir: string;
writeScope: "global" | "project";
projectModeAvailable: boolean;
}
/** Show TUI config selector and return when closed */
@@ -44,6 +45,8 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
},
() => ui.requestRender(),
ui.terminal.rows,
options.writeScope,
options.projectModeAvailable,
);
ui.addChild(selector);
@@ -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[];
@@ -18,11 +18,18 @@ import {
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { rawKeyHint } from "./keybinding-hints.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
type ConfigWriteScope = "global" | "project";
type SettingsScope = "user" | "project";
type ProjectOverrideState = "inherit" | "load" | "unload";
export type ScopedResolvedPaths = Record<ConfigWriteScope, ResolvedPaths>;
const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceType[];
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
extensions: "Extensions",
@@ -178,25 +185,42 @@ type FlatEntry =
| { type: "item"; item: ResourceItem };
class ConfigSelectorHeader implements Component {
private writeScope: ConfigWriteScope;
private projectModeAvailable: boolean;
constructor(writeScope: ConfigWriteScope, projectModeAvailable: boolean) {
this.writeScope = writeScope;
this.projectModeAvailable = projectModeAvailable;
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
}
invalidate(): void {}
render(width: number): string[] {
const title = theme.bold("Resource Configuration");
const title = theme.bold(this.writeScope === "project" ? "Project Local Resources" : "Global Resources");
const sep = theme.fg("muted", " · ");
const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close");
const hintWidth = visibleWidth(hint);
const titleWidth = visibleWidth(title);
const spacing = Math.max(1, width - titleWidth - hintWidth);
const switchHint = this.projectModeAvailable ? keyHint("tui.input.tab", "switch mode") + sep : "";
const actionHint =
this.writeScope === "project" ? rawKeyHint("space", "cycle inherit/+/-") : rawKeyHint("space", "toggle");
const hint = switchHint + actionHint + sep + rawKeyHint("esc", "close");
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
const scopeHint =
this.writeScope === "project"
? theme.fg("muted", `${CONFIG_DIR_NAME}/settings.json · inherited global resources are dimmed`)
: theme.fg("muted", `~/${CONFIG_DIR_NAME}/agent/settings.json`);
return [
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
theme.fg("muted", "Type to filter resources"),
truncateToWidth(scopeHint, width, ""),
];
}
}
class ResourceList implements Component, Focusable {
private groups: ResourceGroup[];
private groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>;
private flatItems: FlatEntry[] = [];
private filteredItems: FlatEntry[] = [];
private selectedIndex = 0;
@@ -205,10 +229,13 @@ class ResourceList implements Component, Focusable {
private settingsManager: SettingsManager;
private cwd: string;
private agentDir: string;
private writeScope: ConfigWriteScope;
private inheritedEnabledByKey: Map<string, boolean>;
public onCancel?: () => void;
public onExit?: () => void;
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
public onSwitchMode?: () => void;
private _focused = false;
get focused(): boolean {
@@ -220,16 +247,19 @@ class ResourceList implements Component, Focusable {
}
constructor(
groups: ResourceGroup[],
groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
) {
this.groups = groups;
this.groupsByScope = groupsByScope;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.writeScope = writeScope;
this.inheritedEnabledByKey = this.buildInheritedEnabledMap(groupsByScope.global);
this.searchInput = new Input();
// 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border
const chrome = 8;
@@ -238,6 +268,28 @@ class ResourceList implements Component, Focusable {
this.filteredItems = [...this.flatItems];
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
this.buildFlatList();
this.filterItems(this.searchInput.getValue());
}
private get groups(): ResourceGroup[] {
return this.groupsByScope[this.writeScope];
}
private buildInheritedEnabledMap(groups: ResourceGroup[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const group of groups) {
for (const subgroup of group.subgroups) {
for (const item of subgroup.items) {
result.set(this.getResourceItemKey(item), item.enabled);
}
}
}
return result;
}
private buildFlatList(): void {
this.flatItems = [];
for (const group of this.groups) {
@@ -362,19 +414,29 @@ class ResourceList implements Component, Focusable {
if (entry.type === "group") {
// Main group header (no cursor)
const groupLine = theme.fg("accent", theme.bold(entry.group.label));
const inherited = this.writeScope === "project" && entry.group.scope === "user";
const label = theme.bold(`${entry.group.label}${inherited ? " · inherited global" : ""}`);
const groupLine = theme.fg(inherited ? "dim" : "accent", label);
lines.push(truncateToWidth(` ${groupLine}`, width, ""));
} else if (entry.type === "subgroup") {
// Subgroup header (indented, no cursor)
const subgroupLine = theme.fg("muted", entry.subgroup.label);
const color = this.writeScope === "project" && entry.group.scope === "user" ? "dim" : "muted";
const subgroupLine = theme.fg(color, entry.subgroup.label);
lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
} else {
// Resource item (cursor only on items)
const item = entry.item;
const cursor = isSelected ? "> " : " ";
const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
const name = isSelected ? theme.bold(item.displayName) : item.displayName;
lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "..."));
const dimmed = this.isDimmedItem(item);
const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName;
const name = dimmed ? theme.fg("dim", nameText) : nameText;
lines.push(
truncateToWidth(
`${cursor} ${this.renderCheckbox(item)} ${name}${this.getItemSuffix(item)}`,
width,
"...",
),
);
}
}
@@ -430,13 +492,18 @@ class ResourceList implements Component, Focusable {
this.onExit?.();
return;
}
if (kb.matches(data, "tui.input.tab")) {
this.onSwitchMode?.();
return;
}
if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex];
if (entry?.type === "item") {
const newEnabled = !entry.item.enabled;
this.toggleResource(entry.item, newEnabled);
this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled);
if (entry?.type === "item" && (this.writeScope === "project" || this.getItemScope(entry.item) === "user")) {
const newEnabled = this.toggleResource(entry.item);
if (newEnabled !== undefined) {
this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled);
}
}
return;
}
@@ -446,12 +513,20 @@ class ResourceList implements Component, Focusable {
this.filterItems(this.searchInput.getValue());
}
private toggleResource(item: ResourceItem, enabled: boolean): void {
private toggleResource(item: ResourceItem): boolean | undefined {
if (this.writeScope === "project") {
const state = this.getNextOverrideState(item);
if (!this.setProjectResourceOverride(item, state)) return undefined;
return state === "inherit" ? this.getInheritedEnabled(item) : state === "load";
}
const enabled = !item.enabled;
if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled);
} else {
this.togglePackageResource(item, enabled);
}
return enabled;
}
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
@@ -561,6 +636,217 @@ class ResourceList implements Component, Focusable {
}
}
private renderCheckbox(item: ResourceItem): string {
if (this.writeScope === "project") {
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("success", "[+]");
if (state === "unload") return theme.fg("warning", "[-]");
return theme.fg("dim", item.enabled ? "[x]" : "[ ]");
}
return item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
}
private getItemSuffix(item: ResourceItem): string {
if (this.writeScope !== "project") return "";
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("muted", " project load");
if (state === "unload") return theme.fg("muted", " project unload");
return this.isInheritedGlobalItem(item) ? theme.fg("dim", " inherited global") : "";
}
private isDimmedItem(item: ResourceItem): boolean {
return (
this.writeScope === "project" &&
this.isInheritedGlobalItem(item) &&
this.getProjectOverrideState(item) === "inherit"
);
}
private setProjectResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
return item.metadata.origin === "top-level"
? this.setProjectTopLevelOverride(item, state)
: this.setProjectPackageOverride(item, state);
}
private setProjectTopLevelOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const current = (this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[];
const pattern = this.isInheritedGlobalItem(item) ? item.path : this.getResourcePatternForScope(item, "project");
const patterns = this.getTopLevelOverridePatterns(item, "project");
const updated = current.filter((entry) => {
const target = this.getPatternEntryTarget(entry);
if ((entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) && patterns.has(target))
return false;
return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern);
});
if (state !== "inherit") {
if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern);
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
}
this.setProjectTopLevelPaths(item.resourceType, updated);
return true;
}
private setProjectTopLevelPaths(key: ResourceType, paths: string[]): void {
if (key === "extensions") this.settingsManager.setProjectExtensionPaths(paths);
else if (key === "skills") this.settingsManager.setProjectSkillPaths(paths);
else if (key === "prompts") this.settingsManager.setProjectPromptTemplatePaths(paths);
else this.settingsManager.setProjectThemePaths(paths);
}
private setProjectPackageOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const packages = [...(this.settingsManager.getProjectSettings().packages ?? [])] as PackageSource[];
let pkgIndex = packages.findIndex((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
"project",
),
);
if (pkgIndex === -1) {
if (state === "inherit") return false;
packages.push(this.createPackageOverrideSource(item));
pkgIndex = packages.length - 1;
}
let pkg = packages[pkgIndex];
if (pkg === undefined) return false;
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const pattern = this.getPackageResourcePattern(item);
const updated = ((pkg[item.resourceType] ?? []) as string[]).filter(
(entry) => this.getPatternEntryTarget(entry) !== pattern,
);
if (state !== "inherit") updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
(pkg as Record<string, unknown>)[item.resourceType] = updated.length > 0 ? updated : undefined;
if (!RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[key] !== undefined)) {
if (pkg.autoload === false) packages.splice(pkgIndex, 1);
else packages[pkgIndex] = pkg.source;
}
this.settingsManager.setProjectPackages(packages);
return true;
}
private getNextOverrideState(item: ResourceItem): ProjectOverrideState {
const state = this.getProjectOverrideState(item);
const inheritedEnabled = this.getInheritedEnabled(item);
if (state === "inherit") return inheritedEnabled ? "unload" : "load";
if (state === "unload") return inheritedEnabled ? "load" : "inherit";
return inheritedEnabled ? "inherit" : "unload";
}
private getProjectOverrideState(item: ResourceItem): ProjectOverrideState {
if (this.writeScope !== "project") return "inherit";
if (item.metadata.origin === "top-level") {
return this.getOverrideStateFromEntries(
(this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[],
this.getTopLevelOverridePatterns(item, "project"),
false,
);
}
const pkg = this.findMatchingPackageSource(item, "project");
if (typeof pkg !== "object") return "inherit";
const entries = pkg[item.resourceType];
if (entries === undefined) return "inherit";
return this.getOverrideStateFromEntries(
entries,
new Set([this.getPackageResourcePattern(item)]),
pkg.autoload !== false,
);
}
private getOverrideStateFromEntries(
entries: string[],
patterns: Set<string>,
emptyArrayIsUnload: boolean,
): ProjectOverrideState {
if (entries.length === 0 && emptyArrayIsUnload) return "unload";
let state: ProjectOverrideState = "inherit";
for (const entry of entries) {
if (!patterns.has(this.getPatternEntryTarget(entry))) continue;
if (entry.startsWith("!") || entry.startsWith("-")) state = "unload";
else state = "load";
}
return state;
}
private getInheritedEnabled(item: ResourceItem): boolean {
return (
this.inheritedEnabledByKey.get(this.getResourceItemKey(item)) ??
(this.getItemScope(item) === "user" ? item.enabled : true)
);
}
private isInheritedGlobalItem(item: ResourceItem): boolean {
return this.getItemScope(item) === "user" || this.inheritedEnabledByKey.has(this.getResourceItemKey(item));
}
private getTopLevelOverridePatterns(item: ResourceItem, scope: SettingsScope): Set<string> {
const baseDir = this.getTopLevelBaseDir(scope);
const patterns = new Set<string>([
this.getResourcePatternForScope(item, scope),
item.path,
relative(baseDir, item.path),
]);
if (item.metadata.baseDir) patterns.add(relative(item.metadata.baseDir, item.path));
return patterns;
}
private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string {
const sourceScope = this.getItemScope(item);
if (scope !== sourceScope) return item.path;
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope);
return relative(baseDir, item.path);
}
private createPackageOverrideSource(item: ResourceItem): PackageSource {
const source = item.metadata.source;
if (!isLocalPath(source)) return { source, autoload: false };
const sourcePath = resolvePath(source, this.getTopLevelBaseDir(this.getItemScope(item)), { trim: true });
return { source: relative(this.getTopLevelBaseDir("project"), sourcePath) || ".", autoload: false };
}
private packageSourceStringMatches(
leftSource: string,
leftScope: SettingsScope,
rightSource: string,
rightScope: SettingsScope,
): boolean {
if (leftSource === rightSource) return true;
if (!isLocalPath(leftSource) || !isLocalPath(rightSource)) return false;
const left = resolvePath(leftSource, this.getTopLevelBaseDir(leftScope), { trim: true });
const right = resolvePath(rightSource, this.getTopLevelBaseDir(rightScope), { trim: true });
return left === right;
}
private findMatchingPackageSource(item: ResourceItem, targetScope: SettingsScope): PackageSource | undefined {
const settings =
targetScope === "project"
? this.settingsManager.getProjectSettings()
: this.settingsManager.getGlobalSettings();
return (settings.packages ?? []).find((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
targetScope,
),
);
}
private getPatternEntryTarget(entry: string): string {
return entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
}
private getResourceItemKey(item: ResourceItem): string {
return `${item.resourceType}:${canonicalizePath(item.path)}`;
}
private getItemScope(item: ResourceItem): SettingsScope {
return item.metadata.scope === "project" ? "project" : "user";
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
@@ -578,7 +864,9 @@ class ResourceList implements Component, Focusable {
}
export class ConfigSelectorComponent extends Container implements Focusable {
private header: ConfigSelectorHeader;
private resourceList: ResourceList;
private writeScope: ConfigWriteScope;
private _focused = false;
get focused(): boolean {
@@ -590,7 +878,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
}
constructor(
resolvedPaths: ResolvedPaths,
resolvedPaths: ScopedResolvedPaths,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
@@ -598,23 +886,43 @@ export class ConfigSelectorComponent extends Container implements Focusable {
onExit: () => void,
requestRender: () => void,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
projectModeAvailable = true,
) {
super();
const groups = buildGroups(resolvedPaths, agentDir);
this.writeScope = writeScope;
const groupsByScope = {
global: buildGroups(resolvedPaths.global, agentDir),
project: buildGroups(resolvedPaths.project, agentDir),
};
// Add header
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new ConfigSelectorHeader());
this.header = new ConfigSelectorHeader(this.writeScope, projectModeAvailable);
this.addChild(this.header);
this.addChild(new Spacer(1));
// Resource list
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir, terminalHeight);
this.resourceList = new ResourceList(
groupsByScope,
settingsManager,
cwd,
agentDir,
terminalHeight,
this.writeScope,
);
this.resourceList.onCancel = onClose;
this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender();
if (projectModeAvailable) {
this.resourceList.onSwitchMode = () => {
this.switchWriteScope();
requestRender();
};
}
this.addChild(this.resourceList);
// Bottom border
@@ -622,6 +930,12 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.addChild(new DynamicBorder());
}
private switchWriteScope(): void {
this.writeScope = this.writeScope === "global" ? "project" : "global";
this.header.setWriteScope(this.writeScope);
this.resourceList.setWriteScope(this.writeScope);
}
getResourceList(): ResourceList {
return this.resourceList;
}
@@ -87,6 +87,23 @@ function getPackageCommandUsage(command: PackageCommand): string {
}
}
const CONFIG_COMMAND_USAGE = `${APP_NAME} config [-l] [--approve|--no-approve]`;
function printConfigCommandHelp(): void {
console.log(`${chalk.bold("Usage:")}
${CONFIG_COMMAND_USAGE}
Open the resource configuration TUI to enable or disable package resources.
Without -l, starts in global settings (~/${CONFIG_DIR_NAME}/agent/settings.json).
Press Tab in the TUI to switch between global and project-local modes.
Options:
-l, --local Edit project overrides (${CONFIG_DIR_NAME}/settings.json)
-a, --approve Trust project-local files for this command with -l
-na, --no-approve Ignore project-local files for this command with -l
`);
}
function printPackageCommandHelp(command: PackageCommand): void {
switch (command) {
case "install":
@@ -466,18 +483,6 @@ function prepareWindowsNpmSelfUpdate(): void {
quarantineWindowsNativeDependencies(packageDir);
}
function parseProjectTrustOverride(args: readonly string[]): boolean | undefined {
let trustOverride: boolean | undefined;
for (const arg of args) {
if (arg === "--approve" || arg === "-a") {
trustOverride = true;
} else if (arg === "--no-approve" || arg === "-na") {
trustOverride = false;
}
}
return trustOverride;
}
export interface PackageCommandRuntimeOptions {
extensionFactories?: ExtensionFactory[];
}
@@ -549,28 +554,70 @@ export async function handleConfigCommand(
args: string[],
runtimeOptions: PackageCommandRuntimeOptions = {},
): Promise<boolean> {
if (args[0] !== "config") {
const [command, ...rest] = args;
if (command !== "config") {
return false;
}
if (rest.includes("-h") || rest.includes("--help")) {
printConfigCommandHelp();
return true;
}
let local = false;
let projectTrustOverride: boolean | undefined;
for (const arg of rest) {
if (arg === "-l" || arg === "--local") {
local = true;
} else if (arg === "-a" || arg === "--approve") {
projectTrustOverride = true;
} else if (arg === "-na" || arg === "--no-approve") {
projectTrustOverride = false;
} else if (arg.startsWith("-")) {
console.error(chalk.red(`Unknown option ${arg} for "config".`));
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${CONFIG_COMMAND_USAGE}".`));
process.exitCode = 1;
return true;
} else {
console.error(chalk.red(`Unexpected argument ${arg}.`));
console.error(chalk.dim(`Usage: ${CONFIG_COMMAND_USAGE}`));
process.exitCode = 1;
return true;
}
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
cwd,
agentDir,
projectTrustOverride: parseProjectTrustOverride(args),
projectTrustOverride,
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
if (local && !settingsManager.isProjectTrusted()) {
console.error(chalk.red("Project is not trusted. Use --approve to modify local resource config."));
process.exitCode = 1;
return true;
}
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
const globalResolvedPaths = await new DefaultPackageManager({
cwd,
agentDir,
settingsManager: globalSettingsManager,
}).resolve();
const projectResolvedPaths = settingsManager.isProjectTrusted()
? await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve()
: globalResolvedPaths;
await selectConfig({
resolvedPaths,
resolvedPaths: { global: globalResolvedPaths, project: projectResolvedPaths },
settingsManager,
cwd,
agentDir,
writeScope: local ? "project" : "global",
projectModeAvailable: settingsManager.isProjectTrusted(),
});
process.exit(0);
@@ -3,8 +3,11 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
import type { ResolvedPaths } from "../src/core/package-manager.ts";
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.ts";
import { ConfigSelectorComponent } from "../src/modes/interactive/components/config-selector.ts";
import { handlePackageCommand } from "../src/package-manager-cli.ts";
describe("package commands", () => {
@@ -28,6 +31,24 @@ describe("package commands", () => {
expect(await handlePackageCommand(args)).toBe(true);
}
function extensionPaths(
packageRoot: string,
source: string,
scope: "user" | "project",
names: string[],
): ResolvedPaths {
return {
extensions: names.map((name) => ({
path: join(packageRoot, "extensions", name),
enabled: true,
metadata: { source, scope, origin: "package", baseDir: packageRoot },
})),
skills: [],
prompts: [],
themes: [],
};
}
beforeEach(() => {
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
@@ -350,6 +371,37 @@ describe("package commands", () => {
}
});
it("cycles project package overrides in config local mode", async () => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] }));
const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true });
const resolvedPaths = extensionPaths(join(tempDir, "pkg"), "npm:pi-tools", "user", ["bar.ts"]);
const selector = new ConfigSelectorComponent(
{ global: resolvedPaths, project: resolvedPaths },
settingsManager,
projectDir,
agentDir,
() => {},
() => {},
() => {},
24,
"project",
);
selector.getResourceList().handleInput(" ");
expect(settingsManager.getProjectSettings().packages).toEqual([
{ source: "npm:pi-tools", autoload: false, extensions: ["-extensions/bar.ts"] },
]);
selector.getResourceList().handleInput(" ");
expect(settingsManager.getProjectSettings().packages).toEqual([
{ source: "npm:pi-tools", autoload: false, extensions: ["+extensions/bar.ts"] },
]);
selector.getResourceList().handleInput(" ");
expect(settingsManager.getProjectSettings().packages).toEqual([]);
});
it("shows a friendly error for unknown install options", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -1674,6 +1674,49 @@ Content`,
expect(result.extensions.some((r) => isEnabled(r, "one.ts"))).toBe(true);
expect(result.extensions.some((r) => isDisabled(r, "two.ts"))).toBe(true);
});
it("should resolve autoload-disabled project package entries as deltas over global packages", async () => {
const pkgDir = join(agentDir, "npm", "node_modules", "pi-tools");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "pi-tools", version: "1.0.0" }));
writeFileSync(join(pkgDir, "extensions", "foo.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "extensions", "bar.ts"), "export default function() {}");
settingsManager.setPackages(["npm:pi-tools"]);
settingsManager.setProjectPackages([
{ source: "npm:pi-tools", autoload: false, extensions: ["-extensions/foo.ts"] },
]);
const runCommandSpy = vi
.spyOn(packageManager as unknown as PackageManagerInternals, "runCommand")
.mockRejectedValue(new Error("unexpected install"));
const result = await packageManager.resolve();
const states = Object.fromEntries(
result.extensions.map((resource) => [
resource.path,
{ enabled: resource.enabled, scope: resource.metadata.scope },
]),
);
expect(runCommandSpy).not.toHaveBeenCalled();
expect(states[join(pkgDir, "extensions", "foo.ts")]).toEqual({ enabled: false, scope: "project" });
expect(states[join(pkgDir, "extensions", "bar.ts")]).toEqual({ enabled: true, scope: "user" });
});
it("should resolve autoload-disabled package entries as positive-only without a global package", async () => {
const pkgDir = join(tempDir, "positive-only-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "skills", "foo"), { recursive: true });
writeFileSync(join(pkgDir, "extensions", "foo.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "extensions", "bar.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "skills", "foo", "SKILL.md"), "# Foo\n");
settingsManager.setProjectPackages([
{ source: relative(join(tempDir, ".pi"), pkgDir), autoload: false, extensions: ["+extensions/foo.ts"] },
]);
const result = await packageManager.resolve();
expect(result.extensions.map((resource) => resource.path)).toEqual([join(pkgDir, "extensions", "foo.ts")]);
expect(result.skills).toEqual([]);
});
});
describe("force-include patterns", () => {