mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): improve config resource overrides
This commit is contained in:
parent
d53b567601
commit
22183ed627
11 changed files with 1207 additions and 145 deletions
|
|
@ -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 <src> # 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 # 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -231,8 +231,8 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} uninstall <source> [-l] Alias for remove
|
||||
${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions)
|
||||
${APP_NAME} list List installed extensions from settings
|
||||
${APP_NAME} config Open TUI to enable/disable package resources
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
|
||||
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--provider <name> Provider name (default: google)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,17 @@
|
|||
*/
|
||||
|
||||
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
|
||||
import type { ResolvedPaths } from "../core/package-manager.ts";
|
||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.ts";
|
||||
import { ConfigSelectorComponent, type ScopedResolvedPaths } from "../modes/interactive/components/config-selector.ts";
|
||||
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts";
|
||||
|
||||
export interface ConfigSelectorOptions {
|
||||
resolvedPaths: ResolvedPaths;
|
||||
resolvedPaths: ScopedResolvedPaths;
|
||||
settingsManager: SettingsManager;
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
writeScope: "global" | "project";
|
||||
projectModeAvailable: boolean;
|
||||
}
|
||||
|
||||
/** Show TUI config selector and return when closed */
|
||||
|
|
@ -44,6 +45,8 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
|
|||
},
|
||||
() => ui.requestRender(),
|
||||
ui.terminal.rows,
|
||||
options.writeScope,
|
||||
options.projectModeAvailable,
|
||||
);
|
||||
|
||||
ui.addChild(selector);
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ function resourcePrecedenceRank(m: PathMetadata): number {
|
|||
}
|
||||
|
||||
interface PackageFilter {
|
||||
autoload?: boolean;
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
prompts?: string[];
|
||||
|
|
@ -772,6 +773,36 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
|
|||
return new Set(result);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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<string, { 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 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<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||
metadata: PathMetadata,
|
||||
): void {
|
||||
if (userPatterns.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { allFiles } = this.collectManifestFiles(packageRoot, resourceType);
|
||||
const enabledByUser = applyAutoloadDisabledPatterns(allFiles, userPatterns, packageRoot);
|
||||
for (const [filePath, enabled] of enabledByUser) {
|
||||
this.addResource(target, filePath, metadata, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all files from a package for a resource type, applying manifest patterns.
|
||||
* Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files
|
||||
|
|
|
|||
|
|
@ -66,11 +66,13 @@ export type TransportSetting = Transport;
|
|||
* Package source for npm/git packages.
|
||||
* - String form: load all resources from the package
|
||||
* - Object form: filter which resources to load
|
||||
* - autoload=false: start empty and only apply explicit resource patterns
|
||||
*/
|
||||
export type PackageSource =
|
||||
| string
|
||||
| {
|
||||
source: string;
|
||||
autoload?: boolean;
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
prompts?: string[];
|
||||
|
|
|
|||
|
|
@ -18,11 +18,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<ConfigWriteScope, ResolvedPaths>;
|
||||
|
||||
const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceType[];
|
||||
|
||||
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
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<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[] {
|
||||
const groupMap = new Map<string, ResourceGroup>();
|
||||
|
||||
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<ConfigWriteScope, ResourceGroup[]>;
|
||||
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<string, boolean>;
|
||||
|
||||
public onCancel?: () => void;
|
||||
public onExit?: () => void;
|
||||
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
|
||||
public onSwitchMode?: () => void;
|
||||
|
||||
private _focused = false;
|
||||
get focused(): boolean {
|
||||
|
|
@ -220,16 +328,22 @@ class ResourceList implements Component, Focusable {
|
|||
}
|
||||
|
||||
constructor(
|
||||
groups: ResourceGroup[],
|
||||
groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>,
|
||||
settingsManager: SettingsManager,
|
||||
cwd: string,
|
||||
agentDir: string,
|
||||
terminalHeight?: number,
|
||||
writeScope: ConfigWriteScope = "global",
|
||||
) {
|
||||
this.groups = groups;
|
||||
this.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<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const group of groups) {
|
||||
for (const subgroup of group.subgroups) {
|
||||
for (const item of subgroup.items) {
|
||||
result.set(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<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) {
|
||||
|
|
@ -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<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
|
||||
|
||||
// Clean up empty filter object
|
||||
const hasFilters = ["extensions", "skills", "prompts", "themes"].some(
|
||||
(k) => (pkg as Record<string, unknown>)[k] !== undefined,
|
||||
);
|
||||
const hasFilters = RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[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<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));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,23 @@ function getPackageCommandUsage(command: PackageCommand): string {
|
|||
}
|
||||
}
|
||||
|
||||
const CONFIG_COMMAND_USAGE = `${APP_NAME} config [-l] [--approve|--no-approve]`;
|
||||
|
||||
function printConfigCommandHelp(): void {
|
||||
console.log(`${chalk.bold("Usage:")}
|
||||
${CONFIG_COMMAND_USAGE}
|
||||
|
||||
Open the resource configuration TUI to enable or disable package resources.
|
||||
Without -l, starts in global settings (~/${CONFIG_DIR_NAME}/agent/settings.json).
|
||||
Press Tab in the TUI to switch between global and project-local modes.
|
||||
|
||||
Options:
|
||||
-l, --local Edit project overrides (${CONFIG_DIR_NAME}/settings.json)
|
||||
-a, --approve Trust project-local files for this command with -l
|
||||
-na, --no-approve Ignore project-local files for this command with -l
|
||||
`);
|
||||
}
|
||||
|
||||
function printPackageCommandHelp(command: PackageCommand): void {
|
||||
switch (command) {
|
||||
case "install":
|
||||
|
|
@ -466,18 +483,6 @@ function prepareWindowsNpmSelfUpdate(): void {
|
|||
quarantineWindowsNativeDependencies(packageDir);
|
||||
}
|
||||
|
||||
function parseProjectTrustOverride(args: readonly string[]): boolean | undefined {
|
||||
let trustOverride: boolean | undefined;
|
||||
for (const arg of args) {
|
||||
if (arg === "--approve" || arg === "-a") {
|
||||
trustOverride = true;
|
||||
} else if (arg === "--no-approve" || arg === "-na") {
|
||||
trustOverride = false;
|
||||
}
|
||||
}
|
||||
return trustOverride;
|
||||
}
|
||||
|
||||
export interface PackageCommandRuntimeOptions {
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}
|
||||
|
|
@ -549,28 +554,70 @@ export async function handleConfigCommand(
|
|||
args: string[],
|
||||
runtimeOptions: PackageCommandRuntimeOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (args[0] !== "config") {
|
||||
const [command, ...rest] = args;
|
||||
if (command !== "config") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rest.includes("-h") || rest.includes("--help")) {
|
||||
printConfigCommandHelp();
|
||||
return true;
|
||||
}
|
||||
|
||||
let local = false;
|
||||
let projectTrustOverride: boolean | undefined;
|
||||
for (const arg of rest) {
|
||||
if (arg === "-l" || arg === "--local") {
|
||||
local = true;
|
||||
} else if (arg === "-a" || arg === "--approve") {
|
||||
projectTrustOverride = true;
|
||||
} else if (arg === "-na" || arg === "--no-approve") {
|
||||
projectTrustOverride = false;
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(chalk.red(`Unknown option ${arg} for "config".`));
|
||||
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${CONFIG_COMMAND_USAGE}".`));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
} else {
|
||||
console.error(chalk.red(`Unexpected argument ${arg}.`));
|
||||
console.error(chalk.dim(`Usage: ${CONFIG_COMMAND_USAGE}`));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
projectTrustOverride: parseProjectTrustOverride(args),
|
||||
projectTrustOverride,
|
||||
extensionFactories: runtimeOptions.extensionFactories,
|
||||
});
|
||||
reportProjectTrustWarnings(projectTrustWarnings);
|
||||
if (local && !settingsManager.isProjectTrusted()) {
|
||||
console.error(chalk.red("Project is not trusted. Use --approve to modify local resource config."));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
reportSettingsErrors(settingsManager, "config command");
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
|
||||
const globalResolvedPaths = await new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: globalSettingsManager,
|
||||
}).resolve();
|
||||
const projectResolvedPaths = settingsManager.isProjectTrusted()
|
||||
? await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve()
|
||||
: globalResolvedPaths;
|
||||
|
||||
await selectConfig({
|
||||
resolvedPaths,
|
||||
resolvedPaths: { global: globalResolvedPaths, project: projectResolvedPaths },
|
||||
settingsManager,
|
||||
cwd,
|
||||
agentDir,
|
||||
writeScope: local ? "project" : "global",
|
||||
projectModeAvailable: settingsManager.isProjectTrusted(),
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,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(() => {});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue