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 ## 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 ## 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 - npm: package name
- git: repository URL without ref - 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} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions) ${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions)
${APP_NAME} list List installed extensions from settings ${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources ${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 ${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
${chalk.bold("Options:")} ${chalk.bold("Options:")}
--provider <name> Provider name (default: google) --provider <name> Provider name (default: google)
@@ -3,16 +3,17 @@
*/ */
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui"; 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 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"; import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts";
export interface ConfigSelectorOptions { export interface ConfigSelectorOptions {
resolvedPaths: ResolvedPaths; resolvedPaths: ScopedResolvedPaths;
settingsManager: SettingsManager; settingsManager: SettingsManager;
cwd: string; cwd: string;
agentDir: string; agentDir: string;
writeScope: "global" | "project";
projectModeAvailable: boolean;
} }
/** Show TUI config selector and return when closed */ /** Show TUI config selector and return when closed */
@@ -44,6 +45,8 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
}, },
() => ui.requestRender(), () => ui.requestRender(),
ui.terminal.rows, ui.terminal.rows,
options.writeScope,
options.projectModeAvailable,
); );
ui.addChild(selector); ui.addChild(selector);
@@ -188,6 +188,7 @@ function resourcePrecedenceRank(m: PathMetadata): number {
} }
interface PackageFilter { interface PackageFilter {
autoload?: boolean;
extensions?: string[]; extensions?: string[];
skills?: string[]; skills?: string[];
prompts?: string[]; prompts?: string[];
@@ -772,6 +773,25 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
return new Set(result); 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 { export class DefaultPackageManager implements PackageManager {
private cwd: string; private cwd: string;
private agentDir: string; private agentDir: string;
@@ -1225,38 +1245,39 @@ export class DefaultPackageManager implements PackageManager {
for (const { pkg, scope } of sources) { for (const { pkg, scope } of sources) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source; const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined; 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" }; const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
if (parsed.type === "local") { if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope); const baseDir = this.getBaseDirForScope(resolvedScope);
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir); this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
continue; continue;
} }
const installMissing = async (): Promise<boolean> => { const installMissing = async (): Promise<boolean> => {
if (isOfflineModeEnabled()) { if (isOfflineModeEnabled()) return false;
return false;
}
if (!onMissing) { if (!onMissing) {
await this.installParsedSource(parsed, scope); await this.installParsedSource(parsed, resolvedScope);
return true; return true;
} }
const action = await onMissing(sourceStr); const action = await onMissing(resolvedSource);
if (action === "skip") return false; if (action === "skip") return false;
if (action === "error") throw new Error(`Missing source: ${sourceStr}`); if (action === "error") throw new Error(`Missing source: ${resolvedSource}`);
await this.installParsedSource(parsed, scope); await this.installParsedSource(parsed, resolvedScope);
return true; return true;
}; };
if (parsed.type === "npm") { if (parsed.type === "npm") {
let installedPath = this.getNpmInstallPath(parsed, scope); let installedPath = this.getNpmInstallPath(parsed, resolvedScope);
const needsInstall = const needsInstall =
!existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));
if (needsInstall) { if (needsInstall) {
const installed = await installMissing(); const installed = await installMissing();
if (!installed) continue; if (!installed) continue;
installedPath = this.getNpmInstallPath(parsed, scope); installedPath = this.getNpmInstallPath(parsed, resolvedScope);
} }
metadata.baseDir = installedPath; metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata); this.collectPackageResources(installedPath, accumulator, filter, metadata);
@@ -1264,12 +1285,12 @@ export class DefaultPackageManager implements PackageManager {
} }
if (parsed.type === "git") { if (parsed.type === "git") {
const installedPath = this.getGitInstallPath(parsed, scope); const installedPath = this.getGitInstallPath(parsed, resolvedScope);
if (!existsSync(installedPath)) { if (!existsSync(installedPath)) {
const installed = await installMissing(); const installed = await installMissing();
if (!installed) continue; if (!installed) continue;
} else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) { } else if (resolvedScope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
await this.refreshTemporaryGitSource(parsed, sourceStr); await this.refreshTemporaryGitSource(parsed, resolvedSource);
} }
metadata.baseDir = installedPath; metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata); 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( private resolveLocalExtensionSource(
source: LocalSource, source: LocalSource,
accumulator: ResourceAccumulator, accumulator: ResourceAccumulator,
@@ -1655,29 +1691,30 @@ export class DefaultPackageManager implements PackageManager {
/** /**
* Dedupe packages: if same package identity appears in both global and project, * 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( private dedupePackages(
packages: Array<{ pkg: PackageSource; scope: SourceScope }>, packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
): 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) { for (const entry of packages) {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; const identity = this.getPackageIdentity(this.getPackageSourceString(entry.pkg), entry.scope);
const identity = this.getPackageIdentity(sourceStr, entry.scope); const index = seen.get(identity);
if (index === undefined) {
const existing = seen.get(identity); seen.set(identity, result.length);
if (!existing) { result.push(entry);
seen.set(identity, entry); continue;
} else if (entry.scope === "project" && existing.scope === "user") { }
// Project wins over user const existing = result[index];
seen.set(identity, entry); 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 result;
return Array.from(seen.values());
} }
private parseNpmSpec(spec: string): { name: string; version?: string } { private parseNpmSpec(spec: string): { name: string; version?: string } {
@@ -2047,9 +2084,11 @@ export class DefaultPackageManager implements PackageManager {
): boolean { ): boolean {
if (filter) { if (filter) {
for (const resourceType of RESOURCE_TYPES) { for (const resourceType of RESOURCE_TYPES) {
const patterns = filter[resourceType as keyof PackageFilter]; const patterns = filter[resourceType];
const target = this.getTargetMap(accumulator, 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); this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata);
} else { } else {
this.collectDefaultResources(packageRoot, resourceType, target, metadata); 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. * Collect all files from a package for a resource type, applying manifest patterns.
* Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files
@@ -66,11 +66,13 @@ export type TransportSetting = Transport;
* Package source for npm/git packages. * Package source for npm/git packages.
* - String form: load all resources from the package * - String form: load all resources from the package
* - Object form: filter which resources to load * - Object form: filter which resources to load
* - autoload=false: start empty and only apply explicit resource patterns
*/ */
export type PackageSource = export type PackageSource =
| string | string
| { | {
source: string; source: string;
autoload?: boolean;
extensions?: string[]; extensions?: string[];
skills?: string[]; skills?: string[];
prompts?: string[]; prompts?: string[];
@@ -18,11 +18,18 @@ import {
import { CONFIG_DIR_NAME } from "../../../config.ts"; import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts"; import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-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 { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.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 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> = { const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
extensions: "Extensions", extensions: "Extensions",
@@ -178,25 +185,42 @@ type FlatEntry =
| { type: "item"; item: ResourceItem }; | { type: "item"; item: ResourceItem };
class ConfigSelectorHeader implements Component { 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 {} invalidate(): void {}
render(width: number): string[] { 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 sep = theme.fg("muted", " · ");
const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close"); const switchHint = this.projectModeAvailable ? keyHint("tui.input.tab", "switch mode") + sep : "";
const hintWidth = visibleWidth(hint); const actionHint =
const titleWidth = visibleWidth(title); this.writeScope === "project" ? rawKeyHint("space", "cycle inherit/+/-") : rawKeyHint("space", "toggle");
const spacing = Math.max(1, width - titleWidth - hintWidth); 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 [ return [
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""), truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
theme.fg("muted", "Type to filter resources"), truncateToWidth(scopeHint, width, ""),
]; ];
} }
} }
class ResourceList implements Component, Focusable { class ResourceList implements Component, Focusable {
private groups: ResourceGroup[]; private groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>;
private flatItems: FlatEntry[] = []; private flatItems: FlatEntry[] = [];
private filteredItems: FlatEntry[] = []; private filteredItems: FlatEntry[] = [];
private selectedIndex = 0; private selectedIndex = 0;
@@ -205,10 +229,13 @@ class ResourceList implements Component, Focusable {
private settingsManager: SettingsManager; private settingsManager: SettingsManager;
private cwd: string; private cwd: string;
private agentDir: string; private agentDir: string;
private writeScope: ConfigWriteScope;
private inheritedEnabledByKey: Map<string, boolean>;
public onCancel?: () => void; public onCancel?: () => void;
public onExit?: () => void; public onExit?: () => void;
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void; public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
public onSwitchMode?: () => void;
private _focused = false; private _focused = false;
get focused(): boolean { get focused(): boolean {
@@ -220,16 +247,19 @@ class ResourceList implements Component, Focusable {
} }
constructor( constructor(
groups: ResourceGroup[], groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>,
settingsManager: SettingsManager, settingsManager: SettingsManager,
cwd: string, cwd: string,
agentDir: string, agentDir: string,
terminalHeight?: number, terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
) { ) {
this.groups = groups; this.groupsByScope = groupsByScope;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.cwd = cwd; this.cwd = cwd;
this.agentDir = agentDir; this.agentDir = agentDir;
this.writeScope = writeScope;
this.inheritedEnabledByKey = this.buildInheritedEnabledMap(groupsByScope.global);
this.searchInput = new Input(); this.searchInput = new Input();
// 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border // 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border
const chrome = 8; const chrome = 8;
@@ -238,6 +268,28 @@ class ResourceList implements Component, Focusable {
this.filteredItems = [...this.flatItems]; 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 { private buildFlatList(): void {
this.flatItems = []; this.flatItems = [];
for (const group of this.groups) { for (const group of this.groups) {
@@ -362,19 +414,29 @@ class ResourceList implements Component, Focusable {
if (entry.type === "group") { if (entry.type === "group") {
// Main group header (no cursor) // 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, "")); lines.push(truncateToWidth(` ${groupLine}`, width, ""));
} else if (entry.type === "subgroup") { } else if (entry.type === "subgroup") {
// Subgroup header (indented, no cursor) // 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, "")); lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
} else { } else {
// Resource item (cursor only on items) // Resource item (cursor only on items)
const item = entry.item; const item = entry.item;
const cursor = isSelected ? "> " : " "; const cursor = isSelected ? "> " : " ";
const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]"); const dimmed = this.isDimmedItem(item);
const name = isSelected ? theme.bold(item.displayName) : item.displayName; const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName;
lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "...")); 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?.(); this.onExit?.();
return; return;
} }
if (kb.matches(data, "tui.input.tab")) {
this.onSwitchMode?.();
return;
}
if (data === " " || kb.matches(data, "tui.select.confirm")) { if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex]; const entry = this.filteredItems[this.selectedIndex];
if (entry?.type === "item") { if (entry?.type === "item" && (this.writeScope === "project" || this.getItemScope(entry.item) === "user")) {
const newEnabled = !entry.item.enabled; const newEnabled = this.toggleResource(entry.item);
this.toggleResource(entry.item, newEnabled); if (newEnabled !== undefined) {
this.updateItem(entry.item, newEnabled); this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled); this.onToggle?.(entry.item, newEnabled);
}
} }
return; return;
} }
@@ -446,12 +513,20 @@ class ResourceList implements Component, Focusable {
this.filterItems(this.searchInput.getValue()); 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") { if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled); this.toggleTopLevelResource(item, enabled);
} else { } else {
this.togglePackageResource(item, enabled); this.togglePackageResource(item, enabled);
} }
return enabled;
} }
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void { 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 { private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir; 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 { export class ConfigSelectorComponent extends Container implements Focusable {
private header: ConfigSelectorHeader;
private resourceList: ResourceList; private resourceList: ResourceList;
private writeScope: ConfigWriteScope;
private _focused = false; private _focused = false;
get focused(): boolean { get focused(): boolean {
@@ -590,7 +878,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
} }
constructor( constructor(
resolvedPaths: ResolvedPaths, resolvedPaths: ScopedResolvedPaths,
settingsManager: SettingsManager, settingsManager: SettingsManager,
cwd: string, cwd: string,
agentDir: string, agentDir: string,
@@ -598,23 +886,43 @@ export class ConfigSelectorComponent extends Container implements Focusable {
onExit: () => void, onExit: () => void,
requestRender: () => void, requestRender: () => void,
terminalHeight?: number, terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
projectModeAvailable = true,
) { ) {
super(); super();
const groups = buildGroups(resolvedPaths, agentDir); this.writeScope = writeScope;
const groupsByScope = {
global: buildGroups(resolvedPaths.global, agentDir),
project: buildGroups(resolvedPaths.project, agentDir),
};
// Add header // Add header
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
this.addChild(new Spacer(1)); 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)); this.addChild(new Spacer(1));
// Resource list // 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.onCancel = onClose;
this.resourceList.onExit = onExit; this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender(); this.resourceList.onToggle = () => requestRender();
if (projectModeAvailable) {
this.resourceList.onSwitchMode = () => {
this.switchWriteScope();
requestRender();
};
}
this.addChild(this.resourceList); this.addChild(this.resourceList);
// Bottom border // Bottom border
@@ -622,6 +930,12 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.addChild(new DynamicBorder()); 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 { getResourceList(): ResourceList {
return this.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 { function printPackageCommandHelp(command: PackageCommand): void {
switch (command) { switch (command) {
case "install": case "install":
@@ -466,18 +483,6 @@ function prepareWindowsNpmSelfUpdate(): void {
quarantineWindowsNativeDependencies(packageDir); 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 { export interface PackageCommandRuntimeOptions {
extensionFactories?: ExtensionFactory[]; extensionFactories?: ExtensionFactory[];
} }
@@ -549,28 +554,70 @@ export async function handleConfigCommand(
args: string[], args: string[],
runtimeOptions: PackageCommandRuntimeOptions = {}, runtimeOptions: PackageCommandRuntimeOptions = {},
): Promise<boolean> { ): Promise<boolean> {
if (args[0] !== "config") { const [command, ...rest] = args;
if (command !== "config") {
return false; 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 cwd = process.cwd();
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
cwd, cwd,
agentDir, agentDir,
projectTrustOverride: parseProjectTrustOverride(args), projectTrustOverride,
extensionFactories: runtimeOptions.extensionFactories, extensionFactories: runtimeOptions.extensionFactories,
}); });
reportProjectTrustWarnings(projectTrustWarnings); 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"); reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
const resolvedPaths = await packageManager.resolve(); const globalResolvedPaths = await new DefaultPackageManager({
cwd,
agentDir,
settingsManager: globalSettingsManager,
}).resolve();
const projectResolvedPaths = settingsManager.isProjectTrusted()
? await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve()
: globalResolvedPaths;
await selectConfig({ await selectConfig({
resolvedPaths, resolvedPaths: { global: globalResolvedPaths, project: projectResolvedPaths },
settingsManager, settingsManager,
cwd, cwd,
agentDir, agentDir,
writeScope: local ? "project" : "global",
projectModeAvailable: settingsManager.isProjectTrusted(),
}); });
process.exit(0); process.exit(0);
@@ -3,8 +3,11 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path"; import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; 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 { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.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"; import { handlePackageCommand } from "../src/package-manager-cli.ts";
describe("package commands", () => { describe("package commands", () => {
@@ -28,6 +31,24 @@ describe("package commands", () => {
expect(await handlePackageCommand(args)).toBe(true); 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(() => { beforeEach(() => {
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`); tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent"); 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 () => { it("shows a friendly error for unknown install options", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 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) => isEnabled(r, "one.ts"))).toBe(true);
expect(result.extensions.some((r) => isDisabled(r, "two.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", () => { describe("force-include patterns", () => {