fix(coding-agent): add force exclude pattern and fix config toggle persistence

- Add `-path` force-exclude pattern (exact path match, highest precedence)
- Change `+path` force-include to exact path match (no glob)
- Manifest-excluded resources are now hidden from config (not toggleable)
- Config toggle uses `+` to enable and `-` to disable
- Settings paths resolve relative to settings.json location:
  - Global: relative to ~/.pi/agent
  - Project: relative to .pi
- Add baseDir to PathMetadata for proper relative path computation
- Update tests for new base directory and pattern behavior

fixes #951
This commit is contained in:
Mario Zechner 2026-01-26 12:47:07 +01:00
parent 7a0b435923
commit ea93e2f3da
5 changed files with 259 additions and 102 deletions

View file

@ -3,8 +3,9 @@
*/
import { existsSync, readdirSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { basename, join, relative } from "node:path";
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../core/package-manager.js";
import type { SettingsManager } from "../core/settings-manager.js";
@ -164,18 +165,49 @@ function collectExtensionEntries(dir: string): string[] {
return entries;
}
function isExcludedByPatterns(filePath: string, patterns: string[]): boolean {
const name = basename(filePath);
for (const pattern of patterns) {
if (pattern.startsWith("!")) {
const excludePattern = pattern.slice(1);
// Match against basename or full path
if (name === excludePattern || filePath.endsWith(excludePattern)) {
return true;
}
}
function normalizeExactPattern(pattern: string): string {
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
return pattern.slice(2);
}
return false;
return pattern;
}
function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {
const rel = relative(baseDir, filePath);
const name = basename(filePath);
return patterns.some(
(pattern) => minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern),
);
}
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
if (patterns.length === 0) return false;
const rel = relative(baseDir, filePath);
return patterns.some((pattern) => {
const normalized = normalizeExactPattern(pattern);
return normalized === rel || normalized === filePath;
});
}
function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {
const overrides = patterns.filter(
(pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"),
);
const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));
const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));
let enabled = true;
if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {
enabled = false;
}
if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
enabled = true;
}
if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
enabled = false;
}
return enabled;
}
/**
@ -202,18 +234,18 @@ function mergeAutoDiscoveredResources(
themes: new Set(resolvedPaths.themes.map((r) => r.path)),
};
// Get exclusion patterns from settings
// Get override patterns from settings
const globalSettings = settingsManager.getGlobalSettings();
const projectSettings = settingsManager.getProjectSettings();
const userExclusions = {
const userOverrides = {
extensions: globalSettings.extensions ?? [],
skills: globalSettings.skills ?? [],
prompts: globalSettings.prompts ?? [],
themes: globalSettings.themes ?? [],
};
const projectExclusions = {
const projectOverrides = {
extensions: projectSettings.extensions ?? [],
skills: projectSettings.skills ?? [],
prompts: projectSettings.prompts ?? [],
@ -225,17 +257,21 @@ function mergeAutoDiscoveredResources(
existing: Set<string>,
paths: string[],
metadata: PathMetadata,
exclusions: string[],
overrides: string[],
baseDir: string,
) => {
for (const path of paths) {
if (!existing.has(path)) {
const enabled = !isExcludedByPatterns(path, exclusions);
const enabled = isEnabledByOverrides(path, overrides, baseDir);
target.push({ path, enabled, metadata });
existing.add(path);
}
}
};
const userBaseDir = agentDir;
const projectBaseDir = join(cwd, CONFIG_DIR_NAME);
// User scope auto-discovery
const userExtDir = join(agentDir, "extensions");
const userSkillsDir = join(agentDir, "skills");
@ -247,28 +283,32 @@ function mergeAutoDiscoveredResources(
existingPaths.extensions,
collectExtensionEntries(userExtDir),
{ source: "auto", scope: "user", origin: "top-level" },
userExclusions.extensions,
userOverrides.extensions,
userBaseDir,
);
addResources(
result.skills,
existingPaths.skills,
collectSkillEntries(userSkillsDir),
{ source: "auto", scope: "user", origin: "top-level" },
userExclusions.skills,
userOverrides.skills,
userBaseDir,
);
addResources(
result.prompts,
existingPaths.prompts,
collectFiles(userPromptsDir, FILE_PATTERNS.prompts),
{ source: "auto", scope: "user", origin: "top-level" },
userExclusions.prompts,
userOverrides.prompts,
userBaseDir,
);
addResources(
result.themes,
existingPaths.themes,
collectFiles(userThemesDir, FILE_PATTERNS.themes),
{ source: "auto", scope: "user", origin: "top-level" },
userExclusions.themes,
userOverrides.themes,
userBaseDir,
);
// Project scope auto-discovery
@ -282,28 +322,32 @@ function mergeAutoDiscoveredResources(
existingPaths.extensions,
collectExtensionEntries(projectExtDir),
{ source: "auto", scope: "project", origin: "top-level" },
projectExclusions.extensions,
projectOverrides.extensions,
projectBaseDir,
);
addResources(
result.skills,
existingPaths.skills,
collectSkillEntries(projectSkillsDir),
{ source: "auto", scope: "project", origin: "top-level" },
projectExclusions.skills,
projectOverrides.skills,
projectBaseDir,
);
addResources(
result.prompts,
existingPaths.prompts,
collectFiles(projectPromptsDir, FILE_PATTERNS.prompts),
{ source: "auto", scope: "project", origin: "top-level" },
projectExclusions.prompts,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
result.themes,
existingPaths.themes,
collectFiles(projectThemesDir, FILE_PATTERNS.themes),
{ source: "auto", scope: "project", origin: "top-level" },
projectExclusions.themes,
projectOverrides.themes,
projectBaseDir,
);
return result;
@ -330,6 +374,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
allPaths,
options.settingsManager,
options.cwd,
options.agentDir,
() => {
if (!resolved) {
resolved = true;

View file

@ -12,6 +12,7 @@ export interface PathMetadata {
source: string;
scope: SourceScope;
origin: "package" | "top-level";
baseDir?: string;
}
export interface ResolvedResource {
@ -115,7 +116,7 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
};
function isPattern(s: string): boolean {
return s.startsWith("!") || s.startsWith("+") || s.includes("*") || s.includes("?");
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
}
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
@ -218,21 +219,41 @@ function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string
);
}
function normalizeExactPattern(pattern: string): string {
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
return pattern.slice(2);
}
return pattern;
}
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
if (patterns.length === 0) return false;
const rel = relative(baseDir, filePath);
return patterns.some((pattern) => {
const normalized = normalizeExactPattern(pattern);
return normalized === rel || normalized === filePath;
});
}
/**
* Apply patterns to paths and return a Set of enabled paths.
* Pattern types:
* - Plain patterns: include matching paths
* - `!pattern`: exclude matching paths
* - `+pattern`: force-include matching paths (overrides exclusions)
* - `+path`: force-include exact path (overrides exclusions)
* - `-path`: force-exclude exact path (overrides force-includes)
*/
function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set<string> {
const includes: string[] = [];
const excludes: string[] = [];
const forceIncludes: string[] = [];
const forceExcludes: string[] = [];
for (const p of patterns) {
if (p.startsWith("+")) {
forceIncludes.push(p.slice(1));
} else if (p.startsWith("-")) {
forceExcludes.push(p.slice(1));
} else if (p.startsWith("!")) {
excludes.push(p.slice(1));
} else {
@ -256,12 +277,17 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
// Step 3: Force-include (add back from allPaths, overriding exclusions)
if (forceIncludes.length > 0) {
for (const filePath of allPaths) {
if (!result.includes(filePath) && matchesAnyPattern(filePath, forceIncludes, baseDir)) {
if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
result.push(filePath);
}
}
}
// Step 4: Force-exclude (remove even if included or force-included)
if (forceExcludes.length > 0) {
result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir));
}
return new Set(result);
}
@ -338,20 +364,35 @@ export class DefaultPackageManager implements PackageManager {
const packageSources = this.dedupePackages(allPackages);
await this.resolvePackageSources(packageSources, accumulator, onMissing);
const globalBaseDir = this.agentDir;
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
this.resolveLocalEntries(globalEntries, resourceType, target, {
source: "local",
scope: "user",
origin: "top-level",
});
this.resolveLocalEntries(projectEntries, resourceType, target, {
source: "local",
scope: "project",
origin: "top-level",
});
this.resolveLocalEntries(
globalEntries,
resourceType,
target,
{
source: "local",
scope: "user",
origin: "top-level",
},
globalBaseDir,
);
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: "project",
origin: "top-level",
},
projectBaseDir,
);
}
return this.toResolvedPaths(accumulator);
@ -470,6 +511,7 @@ export class DefaultPackageManager implements PackageManager {
const installed = await installMissing();
if (!installed) continue;
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
continue;
}
@ -480,6 +522,7 @@ export class DefaultPackageManager implements PackageManager {
const installed = await installMissing();
if (!installed) continue;
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
}
}
@ -499,10 +542,12 @@ export class DefaultPackageManager implements PackageManager {
try {
const stats = statSync(resolved);
if (stats.isFile()) {
metadata.baseDir = dirname(resolved);
this.addResource(accumulator.extensions, resolved, metadata, true);
return;
}
if (stats.isDirectory()) {
metadata.baseDir = resolved;
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
if (!resources) {
this.addResource(accumulator.extensions, resolved, metadata, true);
@ -802,6 +847,14 @@ export class DefaultPackageManager implements PackageManager {
return resolve(this.cwd, trimmed);
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
}
private collectPackageResources(
packageRoot: string,
accumulator: ResourceAccumulator,
@ -893,18 +946,10 @@ export class DefaultPackageManager implements PackageManager {
}
// Apply user patterns on top of manifest-enabled files
const enabledByUser = applyPatterns(Array.from(enabledByManifest), userPatterns, packageRoot);
// A file is enabled if it passes both manifest AND user patterns
// But force-include (+) in user patterns can bring back files excluded by manifest
const forceIncludePatterns = userPatterns.filter((p) => p.startsWith("+")).map((p) => p.slice(1));
const forceEnabled =
forceIncludePatterns.length > 0
? new Set(allFiles.filter((f) => matchesAnyPattern(f, forceIncludePatterns, packageRoot)))
: new Set<string>();
const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot);
for (const f of allFiles) {
const enabled = enabledByUser.has(f) || forceEnabled.has(f);
const enabled = enabledByUser.has(f);
this.addResource(target, f, metadata, enabled);
}
}
@ -925,7 +970,7 @@ export class DefaultPackageManager implements PackageManager {
const manifestPatterns = entries.filter(isPattern);
const enabledByManifest =
manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles);
return { allFiles, enabledByManifest };
return { allFiles: Array.from(enabledByManifest), enabledByManifest };
}
const conventionDir = join(packageRoot, resourceType);
@ -968,7 +1013,9 @@ export class DefaultPackageManager implements PackageManager {
const enabledPaths = applyPatterns(allFiles, patterns, root);
for (const f of allFiles) {
this.addResource(target, f, metadata, enabledPaths.has(f));
if (enabledPaths.has(f)) {
this.addResource(target, f, metadata, true);
}
}
}
@ -983,16 +1030,17 @@ export class DefaultPackageManager implements PackageManager {
resourceType: ResourceType,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
metadata: PathMetadata,
baseDir: string,
): void {
if (entries.length === 0) return;
// Collect all files from plain entries (non-pattern entries)
const { plain, patterns } = splitPatterns(entries);
const resolvedPlain = plain.map((p) => this.resolvePath(p));
const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir));
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
// Determine which files are enabled based on patterns
const enabledPaths = applyPatterns(allFiles, patterns, this.cwd);
const enabledPaths = applyPatterns(allFiles, patterns, baseDir);
// Add all files with their enabled state
for (const f of allFiles) {

View file

@ -2,7 +2,7 @@
* TUI component for managing package resources (enable/disable)
*/
import { basename, relative } from "node:path";
import { basename, dirname, join, relative } from "node:path";
import {
type Component,
Container,
@ -14,6 +14,7 @@ import {
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
import { CONFIG_DIR_NAME } from "../../../config.js";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js";
import { theme } from "../theme/theme.js";
@ -170,6 +171,7 @@ class ResourceList implements Component, Focusable {
private maxVisible = 15;
private settingsManager: SettingsManager;
private cwd: string;
private agentDir: string;
public onCancel?: () => void;
public onExit?: () => void;
@ -184,10 +186,11 @@ class ResourceList implements Component, Focusable {
this.searchInput.focused = value;
}
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string) {
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string, agentDir: string) {
this.groups = groups;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.searchInput = new Input();
this.buildFlatList();
this.filteredItems = [...this.flatItems];
@ -416,19 +419,20 @@ class ResourceList implements Component, Focusable {
// Generate pattern for this resource
const pattern = this.getResourcePattern(item);
const disablePattern = `!${pattern}`;
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.slice(1) : p;
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
return stripped !== pattern;
});
if (!enabled) {
// Add !pattern to disable
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
// For enabling, just remove the !pattern (done above)
if (scope === "project") {
if (arrayKey === "extensions") {
@ -480,18 +484,20 @@ class ResourceList implements Component, Focusable {
// Generate pattern relative to package root
const pattern = this.getPackageResourcePattern(item);
const disablePattern = `!${pattern}`;
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.slice(1) : p;
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
return stripped !== pattern;
});
if (!enabled) {
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
// For enabling, just remove the !pattern (done above)
(pkg as Record<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
@ -510,19 +516,19 @@ class ResourceList implements Component, Focusable {
}
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
private getResourcePattern(item: ResourceItem): string {
// Try relative path from cwd first
const rel = relative(this.cwd, item.path);
if (!rel.startsWith("..") && !rel.startsWith("/")) {
return rel;
}
// Fall back to basename
return basename(item.path);
const scope = item.metadata.scope as "user" | "project";
const baseDir = this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
private getPackageResourcePattern(item: ResourceItem): string {
// For package resources, use just the filename
return basename(item.path);
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
}
@ -542,6 +548,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
resolvedPaths: ResolvedPaths,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
onClose: () => void,
onExit: () => void,
requestRender: () => void,
@ -558,7 +565,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Resource list
this.resourceList = new ResourceList(groups, settingsManager, cwd);
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir);
this.resourceList.onCancel = onClose;
this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender();