feat(coding-agent): resolve resources with enabled state

This commit is contained in:
Mario Zechner 2026-01-25 02:13:24 +01:00
parent 9dc2b9b163
commit 3e8eb956b3
4 changed files with 389 additions and 180 deletions

View file

@ -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<string, PathMetadata>;
extensions: ResolvedResource[];
skills: ResolvedResource[];
prompts: ResolvedResource[];
themes: ResolvedResource[];
}
export type MissingSourceAction = "install" | "skip" | "error";
@ -85,11 +90,10 @@ interface PiManifest {
}
interface ResourceAccumulator {
extensions: Set<string>;
skills: Set<string>;
prompts: Set<string>;
themes: Set<string>;
metadata: Map<string, PathMetadata>;
extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>;
skills: Map<string, { metadata: PathMetadata; enabled: boolean }>;
prompts: Map<string, { metadata: PathMetadata; enabled: boolean }>;
themes: Map<string, { metadata: PathMetadata; enabled: boolean }>;
}
interface PackageFilter {
@ -111,11 +115,7 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
};
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<string> {
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<string>,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
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<string>,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
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<string>();
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<string> } {
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<string>,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
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<string>,
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
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<string> {
private getTargetMap(
accumulator: ResourceAccumulator,
resourceType: ResourceType,
): Map<string, { metadata: PathMetadata; enabled: boolean }> {
switch (resourceType) {
case "extensions":
return accumulator.extensions;
@ -1004,31 +1041,41 @@ export class DefaultPackageManager implements PackageManager {
}
}
private addPath(set: Set<string>, 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<string, { metadata: PathMetadata; enabled: boolean }>,
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<string>(),
skills: new Set<string>(),
prompts: new Set<string>(),
themes: new Set<string>(),
metadata: new Map<string, PathMetadata>(),
extensions: new Map(),
skills: new Map(),
prompts: new Map(),
themes: new Map(),
};
}
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
const toResolved = (entries: Map<string, { metadata: PathMetadata; enabled: boolean }>): 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),
};
}

View file

@ -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) {

View file

@ -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";

View file

@ -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);
});
});
});