mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 08:02:11 +00:00
feat(coding-agent): resolve resources with enabled state
This commit is contained in:
parent
9dc2b9b163
commit
3e8eb956b3
4 changed files with 389 additions and 180 deletions
|
|
@ -14,12 +14,17 @@ export interface PathMetadata {
|
||||||
origin: "package" | "top-level";
|
origin: "package" | "top-level";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedResource {
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
metadata: PathMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedPaths {
|
export interface ResolvedPaths {
|
||||||
extensions: string[];
|
extensions: ResolvedResource[];
|
||||||
skills: string[];
|
skills: ResolvedResource[];
|
||||||
prompts: string[];
|
prompts: ResolvedResource[];
|
||||||
themes: string[];
|
themes: ResolvedResource[];
|
||||||
metadata: Map<string, PathMetadata>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MissingSourceAction = "install" | "skip" | "error";
|
export type MissingSourceAction = "install" | "skip" | "error";
|
||||||
|
|
@ -85,11 +90,10 @@ interface PiManifest {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResourceAccumulator {
|
interface ResourceAccumulator {
|
||||||
extensions: Set<string>;
|
extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>;
|
||||||
skills: Set<string>;
|
skills: Map<string, { metadata: PathMetadata; enabled: boolean }>;
|
||||||
prompts: Set<string>;
|
prompts: Map<string, { metadata: PathMetadata; enabled: boolean }>;
|
||||||
themes: Set<string>;
|
themes: Map<string, { metadata: PathMetadata; enabled: boolean }>;
|
||||||
metadata: Map<string, PathMetadata>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageFilter {
|
interface PackageFilter {
|
||||||
|
|
@ -111,11 +115,7 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPattern(s: string): boolean {
|
function isPattern(s: string): boolean {
|
||||||
return s.startsWith("!") || s.includes("*") || s.includes("?");
|
return s.startsWith("!") || s.startsWith("+") || s.includes("*") || s.includes("?");
|
||||||
}
|
|
||||||
|
|
||||||
function hasPatterns(entries: string[]): boolean {
|
|
||||||
return entries.some(isPattern);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
|
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
|
||||||
|
|
@ -210,42 +210,59 @@ function collectSkillEntries(dir: string): string[] {
|
||||||
return entries;
|
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 includes: string[] = [];
|
||||||
const excludes: string[] = [];
|
const excludes: string[] = [];
|
||||||
|
const forceIncludes: string[] = [];
|
||||||
|
|
||||||
for (const p of patterns) {
|
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));
|
excludes.push(p.slice(1));
|
||||||
} else {
|
} else {
|
||||||
includes.push(p);
|
includes.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 1: Apply includes (or all if no includes)
|
||||||
let result: string[];
|
let result: string[];
|
||||||
if (includes.length === 0) {
|
if (includes.length === 0) {
|
||||||
result = [...allPaths];
|
result = [...allPaths];
|
||||||
} else {
|
} else {
|
||||||
result = allPaths.filter((filePath) => {
|
result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir));
|
||||||
const rel = relative(baseDir, filePath);
|
|
||||||
const name = basename(filePath);
|
|
||||||
return includes.some((pattern) => {
|
|
||||||
return minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Apply excludes
|
||||||
if (excludes.length > 0) {
|
if (excludes.length > 0) {
|
||||||
result = result.filter((filePath) => {
|
result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir));
|
||||||
const rel = relative(baseDir, filePath);
|
|
||||||
const name = basename(filePath);
|
|
||||||
return !excludes.some((pattern) => {
|
|
||||||
return minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export class DefaultPackageManager implements PackageManager {
|
||||||
|
|
@ -322,23 +339,19 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
await this.resolvePackageSources(packageSources, accumulator, onMissing);
|
await this.resolvePackageSources(packageSources, accumulator, onMissing);
|
||||||
|
|
||||||
for (const resourceType of RESOURCE_TYPES) {
|
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 globalEntries = (globalSettings[resourceType] ?? []) as string[];
|
||||||
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
|
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
|
||||||
this.resolveLocalEntries(
|
this.resolveLocalEntries(globalEntries, resourceType, target, {
|
||||||
globalEntries,
|
source: "local",
|
||||||
resourceType,
|
scope: "user",
|
||||||
target,
|
origin: "top-level",
|
||||||
{ source: "local", scope: "user", origin: "top-level" },
|
});
|
||||||
accumulator,
|
this.resolveLocalEntries(projectEntries, resourceType, target, {
|
||||||
);
|
source: "local",
|
||||||
this.resolveLocalEntries(
|
scope: "project",
|
||||||
projectEntries,
|
origin: "top-level",
|
||||||
resourceType,
|
});
|
||||||
target,
|
|
||||||
{ source: "local", scope: "project", origin: "top-level" },
|
|
||||||
accumulator,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResolvedPaths(accumulator);
|
return this.toResolvedPaths(accumulator);
|
||||||
|
|
@ -486,13 +499,13 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
try {
|
try {
|
||||||
const stats = statSync(resolved);
|
const stats = statSync(resolved);
|
||||||
if (stats.isFile()) {
|
if (stats.isFile()) {
|
||||||
this.addPath(accumulator.extensions, resolved, metadata, accumulator);
|
this.addResource(accumulator.extensions, resolved, metadata, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
|
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
|
||||||
if (!resources) {
|
if (!resources) {
|
||||||
this.addPath(accumulator.extensions, resolved, metadata, accumulator);
|
this.addResource(accumulator.extensions, resolved, metadata, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -798,11 +811,11 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
for (const resourceType of RESOURCE_TYPES) {
|
for (const resourceType of RESOURCE_TYPES) {
|
||||||
const patterns = filter[resourceType as keyof PackageFilter];
|
const patterns = filter[resourceType as keyof PackageFilter];
|
||||||
const target = this.getTargetSet(accumulator, resourceType);
|
const target = this.getTargetMap(accumulator, resourceType);
|
||||||
if (patterns !== undefined) {
|
if (patterns !== undefined) {
|
||||||
this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata, accumulator);
|
this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata);
|
||||||
} else {
|
} else {
|
||||||
this.collectDefaultResources(packageRoot, resourceType, target, metadata, accumulator);
|
this.collectDefaultResources(packageRoot, resourceType, target, metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -816,9 +829,8 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
entries,
|
entries,
|
||||||
packageRoot,
|
packageRoot,
|
||||||
resourceType,
|
resourceType,
|
||||||
this.getTargetSet(accumulator, resourceType),
|
this.getTargetMap(accumulator, resourceType),
|
||||||
metadata,
|
metadata,
|
||||||
accumulator,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -828,7 +840,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
for (const resourceType of RESOURCE_TYPES) {
|
for (const resourceType of RESOURCE_TYPES) {
|
||||||
const dir = join(packageRoot, resourceType);
|
const dir = join(packageRoot, resourceType);
|
||||||
if (existsSync(dir)) {
|
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;
|
hasAnyDir = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -838,19 +855,23 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
private collectDefaultResources(
|
private collectDefaultResources(
|
||||||
packageRoot: string,
|
packageRoot: string,
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
target: Set<string>,
|
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
accumulator: ResourceAccumulator,
|
|
||||||
): void {
|
): void {
|
||||||
const manifest = this.readPiManifest(packageRoot);
|
const manifest = this.readPiManifest(packageRoot);
|
||||||
const entries = manifest?.[resourceType as keyof PiManifest];
|
const entries = manifest?.[resourceType as keyof PiManifest];
|
||||||
if (entries) {
|
if (entries) {
|
||||||
this.addManifestEntries(entries, packageRoot, resourceType, target, metadata, accumulator);
|
this.addManifestEntries(entries, packageRoot, resourceType, target, metadata);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dir = join(packageRoot, resourceType);
|
const dir = join(packageRoot, resourceType);
|
||||||
if (existsSync(dir)) {
|
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,
|
packageRoot: string,
|
||||||
userPatterns: string[],
|
userPatterns: string[],
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
target: Set<string>,
|
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
accumulator: ResourceAccumulator,
|
|
||||||
): void {
|
): void {
|
||||||
|
const { allFiles, enabledByManifest } = this.collectManifestFiles(packageRoot, resourceType);
|
||||||
|
|
||||||
if (userPatterns.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifestFiles = this.collectManifestFilteredFiles(packageRoot, resourceType);
|
// Apply user patterns on top of manifest-enabled files
|
||||||
const filtered = applyPatterns(manifestFiles, userPatterns, packageRoot);
|
const enabledByUser = applyPatterns(Array.from(enabledByManifest), userPatterns, packageRoot);
|
||||||
for (const f of filtered) {
|
|
||||||
this.addPath(target, f, metadata, accumulator);
|
// 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 manifest = this.readPiManifest(packageRoot);
|
||||||
const entries = manifest?.[resourceType as keyof PiManifest];
|
const entries = manifest?.[resourceType as keyof PiManifest];
|
||||||
if (entries && entries.length > 0) {
|
if (entries && entries.length > 0) {
|
||||||
const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType);
|
const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType);
|
||||||
const manifestPatterns = entries.filter(isPattern);
|
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);
|
const conventionDir = join(packageRoot, resourceType);
|
||||||
if (!existsSync(conventionDir)) {
|
if (!existsSync(conventionDir)) {
|
||||||
return [];
|
return { allFiles: [], enabledByManifest: new Set() };
|
||||||
}
|
}
|
||||||
return resourceType === "skills"
|
const allFiles =
|
||||||
? collectSkillEntries(conventionDir)
|
resourceType === "skills"
|
||||||
: collectFiles(conventionDir, FILE_PATTERNS[resourceType]);
|
? collectSkillEntries(conventionDir)
|
||||||
|
: collectFiles(conventionDir, FILE_PATTERNS[resourceType]);
|
||||||
|
return { allFiles, enabledByManifest: new Set(allFiles) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private readPiManifest(packageRoot: string): PiManifest | null {
|
private readPiManifest(packageRoot: string): PiManifest | null {
|
||||||
|
|
@ -910,25 +958,17 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
entries: string[] | undefined,
|
entries: string[] | undefined,
|
||||||
root: string,
|
root: string,
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
target: Set<string>,
|
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
accumulator: ResourceAccumulator,
|
|
||||||
): void {
|
): void {
|
||||||
if (!entries) return;
|
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 allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType);
|
||||||
const patterns = entries.filter(isPattern);
|
const patterns = entries.filter(isPattern);
|
||||||
const filtered = applyPatterns(allFiles, patterns, root);
|
const enabledPaths = applyPatterns(allFiles, patterns, root);
|
||||||
for (const f of filtered) {
|
|
||||||
this.addPath(target, f, metadata, accumulator);
|
for (const f of allFiles) {
|
||||||
|
this.addResource(target, f, metadata, enabledPaths.has(f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -941,28 +981,22 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
private resolveLocalEntries(
|
private resolveLocalEntries(
|
||||||
entries: string[],
|
entries: string[],
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
target: Set<string>,
|
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
accumulator: ResourceAccumulator,
|
|
||||||
): void {
|
): void {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
if (!hasPatterns(entries)) {
|
// Collect all files from plain entries (non-pattern entries)
|
||||||
for (const entry of entries) {
|
|
||||||
const resolved = this.resolvePath(entry);
|
|
||||||
if (existsSync(resolved)) {
|
|
||||||
this.addPath(target, resolved, metadata, accumulator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { plain, patterns } = splitPatterns(entries);
|
const { plain, patterns } = splitPatterns(entries);
|
||||||
const resolvedPlain = plain.map((p) => this.resolvePath(p));
|
const resolvedPlain = plain.map((p) => this.resolvePath(p));
|
||||||
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
|
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
|
||||||
const filtered = applyPatterns(allFiles, patterns, this.cwd);
|
|
||||||
for (const f of filtered) {
|
// Determine which files are enabled based on patterns
|
||||||
this.addPath(target, f, metadata, accumulator);
|
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;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTargetSet(accumulator: ResourceAccumulator, resourceType: ResourceType): Set<string> {
|
private getTargetMap(
|
||||||
|
accumulator: ResourceAccumulator,
|
||||||
|
resourceType: ResourceType,
|
||||||
|
): Map<string, { metadata: PathMetadata; enabled: boolean }> {
|
||||||
switch (resourceType) {
|
switch (resourceType) {
|
||||||
case "extensions":
|
case "extensions":
|
||||||
return accumulator.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 {
|
private addResource(
|
||||||
if (!value) return;
|
map: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
set.add(value);
|
path: string,
|
||||||
if (metadata && accumulator && !accumulator.metadata.has(value)) {
|
metadata: PathMetadata,
|
||||||
accumulator.metadata.set(value, metadata);
|
enabled: boolean,
|
||||||
|
): void {
|
||||||
|
if (!path) return;
|
||||||
|
if (!map.has(path)) {
|
||||||
|
map.set(path, { metadata, enabled });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAccumulator(): ResourceAccumulator {
|
private createAccumulator(): ResourceAccumulator {
|
||||||
return {
|
return {
|
||||||
extensions: new Set<string>(),
|
extensions: new Map(),
|
||||||
skills: new Set<string>(),
|
skills: new Map(),
|
||||||
prompts: new Set<string>(),
|
prompts: new Map(),
|
||||||
themes: new Set<string>(),
|
themes: new Map(),
|
||||||
metadata: new Map<string, PathMetadata>(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
|
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 {
|
return {
|
||||||
extensions: Array.from(accumulator.extensions),
|
extensions: toResolved(accumulator.extensions),
|
||||||
skills: Array.from(accumulator.skills),
|
skills: toResolved(accumulator.skills),
|
||||||
prompts: Array.from(accumulator.prompts),
|
prompts: toResolved(accumulator.prompts),
|
||||||
themes: Array.from(accumulator.themes),
|
themes: toResolved(accumulator.themes),
|
||||||
metadata: accumulator.metadata,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,23 +272,45 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||||
temporary: true,
|
temporary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store metadata from resolved paths
|
// Helper to extract enabled paths and store metadata
|
||||||
this.pathMetadata = new Map(resolvedPaths.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
|
// Add CLI paths metadata
|
||||||
for (const p of cliExtensionPaths.extensions) {
|
for (const r of cliExtensionPaths.extensions) {
|
||||||
if (!this.pathMetadata.has(p)) {
|
if (!this.pathMetadata.has(r.path)) {
|
||||||
this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" });
|
this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const p of cliExtensionPaths.skills) {
|
for (const r of cliExtensionPaths.skills) {
|
||||||
if (!this.pathMetadata.has(p)) {
|
if (!this.pathMetadata.has(r.path)) {
|
||||||
this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" });
|
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
|
const extensionPaths = this.noExtensions
|
||||||
? cliExtensionPaths.extensions
|
? cliEnabledExtensions
|
||||||
: this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions);
|
: this.mergePaths(enabledExtensions, cliEnabledExtensions);
|
||||||
|
|
||||||
let extensionsResult: LoadExtensionsResult;
|
let extensionsResult: LoadExtensionsResult;
|
||||||
if (this.noExtensions) {
|
if (this.noExtensions) {
|
||||||
|
|
@ -313,8 +335,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||||
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
|
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
|
||||||
|
|
||||||
const skillPaths = this.noSkills
|
const skillPaths = this.noSkills
|
||||||
? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths)
|
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
|
||||||
: this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths);
|
: this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths);
|
||||||
|
|
||||||
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||||
if (this.noSkills && skillPaths.length === 0) {
|
if (this.noSkills && skillPaths.length === 0) {
|
||||||
|
|
@ -334,11 +356,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptPaths = this.noPromptTemplates
|
const promptPaths = this.noPromptTemplates
|
||||||
? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths)
|
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
|
||||||
: this.mergePaths(
|
: this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths);
|
||||||
[...resolvedPaths.prompts, ...cliExtensionPaths.prompts],
|
|
||||||
this.additionalPromptTemplatePaths,
|
|
||||||
);
|
|
||||||
|
|
||||||
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
||||||
if (this.noPromptTemplates && promptPaths.length === 0) {
|
if (this.noPromptTemplates && promptPaths.length === 0) {
|
||||||
|
|
@ -359,8 +378,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const themePaths = this.noThemes
|
const themePaths = this.noThemes
|
||||||
? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths)
|
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
|
||||||
: this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths);
|
: this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths);
|
||||||
|
|
||||||
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
||||||
if (this.noThemes && themePaths.length === 0) {
|
if (this.noThemes && themePaths.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export type {
|
||||||
ProgressCallback,
|
ProgressCallback,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
ResolvedPaths,
|
ResolvedPaths,
|
||||||
|
ResolvedResource,
|
||||||
} from "./core/package-manager.js";
|
} from "./core/package-manager.js";
|
||||||
export { DefaultPackageManager } from "./core/package-manager.js";
|
export { DefaultPackageManager } from "./core/package-manager.js";
|
||||||
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
|
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,16 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
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";
|
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", () => {
|
describe("DefaultPackageManager", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let settingsManager: SettingsManager;
|
let settingsManager: SettingsManager;
|
||||||
|
|
@ -43,11 +50,11 @@ describe("DefaultPackageManager", () => {
|
||||||
settingsManager.setExtensionPaths([extPath]);
|
settingsManager.setExtensionPaths([extPath]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
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 () => {
|
it("should resolve skill paths from settings", async () => {
|
||||||
const skillDir = join(tempDir, "skills");
|
const skillDir = join(tempDir, "skills", "my-skill");
|
||||||
mkdirSync(skillDir, { recursive: true });
|
mkdirSync(skillDir, { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(skillDir, "SKILL.md"),
|
join(skillDir, "SKILL.md"),
|
||||||
|
|
@ -58,10 +65,11 @@ description: A test skill
|
||||||
Content`,
|
Content`,
|
||||||
);
|
);
|
||||||
|
|
||||||
settingsManager.setSkillPaths([skillDir]);
|
settingsManager.setSkillPaths([join(tempDir, "skills")]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
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() {}");
|
writeFileSync(extPath, "export default function() {}");
|
||||||
|
|
||||||
const result = await packageManager.resolveExtensionSources([extPath]);
|
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 () => {
|
it("should handle directories with pi manifest", async () => {
|
||||||
|
|
@ -89,11 +97,16 @@ Content`,
|
||||||
);
|
);
|
||||||
mkdirSync(join(pkgDir, "src"), { recursive: true });
|
mkdirSync(join(pkgDir, "src"), { recursive: true });
|
||||||
writeFileSync(join(pkgDir, "src", "index.ts"), "export default function() {}");
|
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]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.extensions).toContain(join(pkgDir, "src", "index.ts"));
|
expect(result.extensions.some((r) => r.path === join(pkgDir, "src", "index.ts") && r.enabled)).toBe(true);
|
||||||
expect(result.skills).toContain(join(pkgDir, "skills"));
|
// 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 () => {
|
it("should handle directories with auto-discovery layout", async () => {
|
||||||
|
|
@ -104,8 +117,8 @@ Content`,
|
||||||
writeFileSync(join(pkgDir, "themes", "dark.json"), "{}");
|
writeFileSync(join(pkgDir, "themes", "dark.json"), "{}");
|
||||||
|
|
||||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.extensions).toContain(join(pkgDir, "extensions"));
|
expect(result.extensions.some((r) => r.path.endsWith("main.ts") && r.enabled)).toBe(true);
|
||||||
expect(result.themes).toContain(join(pkgDir, "themes"));
|
expect(result.themes.some((r) => r.path.endsWith("dark.json") && r.enabled)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,8 +182,8 @@ Content`,
|
||||||
settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]);
|
settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((p) => p.endsWith("keep.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "keep.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("remove.ts"))).toBe(false);
|
expect(result.extensions.some((r) => isDisabled(r, "remove.ts"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter themes with glob patterns", async () => {
|
it("should filter themes with glob patterns", async () => {
|
||||||
|
|
@ -183,9 +196,9 @@ Content`,
|
||||||
settingsManager.setThemePaths([themesDir, "!funky.json"]);
|
settingsManager.setThemePaths([themesDir, "!funky.json"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.themes.some((p) => p.endsWith("dark.json"))).toBe(true);
|
expect(result.themes.some((r) => isEnabled(r, "dark.json"))).toBe(true);
|
||||||
expect(result.themes.some((p) => p.endsWith("light.json"))).toBe(true);
|
expect(result.themes.some((r) => isEnabled(r, "light.json"))).toBe(true);
|
||||||
expect(result.themes.some((p) => p.endsWith("funky.json"))).toBe(false);
|
expect(result.themes.some((r) => isDisabled(r, "funky.json"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter prompts with exclusion pattern", async () => {
|
it("should filter prompts with exclusion pattern", async () => {
|
||||||
|
|
@ -197,8 +210,8 @@ Content`,
|
||||||
settingsManager.setPromptTemplatePaths([promptsDir, "!explain.md"]);
|
settingsManager.setPromptTemplatePaths([promptsDir, "!explain.md"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.prompts.some((p) => p.endsWith("review.md"))).toBe(true);
|
expect(result.prompts.some((r) => isEnabled(r, "review.md"))).toBe(true);
|
||||||
expect(result.prompts.some((p) => p.endsWith("explain.md"))).toBe(false);
|
expect(result.prompts.some((r) => isDisabled(r, "explain.md"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter skills with exclusion pattern", async () => {
|
it("should filter skills with exclusion pattern", async () => {
|
||||||
|
|
@ -217,8 +230,8 @@ Content`,
|
||||||
settingsManager.setSkillPaths([skillsDir, "!**/bad-skill"]);
|
settingsManager.setSkillPaths([skillsDir, "!**/bad-skill"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.skills.some((p) => p.includes("good-skill"))).toBe(true);
|
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
||||||
expect(result.skills.some((p) => p.includes("bad-skill"))).toBe(false);
|
expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should work without patterns (backward compatible)", async () => {
|
it("should work without patterns (backward compatible)", async () => {
|
||||||
|
|
@ -228,7 +241,7 @@ Content`,
|
||||||
settingsManager.setExtensionPaths([extPath]);
|
settingsManager.setExtensionPaths([extPath]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
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]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.extensions.some((p) => p.endsWith("local.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "local.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("remote.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "remote.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("skip.ts"))).toBe(false);
|
expect(result.extensions.some((r) => isDisabled(r, "skip.ts"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should support glob patterns in manifest skills", async () => {
|
it("should support glob patterns in manifest skills", async () => {
|
||||||
|
|
@ -279,8 +292,8 @@ Content`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.skills.some((p) => p.includes("good-skill"))).toBe(true);
|
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
||||||
expect(result.skills.some((p) => p.includes("bad-skill"))).toBe(false);
|
expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -316,11 +329,11 @@ Content`,
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
// foo.ts should be included (not excluded by anyone)
|
// 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)
|
// 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)
|
// 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 () => {
|
it("should exclude extensions from package with ! pattern", async () => {
|
||||||
|
|
@ -341,9 +354,9 @@ Content`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((p) => p.endsWith("foo.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "foo.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("bar.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "bar.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("baz.ts"))).toBe(false);
|
expect(result.extensions.some((r) => isDisabled(r, "baz.ts"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter themes from package", async () => {
|
it("should filter themes from package", async () => {
|
||||||
|
|
@ -363,8 +376,8 @@ Content`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.themes.some((p) => p.endsWith("nice.json"))).toBe(true);
|
expect(result.themes.some((r) => isEnabled(r, "nice.json"))).toBe(true);
|
||||||
expect(result.themes.some((p) => p.endsWith("ugly.json"))).toBe(false);
|
expect(result.themes.some((r) => isDisabled(r, "ugly.json"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should combine include and exclude patterns", async () => {
|
it("should combine include and exclude patterns", async () => {
|
||||||
|
|
@ -385,9 +398,9 @@ Content`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((p) => p.endsWith("alpha.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "alpha.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("beta.ts"))).toBe(false);
|
expect(result.extensions.some((r) => isDisabled(r, "beta.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("gamma.ts"))).toBe(false);
|
expect(result.extensions.some((r) => isDisabled(r, "gamma.ts"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should work with direct paths (no patterns)", async () => {
|
it("should work with direct paths (no patterns)", async () => {
|
||||||
|
|
@ -407,8 +420,140 @@ Content`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((p) => p.endsWith("one.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "one.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.endsWith("two.ts"))).toBe(false);
|
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]);
|
expect(projectSettings.packages).toEqual([pkgDir]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
// Auto-discovery returns directories, not individual files
|
|
||||||
// Should only appear once (deduped), with project scope
|
// 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);
|
expect(sharedPaths.length).toBe(1);
|
||||||
const meta = result.metadata.get(sharedPaths[0]);
|
expect(sharedPaths[0].metadata.scope).toBe("project");
|
||||||
expect(meta?.scope).toBe("project");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep both if different packages", async () => {
|
it("should keep both if different packages", async () => {
|
||||||
|
|
@ -449,9 +592,8 @@ Content`,
|
||||||
settingsManager.setProjectPackages([pkg2Dir]); // project
|
settingsManager.setProjectPackages([pkg2Dir]); // project
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
// Auto-discovery returns directories, not individual files
|
expect(result.extensions.some((r) => r.path.includes("pkg1"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.includes("pkg1"))).toBe(true);
|
expect(result.extensions.some((r) => r.path.includes("pkg2"))).toBe(true);
|
||||||
expect(result.extensions.some((p) => p.includes("pkg2"))).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue