diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 905524fe..7798cc7f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -131,6 +131,8 @@ Edit directly or use `/settings` for common options. These settings define where to load extensions, skills, prompts, and themes from. +Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported. + | Setting | Type | Default | Description | |---------|------|---------|-------------| | `packages` | array | `[]` | npm/git packages to load resources from | @@ -140,6 +142,8 @@ These settings define where to load extensions, skills, prompts, and themes from | `themes` | string[] | `[]` | Local theme file paths or directories | | `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands | +Arrays support glob patterns and exclusions. Use `!pattern` to exclude. Use `+path` to force-include an exact path and `-path` to force-exclude an exact path. + #### packages String form loads all resources from a package: diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index a4a140d7..b30bfd3b 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -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, 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 { if (!resolved) { resolved = true; diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 5c06245e..6849744f 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 = { }; 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 { 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(); + 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, 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) { diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index f6c5f1c8..2533b3ad 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -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)[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(); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index c3a9402d..003c9762 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -14,13 +14,14 @@ const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" describe("DefaultPackageManager", () => { let tempDir: string; + let agentDir: string; let settingsManager: SettingsManager; let packageManager: DefaultPackageManager; beforeEach(() => { tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); - const agentDir = join(tempDir, "agent"); + agentDir = join(tempDir, "agent"); mkdirSync(agentDir, { recursive: true }); settingsManager = SettingsManager.inMemory(); @@ -45,16 +46,18 @@ describe("DefaultPackageManager", () => { }); it("should resolve local extension paths from settings", async () => { - const extPath = join(tempDir, "my-extension.ts"); + const extDir = join(agentDir, "extensions"); + mkdirSync(extDir, { recursive: true }); + const extPath = join(extDir, "my-extension.ts"); writeFileSync(extPath, "export default function() {}"); - settingsManager.setExtensionPaths([extPath]); + settingsManager.setExtensionPaths(["extensions/my-extension.ts"]); const result = await packageManager.resolve(); expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); }); it("should resolve skill paths from settings", async () => { - const skillDir = join(tempDir, "skills", "my-skill"); + const skillDir = join(agentDir, "skills", "my-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync( join(skillDir, "SKILL.md"), @@ -65,12 +68,24 @@ description: A test skill Content`, ); - settingsManager.setSkillPaths([join(tempDir, "skills")]); + settingsManager.setSkillPaths(["skills"]); const result = await packageManager.resolve(); // Skills with SKILL.md are returned as directory paths expect(result.skills.some((r) => r.path === skillDir && r.enabled)).toBe(true); }); + + it("should resolve project paths relative to .pi", async () => { + const extDir = join(tempDir, ".pi", "extensions"); + mkdirSync(extDir, { recursive: true }); + const extPath = join(extDir, "project-ext.ts"); + writeFileSync(extPath, "export default function() {}"); + + settingsManager.setProjectExtensionPaths(["extensions/project-ext.ts"]); + + const result = await packageManager.resolve(); + expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); + }); }); describe("resolveExtensionSources", () => { @@ -174,12 +189,12 @@ Content`, describe("pattern filtering in top-level arrays", () => { it("should exclude extensions with ! pattern", async () => { - const extDir = join(tempDir, "extensions"); + const extDir = join(agentDir, "extensions"); mkdirSync(extDir, { recursive: true }); writeFileSync(join(extDir, "keep.ts"), "export default function() {}"); writeFileSync(join(extDir, "remove.ts"), "export default function() {}"); - settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]); + settingsManager.setExtensionPaths(["extensions", "!**/remove.ts"]); const result = await packageManager.resolve(); expect(result.extensions.some((r) => isEnabled(r, "keep.ts"))).toBe(true); @@ -187,13 +202,13 @@ Content`, }); it("should filter themes with glob patterns", async () => { - const themesDir = join(tempDir, "themes"); + const themesDir = join(agentDir, "themes"); mkdirSync(themesDir, { recursive: true }); writeFileSync(join(themesDir, "dark.json"), "{}"); writeFileSync(join(themesDir, "light.json"), "{}"); writeFileSync(join(themesDir, "funky.json"), "{}"); - settingsManager.setThemePaths([themesDir, "!funky.json"]); + settingsManager.setThemePaths(["themes", "!funky.json"]); const result = await packageManager.resolve(); expect(result.themes.some((r) => isEnabled(r, "dark.json"))).toBe(true); @@ -202,12 +217,12 @@ Content`, }); it("should filter prompts with exclusion pattern", async () => { - const promptsDir = join(tempDir, "prompts"); + const promptsDir = join(agentDir, "prompts"); mkdirSync(promptsDir, { recursive: true }); writeFileSync(join(promptsDir, "review.md"), "Review code"); writeFileSync(join(promptsDir, "explain.md"), "Explain code"); - settingsManager.setPromptTemplatePaths([promptsDir, "!explain.md"]); + settingsManager.setPromptTemplatePaths(["prompts", "!explain.md"]); const result = await packageManager.resolve(); expect(result.prompts.some((r) => isEnabled(r, "review.md"))).toBe(true); @@ -215,7 +230,7 @@ Content`, }); it("should filter skills with exclusion pattern", async () => { - const skillsDir = join(tempDir, "skills"); + const skillsDir = join(agentDir, "skills"); mkdirSync(join(skillsDir, "good-skill"), { recursive: true }); mkdirSync(join(skillsDir, "bad-skill"), { recursive: true }); writeFileSync( @@ -227,7 +242,7 @@ Content`, "---\nname: bad-skill\ndescription: Bad\n---\nContent", ); - settingsManager.setSkillPaths([skillsDir, "!**/bad-skill"]); + settingsManager.setSkillPaths(["skills", "!**/bad-skill"]); const result = await packageManager.resolve(); expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true); @@ -235,10 +250,12 @@ Content`, }); it("should work without patterns (backward compatible)", async () => { - const extPath = join(tempDir, "my-ext.ts"); + const extDir = join(agentDir, "extensions"); + mkdirSync(extDir, { recursive: true }); + const extPath = join(extDir, "my-ext.ts"); writeFileSync(extPath, "export default function() {}"); - settingsManager.setExtensionPaths([extPath]); + settingsManager.setExtensionPaths(["extensions/my-ext.ts"]); const result = await packageManager.resolve(); expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); @@ -266,7 +283,7 @@ Content`, const result = await packageManager.resolveExtensionSources([pkgDir]); expect(result.extensions.some((r) => isEnabled(r, "local.ts"))).toBe(true); expect(result.extensions.some((r) => isEnabled(r, "remote.ts"))).toBe(true); - expect(result.extensions.some((r) => isDisabled(r, "skip.ts"))).toBe(true); + expect(result.extensions.some((r) => r.path.endsWith("skip.ts"))).toBe(false); }); it("should support glob patterns in manifest skills", async () => { @@ -293,7 +310,7 @@ Content`, const result = await packageManager.resolveExtensionSources([pkgDir]); expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true); - expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true); + expect(result.skills.some((r) => r.path.includes("bad-skill"))).toBe(false); }); }); @@ -333,7 +350,7 @@ Content`, // bar.ts should be excluded (by user) expect(result.extensions.some((r) => isDisabled(r, "bar.ts"))).toBe(true); // baz.ts should be excluded (by manifest) - expect(result.extensions.some((r) => isDisabled(r, "baz.ts"))).toBe(true); + expect(result.extensions.some((r) => r.path.endsWith("baz.ts"))).toBe(false); }); it("should exclude extensions from package with ! pattern", async () => { @@ -427,14 +444,14 @@ Content`, describe("force-include patterns", () => { it("should force-include extensions with + pattern after exclusion", async () => { - const extDir = join(tempDir, "extensions"); + const extDir = join(agentDir, "extensions"); mkdirSync(extDir, { recursive: true }); writeFileSync(join(extDir, "keep.ts"), "export default function() {}"); writeFileSync(join(extDir, "excluded.ts"), "export default function() {}"); writeFileSync(join(extDir, "force-back.ts"), "export default function() {}"); // Exclude all, then force-include one back - settingsManager.setExtensionPaths([extDir, "!**/*.ts", "+**/force-back.ts"]); + settingsManager.setExtensionPaths(["extensions", "!extensions/*.ts", "+extensions/force-back.ts"]); const result = await packageManager.resolve(); expect(result.extensions.some((r) => isDisabled(r, "keep.ts"))).toBe(true); @@ -452,7 +469,7 @@ Content`, settingsManager.setPackages([ { source: pkgDir, - extensions: ["!*", "+**/beta.ts"], + extensions: ["!**/*.ts", "+extensions/beta.ts"], skills: [], prompts: [], themes: [], @@ -478,7 +495,7 @@ Content`, { source: pkgDir, extensions: [], - skills: ["!*", "+**/skill-a", "+**/skill-c"], + skills: ["!**/*", "+skills/skill-a", "+skills/skill-c"], prompts: [], themes: [], }, @@ -491,13 +508,13 @@ Content`, }); it("should force-include after specific exclusion", async () => { - const extDir = join(tempDir, "specific-force"); + const extDir = join(agentDir, "extensions"); mkdirSync(extDir, { recursive: true }); writeFileSync(join(extDir, "a.ts"), "export default function() {}"); writeFileSync(join(extDir, "b.ts"), "export default function() {}"); // Specifically exclude b.ts, then force it back - settingsManager.setExtensionPaths([extDir, "!**/b.ts", "+**/b.ts"]); + settingsManager.setExtensionPaths(["extensions", "!extensions/b.ts", "+extensions/b.ts"]); const result = await packageManager.resolve(); expect(result.extensions.some((r) => isEnabled(r, "a.ts"))).toBe(true); @@ -515,7 +532,7 @@ Content`, JSON.stringify({ name: "manifest-force-pkg", pi: { - extensions: ["extensions", "!**/two.ts", "+**/two.ts"], + extensions: ["extensions", "!**/two.ts", "+extensions/two.ts"], }, }), ); @@ -527,13 +544,13 @@ Content`, }); it("should force-include themes", async () => { - const themesDir = join(tempDir, "force-themes"); + const themesDir = join(agentDir, "themes"); mkdirSync(themesDir, { recursive: true }); writeFileSync(join(themesDir, "dark.json"), "{}"); writeFileSync(join(themesDir, "light.json"), "{}"); writeFileSync(join(themesDir, "special.json"), "{}"); - settingsManager.setThemePaths([themesDir, "!*.json", "+special.json"]); + settingsManager.setThemePaths(["themes", "!themes/*.json", "+themes/special.json"]); const result = await packageManager.resolve(); expect(result.themes.some((r) => isDisabled(r, "dark.json"))).toBe(true); @@ -542,13 +559,13 @@ Content`, }); it("should force-include prompts", async () => { - const promptsDir = join(tempDir, "force-prompts"); + const promptsDir = join(agentDir, "prompts"); mkdirSync(promptsDir, { recursive: true }); writeFileSync(join(promptsDir, "review.md"), "Review"); writeFileSync(join(promptsDir, "explain.md"), "Explain"); writeFileSync(join(promptsDir, "debug.md"), "Debug"); - settingsManager.setPromptTemplatePaths([promptsDir, "!*", "+debug.md"]); + settingsManager.setPromptTemplatePaths(["prompts", "!prompts/*.md", "+prompts/debug.md"]); const result = await packageManager.resolve(); expect(result.prompts.some((r) => isDisabled(r, "review.md"))).toBe(true); @@ -557,6 +574,42 @@ Content`, }); }); + describe("force-exclude patterns", () => { + it("should force-exclude top-level resources", async () => { + const extDir = join(agentDir, "extensions"); + mkdirSync(extDir, { recursive: true }); + writeFileSync(join(extDir, "alpha.ts"), "export default function() {}"); + writeFileSync(join(extDir, "beta.ts"), "export default function() {}"); + + settingsManager.setExtensionPaths(["extensions", "+extensions/alpha.ts", "-extensions/alpha.ts"]); + + const result = await packageManager.resolve(); + expect(result.extensions.some((r) => isDisabled(r, "alpha.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "beta.ts"))).toBe(true); + }); + + it("should force-exclude in package filters", async () => { + const pkgDir = join(tempDir, "force-exclude-pkg"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + writeFileSync(join(pkgDir, "extensions", "alpha.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "extensions", "beta.ts"), "export default function() {}"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: ["extensions/*.ts", "+extensions/alpha.ts", "-extensions/alpha.ts"], + skills: [], + prompts: [], + themes: [], + }, + ]); + + const result = await packageManager.resolve(); + expect(result.extensions.some((r) => isDisabled(r, "alpha.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "beta.ts"))).toBe(true); + }); + }); + describe("package deduplication", () => { it("should dedupe same local package in global and project (project wins)", async () => { const pkgDir = join(tempDir, "shared-pkg");