From 3e8eb956b3e8af7a207001634f2d355cd34d07b3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 25 Jan 2026 02:13:24 +0100 Subject: [PATCH] feat(coding-agent): resolve resources with enabled state --- .../coding-agent/src/core/package-manager.ts | 277 ++++++++++-------- .../coding-agent/src/core/resource-loader.ts | 57 ++-- packages/coding-agent/src/index.ts | 1 + .../coding-agent/test/package-manager.test.ts | 234 ++++++++++++--- 4 files changed, 389 insertions(+), 180 deletions(-) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 0148a206..5c06245e 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -14,12 +14,17 @@ export interface PathMetadata { origin: "package" | "top-level"; } +export interface ResolvedResource { + path: string; + enabled: boolean; + metadata: PathMetadata; +} + export interface ResolvedPaths { - extensions: string[]; - skills: string[]; - prompts: string[]; - themes: string[]; - metadata: Map; + extensions: ResolvedResource[]; + skills: ResolvedResource[]; + prompts: ResolvedResource[]; + themes: ResolvedResource[]; } export type MissingSourceAction = "install" | "skip" | "error"; @@ -85,11 +90,10 @@ interface PiManifest { } interface ResourceAccumulator { - extensions: Set; - skills: Set; - prompts: Set; - themes: Set; - metadata: Map; + extensions: Map; + skills: Map; + prompts: Map; + themes: Map; } interface PackageFilter { @@ -111,11 +115,7 @@ const FILE_PATTERNS: Record = { }; function isPattern(s: string): boolean { - return s.startsWith("!") || s.includes("*") || s.includes("?"); -} - -function hasPatterns(entries: string[]): boolean { - return entries.some(isPattern); + return s.startsWith("!") || s.startsWith("+") || s.includes("*") || s.includes("?"); } function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { @@ -210,42 +210,59 @@ function collectSkillEntries(dir: string): string[] { return entries; } -function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): string[] { +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), + ); +} + +/** + * 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) + */ +function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set { const includes: string[] = []; const excludes: string[] = []; + const forceIncludes: string[] = []; for (const p of patterns) { - if (p.startsWith("!")) { + if (p.startsWith("+")) { + forceIncludes.push(p.slice(1)); + } else if (p.startsWith("!")) { excludes.push(p.slice(1)); } else { includes.push(p); } } + // Step 1: Apply includes (or all if no includes) let result: string[]; if (includes.length === 0) { result = [...allPaths]; } else { - result = allPaths.filter((filePath) => { - const rel = relative(baseDir, filePath); - const name = basename(filePath); - return includes.some((pattern) => { - return minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern); - }); - }); + result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir)); } + // Step 2: Apply excludes if (excludes.length > 0) { - result = result.filter((filePath) => { - const rel = relative(baseDir, filePath); - const name = basename(filePath); - return !excludes.some((pattern) => { - return minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern); - }); - }); + result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir)); } - return result; + // 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)) { + result.push(filePath); + } + } + } + + return new Set(result); } export class DefaultPackageManager implements PackageManager { @@ -322,23 +339,19 @@ export class DefaultPackageManager implements PackageManager { await this.resolvePackageSources(packageSources, accumulator, onMissing); for (const resourceType of RESOURCE_TYPES) { - const target = this.getTargetSet(accumulator, resourceType); + 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" }, - accumulator, - ); - this.resolveLocalEntries( - projectEntries, - resourceType, - target, - { source: "local", scope: "project", origin: "top-level" }, - accumulator, - ); + this.resolveLocalEntries(globalEntries, resourceType, target, { + source: "local", + scope: "user", + origin: "top-level", + }); + this.resolveLocalEntries(projectEntries, resourceType, target, { + source: "local", + scope: "project", + origin: "top-level", + }); } return this.toResolvedPaths(accumulator); @@ -486,13 +499,13 @@ export class DefaultPackageManager implements PackageManager { try { const stats = statSync(resolved); if (stats.isFile()) { - this.addPath(accumulator.extensions, resolved, metadata, accumulator); + this.addResource(accumulator.extensions, resolved, metadata, true); return; } if (stats.isDirectory()) { const resources = this.collectPackageResources(resolved, accumulator, filter, metadata); if (!resources) { - this.addPath(accumulator.extensions, resolved, metadata, accumulator); + this.addResource(accumulator.extensions, resolved, metadata, true); } } } catch { @@ -798,11 +811,11 @@ export class DefaultPackageManager implements PackageManager { if (filter) { for (const resourceType of RESOURCE_TYPES) { const patterns = filter[resourceType as keyof PackageFilter]; - const target = this.getTargetSet(accumulator, resourceType); + const target = this.getTargetMap(accumulator, resourceType); if (patterns !== undefined) { - this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata, accumulator); + this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); } else { - this.collectDefaultResources(packageRoot, resourceType, target, metadata, accumulator); + this.collectDefaultResources(packageRoot, resourceType, target, metadata); } } return true; @@ -816,9 +829,8 @@ export class DefaultPackageManager implements PackageManager { entries, packageRoot, resourceType, - this.getTargetSet(accumulator, resourceType), + this.getTargetMap(accumulator, resourceType), metadata, - accumulator, ); } return true; @@ -828,7 +840,12 @@ export class DefaultPackageManager implements PackageManager { for (const resourceType of RESOURCE_TYPES) { const dir = join(packageRoot, resourceType); if (existsSync(dir)) { - this.addPath(this.getTargetSet(accumulator, resourceType), dir, metadata, accumulator); + // Collect all files from the directory (all enabled by default) + const files = + resourceType === "skills" ? collectSkillEntries(dir) : collectFiles(dir, FILE_PATTERNS[resourceType]); + for (const f of files) { + this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); + } hasAnyDir = true; } } @@ -838,19 +855,23 @@ export class DefaultPackageManager implements PackageManager { private collectDefaultResources( packageRoot: string, resourceType: ResourceType, - target: Set, + target: Map, metadata: PathMetadata, - accumulator: ResourceAccumulator, ): void { const manifest = this.readPiManifest(packageRoot); const entries = manifest?.[resourceType as keyof PiManifest]; if (entries) { - this.addManifestEntries(entries, packageRoot, resourceType, target, metadata, accumulator); + this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); return; } const dir = join(packageRoot, resourceType); if (existsSync(dir)) { - this.addPath(target, dir, metadata, accumulator); + // Collect all files from the directory (all enabled by default) + const files = + resourceType === "skills" ? collectSkillEntries(dir) : collectFiles(dir, FILE_PATTERNS[resourceType]); + for (const f of files) { + this.addResource(target, f, metadata, true); + } } } @@ -858,37 +879,64 @@ export class DefaultPackageManager implements PackageManager { packageRoot: string, userPatterns: string[], resourceType: ResourceType, - target: Set, + target: Map, metadata: PathMetadata, - accumulator: ResourceAccumulator, ): void { + const { allFiles, enabledByManifest } = this.collectManifestFiles(packageRoot, resourceType); + if (userPatterns.length === 0) { + // No user patterns, just use manifest filtering + for (const f of allFiles) { + this.addResource(target, f, metadata, enabledByManifest.has(f)); + } return; } - const manifestFiles = this.collectManifestFilteredFiles(packageRoot, resourceType); - const filtered = applyPatterns(manifestFiles, userPatterns, packageRoot); - for (const f of filtered) { - this.addPath(target, f, metadata, accumulator); + // 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(); + + for (const f of allFiles) { + const enabled = enabledByUser.has(f) || forceEnabled.has(f); + this.addResource(target, f, metadata, enabled); } } - private collectManifestFilteredFiles(packageRoot: string, resourceType: ResourceType): string[] { + /** + * Collect all files from a package for a resource type, applying manifest patterns. + * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files + * that pass the manifest's own patterns. + */ + private collectManifestFiles( + packageRoot: string, + resourceType: ResourceType, + ): { allFiles: string[]; enabledByManifest: Set } { const manifest = this.readPiManifest(packageRoot); const entries = manifest?.[resourceType as keyof PiManifest]; if (entries && entries.length > 0) { const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); const manifestPatterns = entries.filter(isPattern); - return manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : allFiles; + const enabledByManifest = + manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles); + return { allFiles, enabledByManifest }; } const conventionDir = join(packageRoot, resourceType); if (!existsSync(conventionDir)) { - return []; + return { allFiles: [], enabledByManifest: new Set() }; } - return resourceType === "skills" - ? collectSkillEntries(conventionDir) - : collectFiles(conventionDir, FILE_PATTERNS[resourceType]); + const allFiles = + resourceType === "skills" + ? collectSkillEntries(conventionDir) + : collectFiles(conventionDir, FILE_PATTERNS[resourceType]); + return { allFiles, enabledByManifest: new Set(allFiles) }; } private readPiManifest(packageRoot: string): PiManifest | null { @@ -910,25 +958,17 @@ export class DefaultPackageManager implements PackageManager { entries: string[] | undefined, root: string, resourceType: ResourceType, - target: Set, + target: Map, metadata: PathMetadata, - accumulator: ResourceAccumulator, ): void { if (!entries) return; - if (!hasPatterns(entries)) { - for (const entry of entries) { - const resolved = resolve(root, entry); - this.addPath(target, resolved, metadata, accumulator); - } - return; - } - const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType); const patterns = entries.filter(isPattern); - const filtered = applyPatterns(allFiles, patterns, root); - for (const f of filtered) { - this.addPath(target, f, metadata, accumulator); + const enabledPaths = applyPatterns(allFiles, patterns, root); + + for (const f of allFiles) { + this.addResource(target, f, metadata, enabledPaths.has(f)); } } @@ -941,28 +981,22 @@ export class DefaultPackageManager implements PackageManager { private resolveLocalEntries( entries: string[], resourceType: ResourceType, - target: Set, + target: Map, metadata: PathMetadata, - accumulator: ResourceAccumulator, ): void { if (entries.length === 0) return; - if (!hasPatterns(entries)) { - for (const entry of entries) { - const resolved = this.resolvePath(entry); - if (existsSync(resolved)) { - this.addPath(target, resolved, metadata, accumulator); - } - } - return; - } - + // Collect all files from plain entries (non-pattern entries) const { plain, patterns } = splitPatterns(entries); const resolvedPlain = plain.map((p) => this.resolvePath(p)); const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType); - const filtered = applyPatterns(allFiles, patterns, this.cwd); - for (const f of filtered) { - this.addPath(target, f, metadata, accumulator); + + // Determine which files are enabled based on patterns + const enabledPaths = applyPatterns(allFiles, patterns, this.cwd); + + // Add all files with their enabled state + for (const f of allFiles) { + this.addResource(target, f, metadata, enabledPaths.has(f)); } } @@ -989,7 +1023,10 @@ export class DefaultPackageManager implements PackageManager { return files; } - private getTargetSet(accumulator: ResourceAccumulator, resourceType: ResourceType): Set { + private getTargetMap( + accumulator: ResourceAccumulator, + resourceType: ResourceType, + ): Map { switch (resourceType) { case "extensions": return accumulator.extensions; @@ -1004,31 +1041,41 @@ export class DefaultPackageManager implements PackageManager { } } - private addPath(set: Set, value: string, metadata?: PathMetadata, accumulator?: ResourceAccumulator): void { - if (!value) return; - set.add(value); - if (metadata && accumulator && !accumulator.metadata.has(value)) { - accumulator.metadata.set(value, metadata); + private addResource( + map: Map, + path: string, + metadata: PathMetadata, + enabled: boolean, + ): void { + if (!path) return; + if (!map.has(path)) { + map.set(path, { metadata, enabled }); } } private createAccumulator(): ResourceAccumulator { return { - extensions: new Set(), - skills: new Set(), - prompts: new Set(), - themes: new Set(), - metadata: new Map(), + extensions: new Map(), + skills: new Map(), + prompts: new Map(), + themes: new Map(), }; } private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths { + const toResolved = (entries: Map): ResolvedResource[] => { + return Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({ + path, + enabled, + metadata, + })); + }; + return { - extensions: Array.from(accumulator.extensions), - skills: Array.from(accumulator.skills), - prompts: Array.from(accumulator.prompts), - themes: Array.from(accumulator.themes), - metadata: accumulator.metadata, + extensions: toResolved(accumulator.extensions), + skills: toResolved(accumulator.skills), + prompts: toResolved(accumulator.prompts), + themes: toResolved(accumulator.themes), }; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 56a1bd6a..47ca6bf1 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -272,23 +272,45 @@ export class DefaultResourceLoader implements ResourceLoader { temporary: true, }); - // Store metadata from resolved paths - this.pathMetadata = new Map(resolvedPaths.metadata); + // Helper to extract enabled paths and store metadata + const getEnabledPaths = ( + resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, + ): string[] => { + for (const r of resources) { + if (!this.pathMetadata.has(r.path)) { + this.pathMetadata.set(r.path, r.metadata); + } + } + return resources.filter((r) => r.enabled).map((r) => r.path); + }; + + // Store metadata and get enabled paths + this.pathMetadata = new Map(); + const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); + const enabledSkills = getEnabledPaths(resolvedPaths.skills); + const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); + const enabledThemes = getEnabledPaths(resolvedPaths.themes); + // Add CLI paths metadata - for (const p of cliExtensionPaths.extensions) { - if (!this.pathMetadata.has(p)) { - this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" }); + for (const r of cliExtensionPaths.extensions) { + if (!this.pathMetadata.has(r.path)) { + this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } - for (const p of cliExtensionPaths.skills) { - if (!this.pathMetadata.has(p)) { - this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" }); + for (const r of cliExtensionPaths.skills) { + if (!this.pathMetadata.has(r.path)) { + this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } + const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions); + const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills); + const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); + const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); + const extensionPaths = this.noExtensions - ? cliExtensionPaths.extensions - : this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions); + ? cliEnabledExtensions + : this.mergePaths(enabledExtensions, cliEnabledExtensions); let extensionsResult: LoadExtensionsResult; if (this.noExtensions) { @@ -313,8 +335,8 @@ export class DefaultResourceLoader implements ResourceLoader { this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; const skillPaths = this.noSkills - ? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths) - : this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths); + ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) + : this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths); let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; if (this.noSkills && skillPaths.length === 0) { @@ -334,11 +356,8 @@ export class DefaultResourceLoader implements ResourceLoader { } const promptPaths = this.noPromptTemplates - ? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths) - : this.mergePaths( - [...resolvedPaths.prompts, ...cliExtensionPaths.prompts], - this.additionalPromptTemplatePaths, - ); + ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) + : this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths); let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; if (this.noPromptTemplates && promptPaths.length === 0) { @@ -359,8 +378,8 @@ export class DefaultResourceLoader implements ResourceLoader { } const themePaths = this.noThemes - ? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths) - : this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths); + ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) + : this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths); let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; if (this.noThemes && themePaths.length === 0) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3c5652e7..01795663 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -126,6 +126,7 @@ export type { ProgressCallback, ProgressEvent, ResolvedPaths, + ResolvedResource, } from "./core/package-manager.js"; export { DefaultPackageManager } from "./core/package-manager.js"; export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js"; diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index e849a9a2..c3a9402d 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2,9 +2,16 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultPackageManager, type ProgressEvent } from "../src/core/package-manager.js"; +import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; +// Helper to check if a resource is enabled +const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => + matchFn === "endsWith" ? r.path.endsWith(pathMatch) && r.enabled : r.path.includes(pathMatch) && r.enabled; + +const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => + matchFn === "endsWith" ? r.path.endsWith(pathMatch) && !r.enabled : r.path.includes(pathMatch) && !r.enabled; + describe("DefaultPackageManager", () => { let tempDir: string; let settingsManager: SettingsManager; @@ -43,11 +50,11 @@ describe("DefaultPackageManager", () => { settingsManager.setExtensionPaths([extPath]); const result = await packageManager.resolve(); - expect(result.extensions).toContain(extPath); + 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"); + const skillDir = join(tempDir, "skills", "my-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync( join(skillDir, "SKILL.md"), @@ -58,10 +65,11 @@ description: A test skill Content`, ); - settingsManager.setSkillPaths([skillDir]); + settingsManager.setSkillPaths([join(tempDir, "skills")]); const result = await packageManager.resolve(); - expect(result.skills).toContain(skillDir); + // Skills with SKILL.md are returned as directory paths + expect(result.skills.some((r) => r.path === skillDir && r.enabled)).toBe(true); }); }); @@ -71,7 +79,7 @@ Content`, writeFileSync(extPath, "export default function() {}"); const result = await packageManager.resolveExtensionSources([extPath]); - expect(result.extensions).toContain(extPath); + expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); }); it("should handle directories with pi manifest", async () => { @@ -89,11 +97,16 @@ Content`, ); mkdirSync(join(pkgDir, "src"), { recursive: true }); writeFileSync(join(pkgDir, "src", "index.ts"), "export default function() {}"); - mkdirSync(join(pkgDir, "skills"), { recursive: true }); + mkdirSync(join(pkgDir, "skills", "my-skill"), { recursive: true }); + writeFileSync( + join(pkgDir, "skills", "my-skill", "SKILL.md"), + "---\nname: my-skill\ndescription: Test\n---\nContent", + ); const result = await packageManager.resolveExtensionSources([pkgDir]); - expect(result.extensions).toContain(join(pkgDir, "src", "index.ts")); - expect(result.skills).toContain(join(pkgDir, "skills")); + expect(result.extensions.some((r) => r.path === join(pkgDir, "src", "index.ts") && r.enabled)).toBe(true); + // Skills with SKILL.md are returned as directory paths + expect(result.skills.some((r) => r.path === join(pkgDir, "skills", "my-skill") && r.enabled)).toBe(true); }); it("should handle directories with auto-discovery layout", async () => { @@ -104,8 +117,8 @@ Content`, writeFileSync(join(pkgDir, "themes", "dark.json"), "{}"); const result = await packageManager.resolveExtensionSources([pkgDir]); - expect(result.extensions).toContain(join(pkgDir, "extensions")); - expect(result.themes).toContain(join(pkgDir, "themes")); + expect(result.extensions.some((r) => r.path.endsWith("main.ts") && r.enabled)).toBe(true); + expect(result.themes.some((r) => r.path.endsWith("dark.json") && r.enabled)).toBe(true); }); }); @@ -169,8 +182,8 @@ Content`, settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]); const result = await packageManager.resolve(); - expect(result.extensions.some((p) => p.endsWith("keep.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("remove.ts"))).toBe(false); + expect(result.extensions.some((r) => isEnabled(r, "keep.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "remove.ts"))).toBe(true); }); it("should filter themes with glob patterns", async () => { @@ -183,9 +196,9 @@ Content`, settingsManager.setThemePaths([themesDir, "!funky.json"]); const result = await packageManager.resolve(); - expect(result.themes.some((p) => p.endsWith("dark.json"))).toBe(true); - expect(result.themes.some((p) => p.endsWith("light.json"))).toBe(true); - expect(result.themes.some((p) => p.endsWith("funky.json"))).toBe(false); + expect(result.themes.some((r) => isEnabled(r, "dark.json"))).toBe(true); + expect(result.themes.some((r) => isEnabled(r, "light.json"))).toBe(true); + expect(result.themes.some((r) => isDisabled(r, "funky.json"))).toBe(true); }); it("should filter prompts with exclusion pattern", async () => { @@ -197,8 +210,8 @@ Content`, settingsManager.setPromptTemplatePaths([promptsDir, "!explain.md"]); const result = await packageManager.resolve(); - expect(result.prompts.some((p) => p.endsWith("review.md"))).toBe(true); - expect(result.prompts.some((p) => p.endsWith("explain.md"))).toBe(false); + expect(result.prompts.some((r) => isEnabled(r, "review.md"))).toBe(true); + expect(result.prompts.some((r) => isDisabled(r, "explain.md"))).toBe(true); }); it("should filter skills with exclusion pattern", async () => { @@ -217,8 +230,8 @@ Content`, settingsManager.setSkillPaths([skillsDir, "!**/bad-skill"]); const result = await packageManager.resolve(); - expect(result.skills.some((p) => p.includes("good-skill"))).toBe(true); - expect(result.skills.some((p) => p.includes("bad-skill"))).toBe(false); + expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true); + expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true); }); it("should work without patterns (backward compatible)", async () => { @@ -228,7 +241,7 @@ Content`, settingsManager.setExtensionPaths([extPath]); const result = await packageManager.resolve(); - expect(result.extensions).toContain(extPath); + expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); }); }); @@ -251,9 +264,9 @@ Content`, ); const result = await packageManager.resolveExtensionSources([pkgDir]); - expect(result.extensions.some((p) => p.endsWith("local.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("remote.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("skip.ts"))).toBe(false); + 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); }); it("should support glob patterns in manifest skills", async () => { @@ -279,8 +292,8 @@ Content`, ); const result = await packageManager.resolveExtensionSources([pkgDir]); - expect(result.skills.some((p) => p.includes("good-skill"))).toBe(true); - expect(result.skills.some((p) => p.includes("bad-skill"))).toBe(false); + expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true); + expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true); }); }); @@ -316,11 +329,11 @@ Content`, const result = await packageManager.resolve(); // foo.ts should be included (not excluded by anyone) - expect(result.extensions.some((p) => p.endsWith("foo.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "foo.ts"))).toBe(true); // bar.ts should be excluded (by user) - expect(result.extensions.some((p) => p.endsWith("bar.ts"))).toBe(false); + expect(result.extensions.some((r) => isDisabled(r, "bar.ts"))).toBe(true); // baz.ts should be excluded (by manifest) - expect(result.extensions.some((p) => p.endsWith("baz.ts"))).toBe(false); + expect(result.extensions.some((r) => isDisabled(r, "baz.ts"))).toBe(true); }); it("should exclude extensions from package with ! pattern", async () => { @@ -341,9 +354,9 @@ Content`, ]); const result = await packageManager.resolve(); - expect(result.extensions.some((p) => p.endsWith("foo.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("bar.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("baz.ts"))).toBe(false); + expect(result.extensions.some((r) => isEnabled(r, "foo.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "bar.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "baz.ts"))).toBe(true); }); it("should filter themes from package", async () => { @@ -363,8 +376,8 @@ Content`, ]); const result = await packageManager.resolve(); - expect(result.themes.some((p) => p.endsWith("nice.json"))).toBe(true); - expect(result.themes.some((p) => p.endsWith("ugly.json"))).toBe(false); + expect(result.themes.some((r) => isEnabled(r, "nice.json"))).toBe(true); + expect(result.themes.some((r) => isDisabled(r, "ugly.json"))).toBe(true); }); it("should combine include and exclude patterns", async () => { @@ -385,9 +398,9 @@ Content`, ]); const result = await packageManager.resolve(); - expect(result.extensions.some((p) => p.endsWith("alpha.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("beta.ts"))).toBe(false); - expect(result.extensions.some((p) => p.endsWith("gamma.ts"))).toBe(false); + expect(result.extensions.some((r) => isEnabled(r, "alpha.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "beta.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "gamma.ts"))).toBe(true); }); it("should work with direct paths (no patterns)", async () => { @@ -407,8 +420,140 @@ Content`, ]); const result = await packageManager.resolve(); - expect(result.extensions.some((p) => p.endsWith("one.ts"))).toBe(true); - expect(result.extensions.some((p) => p.endsWith("two.ts"))).toBe(false); + expect(result.extensions.some((r) => isEnabled(r, "one.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "two.ts"))).toBe(true); + }); + }); + + describe("force-include patterns", () => { + it("should force-include extensions with + pattern after exclusion", async () => { + const extDir = join(tempDir, "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"]); + + const result = await packageManager.resolve(); + expect(result.extensions.some((r) => isDisabled(r, "keep.ts"))).toBe(true); + expect(result.extensions.some((r) => isDisabled(r, "excluded.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "force-back.ts"))).toBe(true); + }); + + it("should force-include overrides exclude in package filters", async () => { + const pkgDir = join(tempDir, "force-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() {}"); + writeFileSync(join(pkgDir, "extensions", "gamma.ts"), "export default function() {}"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: ["!*", "+**/beta.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); + expect(result.extensions.some((r) => isDisabled(r, "gamma.ts"))).toBe(true); + }); + + it("should force-include multiple resources", async () => { + const pkgDir = join(tempDir, "multi-force-pkg"); + mkdirSync(join(pkgDir, "skills/skill-a"), { recursive: true }); + mkdirSync(join(pkgDir, "skills/skill-b"), { recursive: true }); + mkdirSync(join(pkgDir, "skills/skill-c"), { recursive: true }); + writeFileSync(join(pkgDir, "skills/skill-a", "SKILL.md"), "---\nname: skill-a\ndescription: A\n---\nContent"); + writeFileSync(join(pkgDir, "skills/skill-b", "SKILL.md"), "---\nname: skill-b\ndescription: B\n---\nContent"); + writeFileSync(join(pkgDir, "skills/skill-c", "SKILL.md"), "---\nname: skill-c\ndescription: C\n---\nContent"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: [], + skills: ["!*", "+**/skill-a", "+**/skill-c"], + prompts: [], + themes: [], + }, + ]); + + const result = await packageManager.resolve(); + expect(result.skills.some((r) => isEnabled(r, "skill-a", "includes"))).toBe(true); + expect(result.skills.some((r) => isDisabled(r, "skill-b", "includes"))).toBe(true); + expect(result.skills.some((r) => isEnabled(r, "skill-c", "includes"))).toBe(true); + }); + + it("should force-include after specific exclusion", async () => { + const extDir = join(tempDir, "specific-force"); + 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"]); + + const result = await packageManager.resolve(); + expect(result.extensions.some((r) => isEnabled(r, "a.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "b.ts"))).toBe(true); + }); + + it("should handle force-include in manifest patterns", async () => { + const pkgDir = join(tempDir, "manifest-force-pkg"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + writeFileSync(join(pkgDir, "extensions", "one.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "extensions", "two.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "extensions", "three.ts"), "export default function() {}"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "manifest-force-pkg", + pi: { + extensions: ["extensions", "!**/two.ts", "+**/two.ts"], + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + expect(result.extensions.some((r) => isEnabled(r, "one.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "two.ts"))).toBe(true); + expect(result.extensions.some((r) => isEnabled(r, "three.ts"))).toBe(true); + }); + + it("should force-include themes", async () => { + const themesDir = join(tempDir, "force-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"]); + + const result = await packageManager.resolve(); + expect(result.themes.some((r) => isDisabled(r, "dark.json"))).toBe(true); + expect(result.themes.some((r) => isDisabled(r, "light.json"))).toBe(true); + expect(result.themes.some((r) => isEnabled(r, "special.json"))).toBe(true); + }); + + it("should force-include prompts", async () => { + const promptsDir = join(tempDir, "force-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"]); + + const result = await packageManager.resolve(); + expect(result.prompts.some((r) => isDisabled(r, "review.md"))).toBe(true); + expect(result.prompts.some((r) => isDisabled(r, "explain.md"))).toBe(true); + expect(result.prompts.some((r) => isEnabled(r, "debug.md"))).toBe(true); }); }); @@ -429,12 +574,10 @@ Content`, expect(projectSettings.packages).toEqual([pkgDir]); const result = await packageManager.resolve(); - // Auto-discovery returns directories, not individual files // Should only appear once (deduped), with project scope - const sharedPaths = result.extensions.filter((p) => p.includes("shared-pkg")); + const sharedPaths = result.extensions.filter((r) => r.path.includes("shared-pkg")); expect(sharedPaths.length).toBe(1); - const meta = result.metadata.get(sharedPaths[0]); - expect(meta?.scope).toBe("project"); + expect(sharedPaths[0].metadata.scope).toBe("project"); }); it("should keep both if different packages", async () => { @@ -449,9 +592,8 @@ Content`, settingsManager.setProjectPackages([pkg2Dir]); // project const result = await packageManager.resolve(); - // Auto-discovery returns directories, not individual files - expect(result.extensions.some((p) => p.includes("pkg1"))).toBe(true); - expect(result.extensions.some((p) => p.includes("pkg2"))).toBe(true); + expect(result.extensions.some((r) => r.path.includes("pkg1"))).toBe(true); + expect(result.extensions.some((r) => r.path.includes("pkg2"))).toBe(true); }); }); });