From 22183ed6270bf962b243eb6c9ec774b5cace19e2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 4 Jul 2026 15:56:54 +0200 Subject: [PATCH] feat(coding-agent): improve config resource overrides --- packages/coding-agent/README.md | 6 +- packages/coding-agent/docs/packages.md | 16 +- packages/coding-agent/docs/settings.md | 2 + packages/coding-agent/docs/usage.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 | 87 ++- .../coding-agent/src/core/settings-manager.ts | 2 + .../interactive/components/config-selector.ts | 685 +++++++++++++++--- .../coding-agent/src/package-manager-cli.ts | 81 ++- .../test/package-command-paths.test.ts | 456 ++++++++++++ 11 files changed, 1207 insertions(+), 145 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 90efe1338..cd915912c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -422,7 +422,7 @@ pi update --extensions # update packages only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/pi-tools # update one package -pi config # enable/disable extensions, skills, prompts, themes +pi config [-l] # enable/disable resources, -l for project-local selections ``` Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update --extensions` and `pi update --all`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. @@ -523,10 +523,10 @@ pi update --self # Update pi only pi update --self --force # Reinstall pi even if current pi update --extension # Update one package pi list # List installed packages -pi config # Enable/disable package resources +pi config [-l] # Enable/disable package resources, -l for project-local selections ``` -`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. +`pi config` starts in global settings; press Tab in the TUI to switch to project-local overrides (`.pi/settings.json`), or start there with `pi config -l`. `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. ### Modes diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 73f2dccb4..81b1498c2 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -214,13 +214,25 @@ Filter what a package loads using the object form in settings: - `-path` force-excludes an exact path. - Filters layer on top of the manifest. They narrow down what is already allowed. +Set `autoload` to `false` for a delta entry that starts empty and only applies explicit patterns. This is useful for project-local overrides of globally configured packages: + +```json +{ + "source": "../../../agent-stuff", + "autoload": false, + "extensions": ["+extensions/goal.ts", "-extensions/answer.ts"] +} +``` + +With `autoload: false`, omitted resource types do not load anything from that entry, plain patterns and `+path` enable matches, and `!pattern` or `-path` disable matches. If the same package is also configured globally, the global package still loads and the project delta overrides only the listed 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 -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/docs/settings.md b/packages/coding-agent/docs/settings.md index d1a49fc71..465ec4db3 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -267,6 +267,8 @@ Object form filters which resources to load: } ``` +Set `autoload` to `false` for a package entry that starts empty and only applies explicit `+path`, `-path`, plain include, or `!pattern` entries. Project-local `autoload: false` entries for packages that are also configured globally act as deltas over the global package. + See [packages.md](packages.md) for package management details. ## Example diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 7a39eecfe..6ed83b256 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -153,10 +153,10 @@ pi update --extensions # Update packages only; reconcile pinned git refs pi update --self # Update pi only pi update --extension # Update one package pi list # List installed packages -pi config # Enable/disable package resources +pi config [-l] # Enable/disable package resources, -l for project-local selections ``` -These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. +These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` starts in global settings; press Tab in the TUI to switch to project-local overrides (`.pi/settings.json`), or start there with `pi config -l`. `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 899241c46..f8d4306e2 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 74265a100..b21e12313 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 ebdf974ba..52b920f1a 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,36 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): return new Set(result); } +function applyAutoloadDisabledPatterns(allPaths: string[], patterns: string[], baseDir: string): Map { + const includes: string[] = []; + const excludes: string[] = []; + const forceIncludes: string[] = []; + const forceExcludes: string[] = []; + + for (const pattern of patterns) { + if (pattern.startsWith("+")) { + forceIncludes.push(pattern.slice(1)); + } else if (pattern.startsWith("-")) { + forceExcludes.push(pattern.slice(1)); + } else if (pattern.startsWith("!")) { + excludes.push(pattern.slice(1)); + } else { + includes.push(pattern); + } + } + + const result = new Map(); + for (const filePath of allPaths) { + let enabled: boolean | undefined; + if (matchesAnyPattern(filePath, includes, baseDir)) enabled = true; + if (matchesAnyPattern(filePath, excludes, baseDir)) enabled = false; + if (matchesAnyExactPattern(filePath, forceIncludes, baseDir)) enabled = true; + if (matchesAnyExactPattern(filePath, forceExcludes, baseDir)) enabled = false; + if (enabled !== undefined) result.set(filePath, enabled); + } + return result; +} + export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; @@ -1655,29 +1686,35 @@ 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 byIdentity = 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 group = byIdentity.get(identity); + if (group) { + group.push(entry); + } else { + byIdentity.set(identity, [entry]); } - // If existing is project and new is global, keep existing (project) - // If both are same scope, keep first one } - return Array.from(seen.values()); + const result: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + for (const group of byIdentity.values()) { + const project = group.find((entry) => entry.scope === "project"); + const user = group.find((entry) => entry.scope === "user"); + if (project && user && typeof project.pkg === "object" && project.pkg.autoload === false) { + result.push(project, user); + } else { + result.push(project ?? group[0]); + } + } + 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 6add0810c..74ee902a1 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 7c46841d8..a1d9696fd 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,17 @@ 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 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", @@ -39,6 +45,7 @@ interface ResourceItem { displayName: string; groupKey: string; subgroupKey: string; + projectOverride?: boolean; } interface ResourceSubgroup { @@ -50,6 +57,7 @@ interface ResourceSubgroup { interface ResourceGroup { key: string; label: string; + displaySource: string; scope: "user" | "project" | "temporary"; origin: "package" | "top-level"; source: string; @@ -73,9 +81,30 @@ function formatBaseDir(baseDir: string): string { return displayPath.endsWith("/") ? displayPath : `${displayPath}/`; } -function getGroupLabel(metadata: PathMetadata, agentDir: string): string { +function getSettingsBaseDir(scope: ResourceGroup["scope"], cwd: string, agentDir: string): string { + if (scope === "project") { + return join(cwd, CONFIG_DIR_NAME); + } + if (scope === "user") { + return agentDir; + } + return cwd; +} + +function getPackageDisplaySource(metadata: PathMetadata, cwd: string, agentDir: string): string { + if (!isLocalPath(metadata.source)) { + return metadata.source; + } + return resolvePath(metadata.source, getSettingsBaseDir(metadata.scope, cwd, agentDir), { trim: true }).replace( + /\\/g, + "/", + ); +} + +function getGroupLabel(metadata: PathMetadata, cwd: string, agentDir: string): string { if (metadata.origin === "package") { - return `${metadata.source} (${metadata.scope})`; + const scopeLabel = metadata.scope === "user" ? "global" : metadata.scope; + return `${getPackageDisplaySource(metadata, cwd, agentDir)} (${scopeLabel})`; } // Top-level resources if (metadata.source === "auto") { @@ -89,7 +118,45 @@ function getGroupLabel(metadata: PathMetadata, agentDir: string): string { return metadata.scope === "user" ? "User settings" : "Project settings"; } -function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] { +function getResourceItemKey(resourceType: ResourceType, path: string): string { + return `${resourceType}:${canonicalizePath(path)}`; +} + +function getEntryResourceItemKey(entry: FlatEntry): string | undefined { + return entry.type === "item" ? getResourceItemKey(entry.item.resourceType, entry.item.path) : undefined; +} + +function getPatternEntryTarget(entry: string): string { + return entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry; +} + +function getOverrideStateFromEntries( + entries: string[], + patterns: Set, + options?: { plainEntryIsLoad?: boolean; emptyArrayIsUnload?: boolean }, +): ProjectOverrideState { + if (entries.length === 0 && options?.emptyArrayIsUnload) { + return "unload"; + } + + let state: ProjectOverrideState = "inherit"; + for (const entry of entries) { + if (!patterns.has(getPatternEntryTarget(entry))) continue; + if (entry.startsWith("!") || entry.startsWith("-")) { + state = "unload"; + } else if (entry.startsWith("+") || options?.plainEntryIsLoad) { + state = "load"; + } + } + return state; +} + +function buildGroups( + resolved: ResolvedPaths, + cwd: string, + agentDir: string, + writeScope: ConfigWriteScope, +): ResourceGroup[] { const groupMap = new Map(); const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => { @@ -100,7 +167,9 @@ function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { key: groupKey, - label: getGroupLabel(metadata, agentDir), + label: getGroupLabel(metadata, cwd, agentDir), + displaySource: + metadata.origin === "package" ? getPackageDisplaySource(metadata, cwd, agentDir) : metadata.source, scope: metadata.scope, origin: metadata.origin, source: metadata.source, @@ -148,9 +217,22 @@ function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] addToGroup(resolved.prompts, "prompts"); addToGroup(resolved.themes, "themes"); - // Sort groups: packages first, then top-level; user before project const groups = Array.from(groupMap.values()); groups.sort((a, b) => { + if (writeScope === "project") { + const projectRank = (group: ResourceGroup): number => { + if (group.scope === "project" && group.origin === "top-level") return 0; + if (group.scope === "project" && group.origin === "package") return 1; + if (group.scope === "user" && group.origin === "package") return 2; + if (group.scope === "user" && group.origin === "top-level") return 3; + return 4; + }; + const rankDiff = projectRank(a) - projectRank(b); + if (rankDiff !== 0) return rankDiff; + return a.source.localeCompare(b.source); + } + + // Global mode: packages first, then top-level; user before project. if (a.origin !== b.origin) { return a.origin === "package" ? -1 : 1; } @@ -178,25 +260,48 @@ 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 globalMode = this.writeScope === "global" ? theme.fg("accent", "global") : theme.fg("muted", "global"); + const projectMode = + this.writeScope === "project" ? theme.fg("accent", "project local") : theme.fg("muted", "project local"); + const modeIndicator = `${theme.fg("muted", "Mode: ")}${globalMode}${theme.fg("muted", " | ")}${projectMode}`; + 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`); + 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 hintWidth = visibleWidth(hint); const titleWidth = visibleWidth(title); const spacing = Math.max(1, width - titleWidth - hintWidth); return [ truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""), - theme.fg("muted", "Type to filter resources"), + truncateToWidth(`${modeIndicator}${sep}${scopeHint}`, width, ""), ]; } } class ResourceList implements Component, Focusable { - private groups: ResourceGroup[]; + private groupsByScope: Record; private flatItems: FlatEntry[] = []; private filteredItems: FlatEntry[] = []; private selectedIndex = 0; @@ -205,10 +310,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 +328,22 @@ 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.settingsManager = settingsManager; this.cwd = cwd; this.agentDir = agentDir; + this.writeScope = writeScope; + this.groupsByScope = { + global: groupsByScope.global, + project: this.mergeProjectPackageDeltaGroups(groupsByScope.project), + }; + 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 +352,99 @@ class ResourceList implements Component, Focusable { this.filteredItems = [...this.flatItems]; } + setWriteScope(writeScope: ConfigWriteScope): void { + if (this.writeScope === writeScope) { + return; + } + 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(getResourceItemKey(item.resourceType, item.path), item.enabled); + } + } + } + return result; + } + + private mergeProjectPackageDeltaGroups(groups: ResourceGroup[]): ResourceGroup[] { + const mergedGroups: ResourceGroup[] = []; + for (const group of groups) { + if ( + group.scope === "project" && + group.origin === "package" && + this.projectPackageAutoloadDisabled(group.source) + ) { + const globalGroup = groups.find( + (candidate) => + candidate.scope === "user" && + candidate.origin === "package" && + this.packageSourceStringMatches(candidate.source, "user", group.source, "project"), + ); + if (globalGroup) { + continue; + } + } + if (group.scope !== "user" || group.origin !== "package") { + mergedGroups.push(group); + continue; + } + + const projectGroup = groups.find( + (candidate) => + candidate.scope === "project" && + candidate.origin === "package" && + this.projectPackageAutoloadDisabled(candidate.source) && + this.packageSourceStringMatches(group.source, "user", candidate.source, "project"), + ); + if (!projectGroup) { + mergedGroups.push(group); + continue; + } + + mergedGroups.push(this.mergePackageGroups(group, projectGroup)); + } + return mergedGroups; + } + + private mergePackageGroups(globalGroup: ResourceGroup, projectGroup: ResourceGroup): ResourceGroup { + const subgroups: ResourceSubgroup[] = []; + for (const resourceType of RESOURCE_TYPES) { + const items = new Map(); + for (const item of globalGroup.subgroups.find((entry) => entry.type === resourceType)?.items ?? []) { + items.set(item.path, item); + } + for (const item of projectGroup.subgroups.find((entry) => entry.type === resourceType)?.items ?? []) { + items.set(item.path, { ...item, projectOverride: true }); + } + const mergedItems = Array.from(items.values()).sort((a, b) => a.displayName.localeCompare(b.displayName)); + if (mergedItems.length > 0) { + subgroups.push({ + type: resourceType, + label: RESOURCE_TYPE_LABELS[resourceType], + items: mergedItems, + }); + } + } + return { + ...globalGroup, + key: `${globalGroup.key}:project-delta:${projectGroup.key}`, + label: `${globalGroup.displaySource} (global with project-local overrides)`, + scope: "temporary", + subgroups, + }; + } + private buildFlatList(): void { this.flatItems = []; for (const group of this.groups) { @@ -254,6 +461,11 @@ class ResourceList implements Component, Focusable { if (this.selectedIndex < 0) this.selectedIndex = 0; } + private getSelectedItemKey(): string | undefined { + const entry = this.filteredItems[this.selectedIndex]; + return entry ? getEntryResourceItemKey(entry) : undefined; + } + private findNextItem(fromIndex: number, direction: 1 | -1): number { let idx = fromIndex + direction; while (idx >= 0 && idx < this.filteredItems.length) { @@ -265,10 +477,10 @@ class ResourceList implements Component, Focusable { return fromIndex; // Stay at current if no item found } - private filterItems(query: string): void { + private filterItems(query: string, preferredItemKey = this.getSelectedItemKey()): void { if (!query.trim()) { this.filteredItems = [...this.flatItems]; - this.selectFirstItem(); + this.selectFirstItem(preferredItemKey); return; } @@ -313,23 +525,34 @@ class ResourceList implements Component, Focusable { } } - this.selectFirstItem(); + this.selectFirstItem(preferredItemKey); } - private selectFirstItem(): void { + private selectFirstItem(preferredItemKey?: string): void { + if (preferredItemKey) { + const preferredIndex = this.filteredItems.findIndex( + (entry) => getEntryResourceItemKey(entry) === preferredItemKey, + ); + if (preferredIndex >= 0) { + this.selectedIndex = preferredIndex; + return; + } + } const firstItemIndex = this.filteredItems.findIndex((e) => e.type === "item"); this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0; } - updateItem(item: ResourceItem, enabled: boolean): void { - item.enabled = enabled; - // Update in groups too + updateItem(item: ResourceItem, state: ProjectOverrideState): void { + const enabled = state === "inherit" ? this.getInheritedEnabled(item) : state === "load"; + const projectOverride = this.writeScope === "project" && state !== "inherit"; + const itemKey = getResourceItemKey(item.resourceType, item.path); + for (const group of this.groups) { for (const subgroup of group.subgroups) { - const found = subgroup.items.find((i) => i.path === item.path && i.resourceType === item.resourceType); - if (found) { + for (const found of subgroup.items) { + if (getResourceItemKey(found.resourceType, found.path) !== itemKey) continue; found.enabled = enabled; - return; + found.projectOverride = projectOverride; } } } @@ -362,19 +585,26 @@ 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 label = theme.bold(this.getGroupDisplayLabel(entry.group)); + const groupLine = this.isDimmedScope(entry.group.scope) + ? theme.fg("dim", label) + : theme.fg("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.isDimmedScope(entry.group.scope) ? "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 checkbox = this.renderCheckbox(item, dimmed); + const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName; + const name = dimmed ? theme.fg("dim", nameText) : nameText; + const suffix = this.getItemSuffix(item); + lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}${suffix}`, width, "...")); } } @@ -389,6 +619,72 @@ class ResourceList implements Component, Focusable { return lines; } + private getGroupDisplayLabel(group: ResourceGroup): string { + if (this.writeScope !== "project") { + return group.label; + } + if ( + group.scope === "project" && + group.origin === "package" && + this.hasMatchingPackageSource(group.source, "project", "user") + ) { + return `${group.displaySource} (project package replaces global package)`; + } + if (group.scope === "user") { + if (group.origin === "package" && this.hasMatchingPackageSource(group.source, "user", "project")) { + return `${group.displaySource} (global package shadowed by project override)`; + } + return `${group.label} · inherited global`; + } + return group.label; + } + + private isDimmedScope(scope: ResourceGroup["scope"]): boolean { + return this.writeScope === "project" && scope === "user"; + } + + private isDimmedItem(item: ResourceItem): boolean { + return ( + this.writeScope === "project" && + this.isInheritedGlobalItem(item) && + this.getProjectOverrideState(item) === "inherit" + ); + } + + private renderCheckbox(item: ResourceItem, dimmed: boolean): 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]" : "[ ]"); + } + if (dimmed) { + return theme.fg("dim", item.enabled ? "[x]" : "[ ]"); + } + return item.enabled ? theme.fg("success", "[x]") : theme.fg("warning", "[ ]"); + } + + 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"); + } + if (this.isInheritedGlobalItem(item)) { + return theme.fg("dim", " inherited global"); + } + return ""; + } + handleInput(data: string): void { const kb = getKeybindings(); @@ -430,13 +726,23 @@ 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.canEditItem(entry.item)) { + const selectedItemKey = getResourceItemKey(entry.item.resourceType, entry.item.path); + const nextState = this.getNextOverrideState(entry.item); + if (this.setResourceOverride(entry.item, nextState)) { + this.updateItem(entry.item, nextState); + this.filterItems(this.searchInput.getValue(), selectedItemKey); + this.onToggle?.( + entry.item, + nextState === "inherit" ? this.getInheritedEnabled(entry.item) : nextState === "load", + ); + } } return; } @@ -446,112 +752,121 @@ class ResourceList implements Component, Focusable { this.filterItems(this.searchInput.getValue()); } - private toggleResource(item: ResourceItem, enabled: boolean): void { + private setResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean { if (item.metadata.origin === "top-level") { - this.toggleTopLevelResource(item, enabled); - } else { - this.togglePackageResource(item, enabled); + return this.setTopLevelResourceOverride(item, state); } + return this.setPackageResourceOverride(item, state); } - private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void { - const scope = item.metadata.scope as "user" | "project"; + private setTopLevelResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean { + const scope = this.getWriteScope(); const settings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; const current = (settings[arrayKey] ?? []) as string[]; + const patterns = this.getTopLevelOverridePatterns(item, scope); + const pattern = + scope === "project" ? this.getProjectTopLevelWritePattern(item) : this.getResourcePattern(item, scope); + const isInheritedGlobal = scope === "project" && this.isInheritedGlobalItem(item); - // Generate pattern for this resource - const pattern = this.getResourcePattern(item); - const disablePattern = `-${pattern}`; - const enablePattern = `+${pattern}`; - - // Filter out existing patterns for this resource - const updated = current.filter((p) => { - const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p; - return stripped !== pattern; + const updated = current.filter((entry) => { + const target = getPatternEntryTarget(entry); + const isOverride = entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-"); + if (isOverride && patterns.has(target)) { + return false; + } + return !(state === "inherit" && isInheritedGlobal && target === pattern); }); - if (enabled) { - updated.push(enablePattern); - } else { - updated.push(disablePattern); + if (state !== "inherit") { + if (isInheritedGlobal && !updated.includes(pattern)) { + updated.push(pattern); + } + updated.push(`${state === "load" ? "+" : "-"}${pattern}`); } + this.setTopLevelResourcePaths(scope, arrayKey, updated); + return true; + } + + private setTopLevelResourcePaths( + scope: "user" | "project", + arrayKey: "extensions" | "skills" | "prompts" | "themes", + paths: string[], + ): void { if (scope === "project") { if (arrayKey === "extensions") { - this.settingsManager.setProjectExtensionPaths(updated); + this.settingsManager.setProjectExtensionPaths(paths); } else if (arrayKey === "skills") { - this.settingsManager.setProjectSkillPaths(updated); + this.settingsManager.setProjectSkillPaths(paths); } else if (arrayKey === "prompts") { - this.settingsManager.setProjectPromptTemplatePaths(updated); + this.settingsManager.setProjectPromptTemplatePaths(paths); } else if (arrayKey === "themes") { - this.settingsManager.setProjectThemePaths(updated); - } - } else { - if (arrayKey === "extensions") { - this.settingsManager.setExtensionPaths(updated); - } else if (arrayKey === "skills") { - this.settingsManager.setSkillPaths(updated); - } else if (arrayKey === "prompts") { - this.settingsManager.setPromptTemplatePaths(updated); - } else if (arrayKey === "themes") { - this.settingsManager.setThemePaths(updated); + this.settingsManager.setProjectThemePaths(paths); } + return; + } + if (arrayKey === "extensions") { + this.settingsManager.setExtensionPaths(paths); + } else if (arrayKey === "skills") { + this.settingsManager.setSkillPaths(paths); + } else if (arrayKey === "prompts") { + this.settingsManager.setPromptTemplatePaths(paths); + } else if (arrayKey === "themes") { + this.settingsManager.setThemePaths(paths); } } - private togglePackageResource(item: ResourceItem, enabled: boolean): void { - const scope = item.metadata.scope as "user" | "project"; + private setPackageResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean { + const scope = this.getWriteScope(); const settings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const packages = [...(settings.packages ?? [])] as PackageSource[]; - const pkgIndex = packages.findIndex((pkg) => { - const source = typeof pkg === "string" ? pkg : pkg.source; - return source === item.metadata.source; - }); - - if (pkgIndex === -1) return; + let pkgIndex = packages.findIndex((pkg) => + this.packageSourceStringMatches( + item.metadata.source, + this.getItemScope(item), + typeof pkg === "string" ? pkg : pkg.source, + scope, + ), + ); + if (pkgIndex === -1) { + if (scope !== "project" || state === "inherit") { + return false; + } + packages.push(this.createPackageOverrideSource(item)); + pkgIndex = packages.length - 1; + } let pkg = packages[pkgIndex]; + if (pkg === undefined) return false; - // Convert string to object form if needed if (typeof pkg === "string") { pkg = { source: pkg }; packages[pkgIndex] = pkg; } - // Get the resource array for this type const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; const current = (pkg[arrayKey] ?? []) as string[]; - - // Generate pattern relative to package root const pattern = this.getPackageResourcePattern(item); - const disablePattern = `-${pattern}`; - const enablePattern = `+${pattern}`; + const updated = current.filter((entry) => getPatternEntryTarget(entry) !== pattern); - // Filter out existing patterns for this resource - const updated = current.filter((p) => { - const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p; - return stripped !== pattern; - }); - - if (enabled) { - updated.push(enablePattern); - } else { - updated.push(disablePattern); + if (state !== "inherit") { + updated.push(`${state === "load" ? "+" : "-"}${pattern}`); } (pkg as Record)[arrayKey] = updated.length > 0 ? updated : undefined; - // Clean up empty filter object - const hasFilters = ["extensions", "skills", "prompts", "themes"].some( - (k) => (pkg as Record)[k] !== undefined, - ); + const hasFilters = RESOURCE_TYPES.some((key) => (pkg as Record)[key] !== undefined); if (!hasFilters) { - packages[pkgIndex] = (pkg as { source: string }).source; + if (pkg.autoload === false) { + packages.splice(pkgIndex, 1); + } else { + packages[pkgIndex] = pkg.source; + } } if (scope === "project") { @@ -559,18 +874,158 @@ class ResourceList implements Component, Focusable { } else { this.settingsManager.setPackages(packages); } + return true; + } + + private getNextOverrideState(item: ResourceItem): ProjectOverrideState { + if (this.writeScope !== "project") { + return item.enabled ? "unload" : "load"; + } + + 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") { + const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; + const entries = (this.settingsManager.getProjectSettings()[arrayKey] ?? []) as string[]; + return getOverrideStateFromEntries(entries, this.getTopLevelOverridePatterns(item, "project")); + } + + const pkg = this.findMatchingPackageSource(item.metadata.source, this.getItemScope(item), "project"); + if (typeof pkg !== "object") { + return "inherit"; + } + const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; + const entries = pkg[arrayKey]; + if (entries === undefined) { + return "inherit"; + } + return getOverrideStateFromEntries(entries, new Set([this.getPackageResourcePattern(item)]), { + plainEntryIsLoad: true, + emptyArrayIsUnload: true, + }); + } + + private getInheritedEnabled(item: ResourceItem): boolean { + const inheritedEnabled = this.inheritedEnabledByKey.get(getResourceItemKey(item.resourceType, item.path)); + if (inheritedEnabled !== undefined) { + return inheritedEnabled; + } + return this.getItemScope(item) === "user" ? item.enabled : true; + } + + private isInheritedGlobalItem(item: ResourceItem): boolean { + return ( + this.getItemScope(item) === "user" || + this.inheritedEnabledByKey.has(getResourceItemKey(item.resourceType, item.path)) + ); + } + + private getTopLevelOverridePatterns(item: ResourceItem, targetScope: "user" | "project"): Set { + const patterns = new Set([this.getResourcePattern(item, targetScope), item.path]); + const targetBaseDir = this.getTopLevelBaseDir(targetScope); + patterns.add(relative(targetBaseDir, item.path)); + if (item.metadata.baseDir) { + patterns.add(relative(item.metadata.baseDir, item.path)); + } + return patterns; + } + + private getProjectTopLevelWritePattern(item: ResourceItem): string { + return this.isInheritedGlobalItem(item) ? item.path : this.getResourcePattern(item, "project"); + } + + private canEditItem(item: ResourceItem): boolean { + return this.writeScope === "project" || this.getItemScope(item) === "user"; + } + + private getItemScope(item: ResourceItem): "user" | "project" { + return item.metadata.scope === "project" ? "project" : "user"; + } + + private getWriteScope(): "user" | "project" { + return this.writeScope === "project" ? "project" : "user"; } private getTopLevelBaseDir(scope: "user" | "project"): string { return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir; } - private getResourcePattern(item: ResourceItem): string { - const scope = item.metadata.scope as "user" | "project"; - const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope); + private getResourcePattern(item: ResourceItem, targetScope: "user" | "project"): string { + const sourceScope = this.getItemScope(item); + if (targetScope !== sourceScope) { + return item.path; + } + const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope); return relative(baseDir, item.path); } + /** Build an autoload=false project delta entry for a package configured in another scope. */ + 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: "user" | "project", + rightSource: string, + rightScope: "user" | "project", + ): boolean { + if (leftSource === rightSource) { + return true; + } + if (!isLocalPath(leftSource) || !isLocalPath(rightSource)) { + return false; + } + const leftPath = resolvePath(leftSource, this.getTopLevelBaseDir(leftScope), { trim: true }); + const rightPath = resolvePath(rightSource, this.getTopLevelBaseDir(rightScope), { trim: true }); + return leftPath === rightPath; + } + + private findMatchingPackageSource( + source: string, + sourceScope: "user" | "project", + targetScope: "user" | "project", + ): PackageSource | undefined { + const targetSettings = + targetScope === "project" + ? this.settingsManager.getProjectSettings() + : this.settingsManager.getGlobalSettings(); + return (targetSettings.packages ?? []).find((pkg) => + this.packageSourceStringMatches(source, sourceScope, typeof pkg === "string" ? pkg : pkg.source, targetScope), + ); + } + + private hasMatchingPackageSource( + source: string, + sourceScope: "user" | "project", + targetScope: "user" | "project", + ): boolean { + return this.findMatchingPackageSource(source, sourceScope, targetScope) !== undefined; + } + + private projectPackageAutoloadDisabled(source: string): boolean { + const pkg = this.findMatchingPackageSource(source, "project", "project"); + return typeof pkg === "object" && pkg.autoload === false; + } + private getPackageResourcePattern(item: ResourceItem): string { const baseDir = item.metadata.baseDir ?? dirname(item.path); return relative(baseDir, item.path); @@ -578,7 +1033,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 +1047,7 @@ export class ConfigSelectorComponent extends Container implements Focusable { } constructor( - resolvedPaths: ResolvedPaths, + resolvedPaths: ScopedResolvedPaths, settingsManager: SettingsManager, cwd: string, agentDir: string, @@ -598,23 +1055,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, cwd, agentDir, "global"), + project: buildGroups(resolvedPaths.project, cwd, agentDir, "project"), + }; // 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 +1099,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 09349a3b7..c354c6e35 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 d209ce500..a80a78860 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,8 +3,12 @@ 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 { DefaultPackageManager, 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 { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { handlePackageCommand } from "../src/package-manager-cli.ts"; describe("package commands", () => { @@ -350,6 +354,458 @@ describe("package commands", () => { } }); + it("shows config subcommand help", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["config", "--help"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Usage:"); + expect(stdout).toContain("pi config [-l]"); + expect(stdout).toContain("--local"); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it("shows a friendly error for unknown config options", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["config", "--unknown"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain('Unknown option --unknown for "config".'); + expect(stderr).toContain('Use "pi --help" or "pi config [-l] [--approve|--no-approve]".'); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("blocks local config changes when project is untrusted", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), "{}"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["config", "-l"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Project is not trusted. Use --approve to modify local resource config."); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("writes config package selections to project settings in local mode", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] })); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageRoot = join(tempDir, "pkg"); + const resolvedPaths: ResolvedPaths = { + extensions: [ + { + path: join(packageRoot, "extensions", "foo.ts"), + enabled: true, + metadata: { + source: "npm:pi-tools", + scope: "user", + origin: "package", + baseDir: packageRoot, + }, + }, + ], + skills: [], + prompts: [], + themes: [], + }; + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getGlobalSettings().packages).toEqual(["npm:pi-tools"]); + expect(settingsManager.getProjectSettings().packages).toEqual([ + { source: "npm:pi-tools", autoload: false, extensions: ["-extensions/foo.ts"] }, + ]); + }); + + it("cycles project package overrides and keeps the cursor on the same resource", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] })); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageRoot = join(tempDir, "pkg"); + const resolvedPaths: ResolvedPaths = { + extensions: [ + { + path: join(packageRoot, "extensions", "bar.ts"), + enabled: true, + metadata: { + source: "npm:pi-tools", + scope: "user", + origin: "package", + baseDir: packageRoot, + }, + }, + { + path: join(packageRoot, "extensions", "foo.ts"), + enabled: true, + metadata: { + source: "npm:pi-tools", + scope: "user", + origin: "package", + baseDir: packageRoot, + }, + }, + ], + skills: [], + prompts: [], + themes: [], + }; + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + initTheme("dark"); + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getProjectSettings().packages).toEqual([ + { source: "npm:pi-tools", autoload: false, extensions: ["-extensions/bar.ts"] }, + ]); + let selectedLine = selector + .getResourceList() + .render(240) + .find((line) => line.includes("bar.ts")); + expect(selectedLine?.startsWith(">")).toBe(true); + expect(selectedLine).toContain("[-]"); + expect(selectedLine).toContain("project unload"); + + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getProjectSettings().packages).toEqual([ + { source: "npm:pi-tools", autoload: false, extensions: ["+extensions/bar.ts"] }, + ]); + selectedLine = selector + .getResourceList() + .render(240) + .find((line) => line.includes("bar.ts")); + expect(selectedLine?.startsWith(">")).toBe(true); + expect(selectedLine).toContain("[+]"); + expect(selectedLine).toContain("project load"); + + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getProjectSettings().packages).toEqual([]); + selectedLine = selector + .getResourceList() + .render(240) + .find((line) => line.includes("bar.ts")); + expect(selectedLine?.startsWith(">")).toBe(true); + expect(selectedLine).toContain("[x]"); + expect(selectedLine).toContain("inherited global"); + }); + + it("switches config selector modes with tab", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => JSON.stringify({ packages: ["npm:global-tools"] })); + storage.withLock("project", () => JSON.stringify({ packages: ["npm:project-tools"] })); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const globalRoot = join(tempDir, "global-tools"); + const projectRoot = join(tempDir, "project-tools"); + const resolvedPaths: Record<"global" | "project", ResolvedPaths> = { + global: { + extensions: [ + { + path: join(globalRoot, "extensions", "global.ts"), + enabled: true, + metadata: { + source: "npm:global-tools", + scope: "user", + origin: "package", + baseDir: globalRoot, + }, + }, + ], + skills: [], + prompts: [], + themes: [], + }, + project: { + extensions: [ + { + path: join(projectRoot, "extensions", "project.ts"), + enabled: true, + metadata: { + source: "npm:project-tools", + scope: "project", + origin: "package", + baseDir: projectRoot, + }, + }, + ], + skills: [], + prompts: [], + themes: [], + }, + }; + const selector = new ConfigSelectorComponent( + resolvedPaths, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "global", + ); + + selector.getResourceList().handleInput("\t"); + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getGlobalSettings().packages).toEqual(["npm:global-tools"]); + expect(settingsManager.getProjectSettings().packages).toEqual([ + { source: "npm:project-tools", extensions: ["-extensions/project.ts"] }, + ]); + }); + + it("writes inherited global package toggles as autoload-disabled project deltas", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + packages: [ + { + source: "../pkg", + extensions: ["-extensions/foo.ts", "-extensions/bar.ts"], + skills: ["-skills/old/SKILL.md"], + }, + ], + }), + ); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageRoot = join(tempDir, "pkg"); + const resolvedPaths: ResolvedPaths = { + extensions: [ + { + path: join(packageRoot, "extensions", "foo.ts"), + enabled: false, + metadata: { + source: "../pkg", + scope: "user", + origin: "package", + baseDir: packageRoot, + }, + }, + ], + skills: [], + prompts: [], + themes: [], + }; + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + expect(settingsManager.getProjectSettings().packages).toEqual([ + { + source: "../../pkg", + autoload: false, + extensions: ["+extensions/foo.ts"], + }, + ]); + }); + + it("merges autoload-disabled project package deltas with matching global packages in config UI", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => JSON.stringify({ packages: [{ source: "../pkg" }] })); + storage.withLock("project", () => + JSON.stringify({ packages: [{ source: "../../pkg", autoload: false, extensions: ["+extensions/foo.ts"] }] }), + ); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageRoot = join(tempDir, "pkg"); + const resolvedPaths: Record<"global" | "project", ResolvedPaths> = { + global: { extensions: [], skills: [], prompts: [], themes: [] }, + project: { + extensions: [ + { + path: join(packageRoot, "extensions", "foo.ts"), + enabled: true, + metadata: { source: "../../pkg", scope: "project", origin: "package", baseDir: packageRoot }, + }, + { + path: join(packageRoot, "extensions", "bar.ts"), + enabled: true, + metadata: { source: "../pkg", scope: "user", origin: "package", baseDir: packageRoot }, + }, + ], + skills: [], + prompts: [], + themes: [], + }, + }; + const selector = new ConfigSelectorComponent( + resolvedPaths, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + initTheme("dark"); + const output = selector.getResourceList().render(240).join("\n"); + + expect(output).toContain(`${packageRoot} (global with project-local overrides)`); + expect(output).toContain("foo.ts"); + expect(output).toContain("project load"); + expect(output).toContain("bar.ts"); + expect(output).toContain("inherited global"); + expect(output).not.toContain("project package replaces global package"); + }); + + it("resolves autoload-disabled project package entries as deltas over global packages", async () => { + const packageRoot = join(tempDir, "pkg"); + mkdirSync(join(packageRoot, "extensions"), { recursive: true }); + writeFileSync(join(packageRoot, "extensions", "foo.ts"), "export default function () {}\n"); + writeFileSync(join(packageRoot, "extensions", "bar.ts"), "export default function () {}\n"); + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + packages: [{ source: "../pkg", extensions: ["-extensions/foo.ts"] }], + }), + ); + storage.withLock("project", () => + JSON.stringify({ + packages: [{ source: "../../pkg", autoload: false, extensions: ["+extensions/foo.ts"] }], + }), + ); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageManager = new DefaultPackageManager({ cwd: projectDir, agentDir, settingsManager }); + + const resolved = await packageManager.resolve(); + + const states = Object.fromEntries( + resolved.extensions.map((resource) => [ + resource.path, + { enabled: resource.enabled, scope: resource.metadata.scope }, + ]), + ); + expect(states[join(packageRoot, "extensions", "foo.ts")]).toEqual({ enabled: true, scope: "project" }); + expect(states[join(packageRoot, "extensions", "bar.ts")]).toEqual({ enabled: true, scope: "user" }); + }); + + it("resolves autoload-disabled package entries as positive-only without a global package", async () => { + const packageRoot = join(tempDir, "positive-only-pkg"); + mkdirSync(join(packageRoot, "extensions"), { recursive: true }); + mkdirSync(join(packageRoot, "skills", "foo"), { recursive: true }); + writeFileSync(join(packageRoot, "extensions", "foo.ts"), "export default function () {}\n"); + writeFileSync(join(packageRoot, "extensions", "bar.ts"), "export default function () {}\n"); + writeFileSync(join(packageRoot, "skills", "foo", "SKILL.md"), "# Foo\n"); + const storage = new InMemorySettingsStorage(); + storage.withLock("project", () => + JSON.stringify({ + packages: [{ source: "../../positive-only-pkg", autoload: false, extensions: ["+extensions/foo.ts"] }], + }), + ); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const packageManager = new DefaultPackageManager({ cwd: projectDir, agentDir, settingsManager }); + + const resolved = await packageManager.resolve(); + + expect(resolved.extensions.map((resource) => resource.path)).toEqual([join(packageRoot, "extensions", "foo.ts")]); + expect(resolved.skills).toEqual([]); + }); + + it("writes config top-level selections to project settings in local mode", async () => { + const skillPath = join(agentDir, "skills", "foo", "SKILL.md"); + mkdirSync(join(agentDir, "skills", "foo"), { recursive: true }); + writeFileSync(skillPath, "# Foo\n"); + const settingsManager = SettingsManager.create(projectDir, agentDir, { projectTrusted: true }); + const resolvedPaths: ResolvedPaths = { + extensions: [], + skills: [ + { + path: skillPath, + enabled: true, + metadata: { + source: "auto", + scope: "user", + origin: "top-level", + baseDir: agentDir, + }, + }, + ], + prompts: [], + themes: [], + }; + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + selector.getResourceList().handleInput(" "); + await settingsManager.flush(); + + const settingsPath = join(projectDir, ".pi", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { skills?: string[] }; + expect(settings.skills).toEqual([skillPath, `-${skillPath}`]); + + const packageManager = new DefaultPackageManager({ cwd: projectDir, agentDir, settingsManager }); + const resolvedAfter = await packageManager.resolve(); + const skill = resolvedAfter.skills.find((resource) => resource.path === skillPath); + expect(skill?.enabled).toBe(false); + expect(skill?.metadata.scope).toBe("project"); + }); + it("shows a friendly error for unknown install options", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});