fix(coding-agent): simplify config resource overrides

This commit is contained in:
Armin Ronacher 2026-07-04 16:47:46 +02:00
parent 22183ed627
commit 008e33661f
8 changed files with 417 additions and 961 deletions

View file

@ -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 [-l] # enable/disable resources, -l for project-local selections
pi config # enable/disable extensions, skills, prompts, themes
```
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 <src> # Update one package
pi list # List installed packages
pi config [-l] # Enable/disable package resources, -l for project-local selections
pi config # Enable/disable package resources
```
`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.
`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

View file

@ -214,18 +214,6 @@ 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. `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.

View file

@ -267,8 +267,6 @@ 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

View file

@ -153,10 +153,10 @@ pi update --extensions # Update packages only; reconcile pinned git refs
pi update --self # Update pi only
pi update --extension <src> # Update one package
pi list # List installed packages
pi config [-l] # Enable/disable package resources, -l for project-local selections
pi config # Enable/disable package resources
```
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.
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.
See [Pi Packages](packages.md) for package sources and security notes.

View file

@ -774,31 +774,20 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
}
function applyAutoloadDisabledPatterns(allPaths: string[], patterns: string[], baseDir: string): Map<string, boolean> {
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<string, boolean>();
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);
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;
}
@ -1256,38 +1245,39 @@ export class DefaultPackageManager implements PackageManager {
for (const { pkg, scope } of sources) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const parsed = this.parseSource(sourceStr);
const deltaBase = this.findAutoloadDeltaBase(pkg, scope, sources);
const resolvedSource = deltaBase?.source ?? sourceStr;
const resolvedScope = deltaBase?.scope ?? scope;
const parsed = this.parseSource(resolvedSource);
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
const baseDir = this.getBaseDirForScope(resolvedScope);
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
continue;
}
const installMissing = async (): Promise<boolean> => {
if (isOfflineModeEnabled()) {
return false;
}
if (isOfflineModeEnabled()) return false;
if (!onMissing) {
await this.installParsedSource(parsed, scope);
await this.installParsedSource(parsed, resolvedScope);
return true;
}
const action = await onMissing(sourceStr);
const action = await onMissing(resolvedSource);
if (action === "skip") return false;
if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
await this.installParsedSource(parsed, scope);
if (action === "error") throw new Error(`Missing source: ${resolvedSource}`);
await this.installParsedSource(parsed, resolvedScope);
return true;
};
if (parsed.type === "npm") {
let installedPath = this.getNpmInstallPath(parsed, scope);
let installedPath = this.getNpmInstallPath(parsed, resolvedScope);
const needsInstall =
!existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
installedPath = this.getNpmInstallPath(parsed, scope);
installedPath = this.getNpmInstallPath(parsed, resolvedScope);
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
@ -1295,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);
@ -1308,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,
@ -1692,26 +1697,21 @@ export class DefaultPackageManager implements PackageManager {
private dedupePackages(
packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
): Array<{ pkg: PackageSource; scope: SourceScope }> {
const byIdentity = new Map<string, Array<{ pkg: PackageSource; scope: SourceScope }>>();
for (const entry of packages) {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const identity = this.getPackageIdentity(sourceStr, entry.scope);
const group = byIdentity.get(identity);
if (group) {
group.push(entry);
} else {
byIdentity.set(identity, [entry]);
}
}
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]);
const seen = new Map<string, number>();
for (const entry of packages) {
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;
}
}
return result;

View file

@ -25,6 +25,7 @@ import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
type ConfigWriteScope = "global" | "project";
type SettingsScope = "user" | "project";
type ProjectOverrideState = "inherit" | "load" | "unload";
export type ScopedResolvedPaths = Record<ConfigWriteScope, ResolvedPaths>;
@ -45,7 +46,6 @@ interface ResourceItem {
displayName: string;
groupKey: string;
subgroupKey: string;
projectOverride?: boolean;
}
interface ResourceSubgroup {
@ -57,7 +57,6 @@ interface ResourceSubgroup {
interface ResourceGroup {
key: string;
label: string;
displaySource: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
source: string;
@ -81,30 +80,9 @@ function formatBaseDir(baseDir: string): string {
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
}
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 {
function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
if (metadata.origin === "package") {
const scopeLabel = metadata.scope === "user" ? "global" : metadata.scope;
return `${getPackageDisplaySource(metadata, cwd, agentDir)} (${scopeLabel})`;
return `${metadata.source} (${metadata.scope})`;
}
// Top-level resources
if (metadata.source === "auto") {
@ -118,45 +96,7 @@ function getGroupLabel(metadata: PathMetadata, cwd: string, agentDir: string): s
return metadata.scope === "user" ? "User settings" : "Project settings";
}
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<string>,
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[] {
function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] {
const groupMap = new Map<string, ResourceGroup>();
const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => {
@ -167,9 +107,7 @@ function buildGroups(
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
label: getGroupLabel(metadata, cwd, agentDir),
displaySource:
metadata.origin === "package" ? getPackageDisplaySource(metadata, cwd, agentDir) : metadata.source,
label: getGroupLabel(metadata, agentDir),
scope: metadata.scope,
origin: metadata.origin,
source: metadata.source,
@ -217,22 +155,9 @@ function buildGroups(
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;
}
@ -277,25 +202,19 @@ class ConfigSelectorHeader implements Component {
render(width: number): string[] {
const title = theme.bold(this.writeScope === "project" ? "Project Local Resources" : "Global Resources");
const sep = theme.fg("muted", " · ");
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);
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, ""),
truncateToWidth(`${modeIndicator}${sep}${scopeHint}`, width, ""),
truncateToWidth(scopeHint, width, ""),
];
}
}
@ -335,14 +254,11 @@ class ResourceList implements Component, Focusable {
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
) {
this.groupsByScope = groupsByScope;
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
@ -353,9 +269,6 @@ class ResourceList implements Component, Focusable {
}
setWriteScope(writeScope: ConfigWriteScope): void {
if (this.writeScope === writeScope) {
return;
}
this.writeScope = writeScope;
this.buildFlatList();
this.filterItems(this.searchInput.getValue());
@ -370,81 +283,13 @@ class ResourceList implements Component, Focusable {
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);
result.set(this.getResourceItemKey(item), 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<string, ResourceItem>();
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) {
@ -461,11 +306,6 @@ 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) {
@ -477,10 +317,10 @@ class ResourceList implements Component, Focusable {
return fromIndex; // Stay at current if no item found
}
private filterItems(query: string, preferredItemKey = this.getSelectedItemKey()): void {
private filterItems(query: string): void {
if (!query.trim()) {
this.filteredItems = [...this.flatItems];
this.selectFirstItem(preferredItemKey);
this.selectFirstItem();
return;
}
@ -525,34 +365,23 @@ class ResourceList implements Component, Focusable {
}
}
this.selectFirstItem(preferredItemKey);
this.selectFirstItem();
}
private selectFirstItem(preferredItemKey?: string): void {
if (preferredItemKey) {
const preferredIndex = this.filteredItems.findIndex(
(entry) => getEntryResourceItemKey(entry) === preferredItemKey,
);
if (preferredIndex >= 0) {
this.selectedIndex = preferredIndex;
return;
}
}
private selectFirstItem(): void {
const firstItemIndex = this.filteredItems.findIndex((e) => e.type === "item");
this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0;
}
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);
updateItem(item: ResourceItem, enabled: boolean): void {
item.enabled = enabled;
// Update in groups too
for (const group of this.groups) {
for (const subgroup of group.subgroups) {
for (const found of subgroup.items) {
if (getResourceItemKey(found.resourceType, found.path) !== itemKey) continue;
const found = subgroup.items.find((i) => i.path === item.path && i.resourceType === item.resourceType);
if (found) {
found.enabled = enabled;
found.projectOverride = projectOverride;
return;
}
}
}
@ -585,14 +414,13 @@ class ResourceList implements Component, Focusable {
if (entry.type === "group") {
// Main group header (no cursor)
const label = theme.bold(this.getGroupDisplayLabel(entry.group));
const groupLine = this.isDimmedScope(entry.group.scope)
? theme.fg("dim", label)
: theme.fg("accent", 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 color = this.isDimmedScope(entry.group.scope) ? "dim" : "muted";
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 {
@ -600,11 +428,15 @@ class ResourceList implements Component, Focusable {
const item = entry.item;
const cursor = isSelected ? "> " : " ";
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, "..."));
lines.push(
truncateToWidth(
`${cursor} ${this.renderCheckbox(item)} ${name}${this.getItemSuffix(item)}`,
width,
"...",
),
);
}
}
@ -619,72 +451,6 @@ 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();
@ -732,16 +498,11 @@ class ResourceList implements Component, Focusable {
}
if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex];
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",
);
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;
@ -752,121 +513,120 @@ class ResourceList implements Component, Focusable {
this.filterItems(this.searchInput.getValue());
}
private setResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
if (item.metadata.origin === "top-level") {
return this.setTopLevelResourceOverride(item, state);
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";
}
return this.setPackageResourceOverride(item, state);
const enabled = !item.enabled;
if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled);
} else {
this.togglePackageResource(item, enabled);
}
return enabled;
}
private setTopLevelResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const scope = this.getWriteScope();
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
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);
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);
// 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;
});
if (state !== "inherit") {
if (isInheritedGlobal && !updated.includes(pattern)) {
updated.push(pattern);
}
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
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(paths);
this.settingsManager.setProjectExtensionPaths(updated);
} else if (arrayKey === "skills") {
this.settingsManager.setProjectSkillPaths(paths);
this.settingsManager.setProjectSkillPaths(updated);
} else if (arrayKey === "prompts") {
this.settingsManager.setProjectPromptTemplatePaths(paths);
this.settingsManager.setProjectPromptTemplatePaths(updated);
} else if (arrayKey === "themes") {
this.settingsManager.setProjectThemePaths(paths);
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);
}
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 setPackageResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const scope = this.getWriteScope();
private togglePackageResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
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;
}
const pkgIndex = packages.findIndex((pkg) => {
const source = typeof pkg === "string" ? pkg : pkg.source;
return source === item.metadata.source;
});
if (pkgIndex === -1) return;
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[];
const pattern = this.getPackageResourcePattern(item);
const updated = current.filter((entry) => getPatternEntryTarget(entry) !== pattern);
if (state !== "inherit") {
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
// Generate pattern relative to package root
const pattern = this.getPackageResourcePattern(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;
});
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
(pkg as Record<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
const hasFilters = RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[key] !== undefined);
// Clean up empty filter object
const hasFilters = ["extensions", "skills", "prompts", "themes"].some(
(k) => (pkg as Record<string, unknown>)[k] !== undefined,
);
if (!hasFilters) {
if (pkg.autoload === false) {
packages.splice(pkgIndex, 1);
} else {
packages[pkgIndex] = pkg.source;
}
packages[pkgIndex] = (pkg as { source: string }).source;
}
if (scope === "project") {
@ -874,156 +634,227 @@ class ResourceList implements Component, Focusable {
} else {
this.settingsManager.setPackages(packages);
}
}
private renderCheckbox(item: ResourceItem): string {
if (this.writeScope === "project") {
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("success", "[+]");
if (state === "unload") return theme.fg("warning", "[-]");
return theme.fg("dim", item.enabled ? "[x]" : "[ ]");
}
return item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
}
private getItemSuffix(item: ResourceItem): string {
if (this.writeScope !== "project") return "";
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("muted", " project load");
if (state === "unload") return theme.fg("muted", " project unload");
return this.isInheritedGlobalItem(item) ? theme.fg("dim", " inherited global") : "";
}
private isDimmedItem(item: ResourceItem): boolean {
return (
this.writeScope === "project" &&
this.isInheritedGlobalItem(item) &&
this.getProjectOverrideState(item) === "inherit"
);
}
private setProjectResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
return item.metadata.origin === "top-level"
? this.setProjectTopLevelOverride(item, state)
: this.setProjectPackageOverride(item, state);
}
private setProjectTopLevelOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const current = (this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[];
const pattern = this.isInheritedGlobalItem(item) ? item.path : this.getResourcePatternForScope(item, "project");
const patterns = this.getTopLevelOverridePatterns(item, "project");
const updated = current.filter((entry) => {
const target = this.getPatternEntryTarget(entry);
if ((entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) && patterns.has(target))
return false;
return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern);
});
if (state !== "inherit") {
if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern);
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
}
this.setProjectTopLevelPaths(item.resourceType, updated);
return true;
}
private setProjectTopLevelPaths(key: ResourceType, paths: string[]): void {
if (key === "extensions") this.settingsManager.setProjectExtensionPaths(paths);
else if (key === "skills") this.settingsManager.setProjectSkillPaths(paths);
else if (key === "prompts") this.settingsManager.setProjectPromptTemplatePaths(paths);
else this.settingsManager.setProjectThemePaths(paths);
}
private setProjectPackageOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const packages = [...(this.settingsManager.getProjectSettings().packages ?? [])] as PackageSource[];
let pkgIndex = packages.findIndex((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
"project",
),
);
if (pkgIndex === -1) {
if (state === "inherit") return false;
packages.push(this.createPackageOverrideSource(item));
pkgIndex = packages.length - 1;
}
let pkg = packages[pkgIndex];
if (pkg === undefined) return false;
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const pattern = this.getPackageResourcePattern(item);
const updated = ((pkg[item.resourceType] ?? []) as string[]).filter(
(entry) => this.getPatternEntryTarget(entry) !== pattern,
);
if (state !== "inherit") updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
(pkg as Record<string, unknown>)[item.resourceType] = updated.length > 0 ? updated : undefined;
if (!RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[key] !== undefined)) {
if (pkg.autoload === false) packages.splice(pkgIndex, 1);
else packages[pkgIndex] = pkg.source;
}
this.settingsManager.setProjectPackages(packages);
return true;
}
private getNextOverrideState(item: ResourceItem): ProjectOverrideState {
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";
}
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 (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"));
return this.getOverrideStateFromEntries(
(this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[],
this.getTopLevelOverridePatterns(item, "project"),
false,
);
}
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))
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 getTopLevelOverridePatterns(item: ResourceItem, targetScope: "user" | "project"): Set<string> {
const patterns = new Set<string>([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));
private getOverrideStateFromEntries(
entries: string[],
patterns: Set<string>,
emptyArrayIsUnload: boolean,
): ProjectOverrideState {
if (entries.length === 0 && emptyArrayIsUnload) return "unload";
let state: ProjectOverrideState = "inherit";
for (const entry of entries) {
if (!patterns.has(this.getPatternEntryTarget(entry))) continue;
if (entry.startsWith("!") || entry.startsWith("-")) state = "unload";
else state = "load";
}
return state;
}
private getInheritedEnabled(item: ResourceItem): boolean {
return (
this.inheritedEnabledByKey.get(this.getResourceItemKey(item)) ??
(this.getItemScope(item) === "user" ? item.enabled : true)
);
}
private isInheritedGlobalItem(item: ResourceItem): boolean {
return this.getItemScope(item) === "user" || this.inheritedEnabledByKey.has(this.getResourceItemKey(item));
}
private getTopLevelOverridePatterns(item: ResourceItem, scope: SettingsScope): Set<string> {
const baseDir = this.getTopLevelBaseDir(scope);
const patterns = new Set<string>([
this.getResourcePatternForScope(item, scope),
item.path,
relative(baseDir, item.path),
]);
if (item.metadata.baseDir) patterns.add(relative(item.metadata.baseDir, item.path));
return patterns;
}
private 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, targetScope: "user" | "project"): string {
private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string {
const sourceScope = this.getItemScope(item);
if (targetScope !== sourceScope) {
return item.path;
}
if (scope !== 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 };
}
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",
leftScope: SettingsScope,
rightSource: string,
rightScope: "user" | "project",
rightScope: SettingsScope,
): 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;
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(
source: string,
sourceScope: "user" | "project",
targetScope: "user" | "project",
): PackageSource | undefined {
const targetSettings =
private findMatchingPackageSource(item: ResourceItem, targetScope: SettingsScope): PackageSource | undefined {
const settings =
targetScope === "project"
? this.settingsManager.getProjectSettings()
: this.settingsManager.getGlobalSettings();
return (targetSettings.packages ?? []).find((pkg) =>
this.packageSourceStringMatches(source, sourceScope, typeof pkg === "string" ? pkg : pkg.source, targetScope),
return (settings.packages ?? []).find((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
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 getPatternEntryTarget(entry: string): string {
return entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
}
private projectPackageAutoloadDisabled(source: string): boolean {
const pkg = this.findMatchingPackageSource(source, "project", "project");
return typeof pkg === "object" && pkg.autoload === false;
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;
}
private getResourcePattern(item: ResourceItem): string {
const scope = item.metadata.scope as "user" | "project";
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
private getPackageResourcePattern(item: ResourceItem): string {
@ -1062,8 +893,8 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.writeScope = writeScope;
const groupsByScope = {
global: buildGroups(resolvedPaths.global, cwd, agentDir, "global"),
project: buildGroups(resolvedPaths.project, cwd, agentDir, "project"),
global: buildGroups(resolvedPaths.global, agentDir),
project: buildGroups(resolvedPaths.project, agentDir),
};
// Add header

View file

@ -3,12 +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 { DefaultPackageManager, type ResolvedPaths } from "../src/core/package-manager.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 { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { handlePackageCommand } from "../src/package-manager-cli.ts";
describe("package commands", () => {
@ -32,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");
@ -354,78 +371,11 @@ 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 () => {
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 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 resolvedPaths = extensionPaths(join(tempDir, "pkg"), "npm:pi-tools", "user", ["bar.ts"]);
const selector = new ConfigSelectorComponent(
{ global: resolvedPaths, project: resolvedPaths },
settingsManager,
@ -439,371 +389,17 @@ describe("package commands", () => {
);
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 () => {

View file

@ -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", () => {