From c8ada4e76e123e8f292e4b057f443f874554b5ac Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 6 Jul 2026 20:50:30 +0200 Subject: [PATCH] Improve project-local pi config (#6309) * feat(coding-agent): improve config resource overrides * fix(coding-agent): simplify config resource overrides --- packages/coding-agent/docs/packages.md | 4 +- packages/coding-agent/src/cli/args.ts | 4 +- .../coding-agent/src/cli/config-selector.ts | 9 +- .../coding-agent/src/core/package-manager.ts | 121 ++++-- .../coding-agent/src/core/settings-manager.ts | 2 + .../interactive/components/config-selector.ts | 364 ++++++++++++++++-- .../coding-agent/src/package-manager-cli.ts | 81 +++- .../test/package-command-paths.test.ts | 52 +++ .../coding-agent/test/package-manager.test.ts | 43 +++ 9 files changed, 599 insertions(+), 81 deletions(-) diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 73f2dccb..a5a0cae9 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -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 diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 899241c4..f8d4306e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -231,8 +231,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} uninstall [-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} --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} --help Show help for install/remove/uninstall/update/list/config ${chalk.bold("Options:")} --provider Provider name (default: google) diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index 74265a10..b21e1231 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -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 ui.requestRender(), ui.terminal.rows, + options.writeScope, + options.projectModeAvailable, ); ui.addChild(selector); diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index ebdf974b..6b546d86 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 { + const result = new Map(); + 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 => { - 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(); - + const result: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + const seen = new Map(); 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, + 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 diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 6add0810..74ee902a 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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[]; diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 7c46841d..217f261a 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -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; + +const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceType[]; const RESOURCE_TYPE_LABELS: Record = { 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; 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; 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, 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 { + const result = new Map(); + 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)[item.resourceType] = updated.length > 0 ? updated : undefined; + if (!RESOURCE_TYPES.some((key) => (pkg as Record)[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, + 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 { + const baseDir = this.getTopLevelBaseDir(scope); + const patterns = new Set([ + 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; } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 09349a3b..c354c6e3 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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 { - 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); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index d209ce50..8e202253 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -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(() => {}); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 9b1b0d7f..19bda172 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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", () => {