diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 446efd82..8580e1f3 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,8 +1,9 @@ import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.js"; import type { PackageSource, SettingsManager } from "./settings-manager.js"; @@ -88,6 +89,164 @@ interface PackageFilter { themes?: string[]; } +// File type patterns for each resource type +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +const FILE_PATTERNS: Record = { + extensions: /\.(ts|js)$/, + skills: /\.md$/, + prompts: /\.md$/, + themes: /\.json$/, +}; + +/** + * Check if a string contains glob pattern characters or is an exclusion. + */ +function isPattern(s: string): boolean { + return s.startsWith("!") || s.includes("*") || s.includes("?"); +} + +/** + * Check if any entry in the array is a pattern. + */ +function hasPatterns(entries: string[]): boolean { + return entries.some(isPattern); +} + +/** + * Recursively collect files from a directory matching a file pattern. + */ +function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true): string[] { + const files: string[] = []; + if (!existsSync(dir)) return files; + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + if (skipNodeModules && entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isDir) { + files.push(...collectFiles(fullPath, filePattern, skipNodeModules)); + } else if (isFile && filePattern.test(entry.name)) { + files.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return files; +} + +/** + * Collect skill entries from a directory. + * Skills can be directories (with SKILL.md) or direct .md files. + */ +function collectSkillEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isDir) { + // Skill directory - add if it has SKILL.md or recurse + const skillMd = join(fullPath, "SKILL.md"); + if (existsSync(skillMd)) { + entries.push(fullPath); + } else { + entries.push(...collectSkillEntries(fullPath)); + } + } else if (isFile && entry.name.endsWith(".md")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +/** + * Apply inclusion/exclusion patterns to filter paths. + * @param allPaths - All available paths to filter + * @param patterns - Array of patterns (prefix with ! for exclusion) + * @param baseDir - Base directory for relative pattern matching + */ +function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): string[] { + const includes: string[] = []; + const excludes: string[] = []; + + for (const p of patterns) { + if (p.startsWith("!")) { + excludes.push(p.slice(1)); + } else { + includes.push(p); + } + } + + // If only exclusions, start with all paths; otherwise filter to inclusions first + 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) => { + // Match against relative path, basename, or full path + return minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern); + }); + }); + } + + // Apply exclusions + 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); + }); + }); + } + + return result; +} + export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; @@ -126,44 +285,93 @@ export class DefaultPackageManager implements PackageManager { await this.resolvePackageSources(packageSources, accumulator, onMissing); // Resolve local extensions - for (const ext of globalSettings.extensions ?? []) { - this.resolveLocalPath(ext, accumulator.extensions); - } - for (const ext of projectSettings.extensions ?? []) { - this.resolveLocalPath(ext, accumulator.extensions); - } + this.resolveLocalEntries( + [...(globalSettings.extensions ?? []), ...(projectSettings.extensions ?? [])], + "extensions", + accumulator.extensions, + ); // Resolve local skills - for (const skill of globalSettings.skills ?? []) { - this.addPath(accumulator.skills, this.resolvePath(skill)); - } - for (const skill of projectSettings.skills ?? []) { - this.addPath(accumulator.skills, this.resolvePath(skill)); - } + this.resolveLocalEntries( + [...(globalSettings.skills ?? []), ...(projectSettings.skills ?? [])], + "skills", + accumulator.skills, + ); // Resolve local prompts - for (const prompt of globalSettings.prompts ?? []) { - this.addPath(accumulator.prompts, this.resolvePath(prompt)); - } - for (const prompt of projectSettings.prompts ?? []) { - this.addPath(accumulator.prompts, this.resolvePath(prompt)); - } + this.resolveLocalEntries( + [...(globalSettings.prompts ?? []), ...(projectSettings.prompts ?? [])], + "prompts", + accumulator.prompts, + ); // Resolve local themes - for (const theme of globalSettings.themes ?? []) { - this.addPath(accumulator.themes, this.resolvePath(theme)); - } - for (const theme of projectSettings.themes ?? []) { - this.addPath(accumulator.themes, this.resolvePath(theme)); - } + this.resolveLocalEntries( + [...(globalSettings.themes ?? []), ...(projectSettings.themes ?? [])], + "themes", + accumulator.themes, + ); return this.toResolvedPaths(accumulator); } - private resolveLocalPath(path: string, target: Set): void { - const resolved = this.resolvePath(path); - if (existsSync(resolved)) { - this.addPath(target, resolved); + /** + * Resolve local entries with pattern support. + * If any entry contains patterns, enumerate files and apply filters. + * Otherwise, just resolve paths directly. + */ + private resolveLocalEntries(entries: string[], resourceType: ResourceType, target: Set): void { + if (entries.length === 0) return; + + if (!hasPatterns(entries)) { + // No patterns - resolve directly + for (const entry of entries) { + const resolved = this.resolvePath(entry); + if (existsSync(resolved)) { + this.addPath(target, resolved); + } + } + return; + } + + // Has patterns - need to enumerate and filter + const plainPaths: string[] = []; + const patterns: string[] = []; + + for (const entry of entries) { + if (isPattern(entry)) { + patterns.push(entry); + } else { + plainPaths.push(entry); + } + } + + // Collect all files from plain paths + const allFiles: string[] = []; + for (const p of plainPaths) { + const resolved = this.resolvePath(p); + if (!existsSync(resolved)) continue; + + try { + const stats = statSync(resolved); + if (stats.isFile()) { + allFiles.push(resolved); + } else if (stats.isDirectory()) { + if (resourceType === "skills") { + allFiles.push(...collectSkillEntries(resolved)); + } else { + allFiles.push(...collectFiles(resolved, FILE_PATTERNS[resourceType])); + } + } + } catch { + // Ignore errors + } + } + + // Apply patterns + const filtered = applyPatterns(allFiles, patterns, this.cwd); + for (const f of filtered) { + this.addPath(target, f); } } @@ -282,7 +490,7 @@ export class DefaultPackageManager implements PackageManager { const parsed = this.parseSource(sourceStr); if (parsed.type === "local") { - this.resolveLocalExtensionSource(parsed, accumulator); + this.resolveLocalExtensionSource(parsed, accumulator, filter); continue; } @@ -319,7 +527,11 @@ export class DefaultPackageManager implements PackageManager { } } - private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void { + private resolveLocalExtensionSource( + source: LocalSource, + accumulator: ResourceAccumulator, + filter?: PackageFilter, + ): void { const resolved = this.resolvePath(source.path); if (!existsSync(resolved)) { return; @@ -332,7 +544,7 @@ export class DefaultPackageManager implements PackageManager { return; } if (stats.isDirectory()) { - const resources = this.collectPackageResources(resolved, accumulator); + const resources = this.collectPackageResources(resolved, accumulator, filter); if (!resources) { this.addPath(accumulator.extensions, resolved); } @@ -556,45 +768,25 @@ export class DefaultPackageManager implements PackageManager { if (filter) { // Empty array means "load none", undefined means "load all" if (filter.extensions !== undefined) { - for (const entry of filter.extensions) { - const resolved = resolve(packageRoot, entry); - if (existsSync(resolved)) { - this.addPath(accumulator.extensions, resolved); - } - } + this.applyPackageFilter(packageRoot, filter.extensions, "extensions", accumulator.extensions); } else { this.collectDefaultExtensions(packageRoot, accumulator); } if (filter.skills !== undefined) { - for (const entry of filter.skills) { - const resolved = resolve(packageRoot, entry); - if (existsSync(resolved)) { - this.addPath(accumulator.skills, resolved); - } - } + this.applyPackageFilter(packageRoot, filter.skills, "skills", accumulator.skills); } else { this.collectDefaultSkills(packageRoot, accumulator); } if (filter.prompts !== undefined) { - for (const entry of filter.prompts) { - const resolved = resolve(packageRoot, entry); - if (existsSync(resolved)) { - this.addPath(accumulator.prompts, resolved); - } - } + this.applyPackageFilter(packageRoot, filter.prompts, "prompts", accumulator.prompts); } else { this.collectDefaultPrompts(packageRoot, accumulator); } if (filter.themes !== undefined) { - for (const entry of filter.themes) { - const resolved = resolve(packageRoot, entry); - if (existsSync(resolved)) { - this.addPath(accumulator.themes, resolved); - } - } + this.applyPackageFilter(packageRoot, filter.themes, "themes", accumulator.themes); } else { this.collectDefaultThemes(packageRoot, accumulator); } @@ -686,6 +878,85 @@ export class DefaultPackageManager implements PackageManager { } } + /** + * Apply filter patterns to a package's resources. + * Supports glob patterns and exclusions (! prefix). + */ + private applyPackageFilter( + packageRoot: string, + patterns: string[], + resourceType: ResourceType, + target: Set, + ): void { + if (patterns.length === 0) { + return; + } + + if (!hasPatterns(patterns)) { + // No patterns - just resolve paths directly + for (const entry of patterns) { + const resolved = resolve(packageRoot, entry); + if (existsSync(resolved)) { + this.addPath(target, resolved); + } + } + return; + } + + // Has patterns - enumerate all files and filter + const allFiles = this.collectAllPackageFiles(packageRoot, resourceType); + const filtered = applyPatterns(allFiles, patterns, packageRoot); + for (const f of filtered) { + this.addPath(target, f); + } + } + + /** + * Collect all files of a given resource type from a package. + */ + private collectAllPackageFiles(packageRoot: string, resourceType: ResourceType): string[] { + const manifest = this.readPiManifest(packageRoot); + + // If manifest specifies paths, use those + if (manifest) { + const manifestPaths = manifest[resourceType]; + if (manifestPaths && manifestPaths.length > 0) { + const files: string[] = []; + for (const p of manifestPaths) { + const resolved = resolve(packageRoot, p); + if (!existsSync(resolved)) continue; + + try { + const stats = statSync(resolved); + if (stats.isFile()) { + files.push(resolved); + } else if (stats.isDirectory()) { + if (resourceType === "skills") { + files.push(...collectSkillEntries(resolved)); + } else { + files.push(...collectFiles(resolved, FILE_PATTERNS[resourceType])); + } + } + } catch { + // Ignore errors + } + } + return files; + } + } + + // Fall back to convention-based directories + const conventionDir = join(packageRoot, resourceType); + if (!existsSync(conventionDir)) { + return []; + } + + if (resourceType === "skills") { + return collectSkillEntries(conventionDir); + } + return collectFiles(conventionDir, FILE_PATTERNS[resourceType]); + } + private readPiManifest(packageRoot: string): PiManifest | null { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 4e5e7a9b..324a290b 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -158,4 +158,167 @@ Content`, expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true); }); }); + + describe("pattern filtering in top-level arrays", () => { + it("should exclude extensions with ! pattern", async () => { + const extDir = join(tempDir, "extensions"); + mkdirSync(extDir, { recursive: true }); + writeFileSync(join(extDir, "keep.ts"), "export default function() {}"); + writeFileSync(join(extDir, "remove.ts"), "export default function() {}"); + + settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]); + + 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); + }); + + it("should filter themes with glob patterns", async () => { + const themesDir = join(tempDir, "themes"); + mkdirSync(themesDir, { recursive: true }); + writeFileSync(join(themesDir, "dark.json"), "{}"); + writeFileSync(join(themesDir, "light.json"), "{}"); + writeFileSync(join(themesDir, "funky.json"), "{}"); + + settingsManager.setThemePaths([themesDir, "!funky.json"]); + + 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); + }); + + it("should filter prompts with exclusion pattern", async () => { + const promptsDir = join(tempDir, "prompts"); + mkdirSync(promptsDir, { recursive: true }); + writeFileSync(join(promptsDir, "review.md"), "Review code"); + writeFileSync(join(promptsDir, "explain.md"), "Explain code"); + + 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); + }); + + it("should filter skills with exclusion pattern", async () => { + const skillsDir = join(tempDir, "skills"); + mkdirSync(join(skillsDir, "good-skill"), { recursive: true }); + mkdirSync(join(skillsDir, "bad-skill"), { recursive: true }); + writeFileSync( + join(skillsDir, "good-skill", "SKILL.md"), + "---\nname: good-skill\ndescription: Good\n---\nContent", + ); + writeFileSync( + join(skillsDir, "bad-skill", "SKILL.md"), + "---\nname: bad-skill\ndescription: Bad\n---\nContent", + ); + + 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); + }); + + it("should work without patterns (backward compatible)", async () => { + const extPath = join(tempDir, "my-ext.ts"); + writeFileSync(extPath, "export default function() {}"); + + settingsManager.setExtensionPaths([extPath]); + + const result = await packageManager.resolve(); + expect(result.extensions).toContain(extPath); + }); + }); + + describe("pattern filtering in package filters", () => { + it("should exclude extensions from package with ! pattern", async () => { + const pkgDir = join(tempDir, "pattern-pkg"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + writeFileSync(join(pkgDir, "extensions", "foo.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "extensions", "bar.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "extensions", "baz.ts"), "export default function() {}"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: ["!**/baz.ts"], + skills: [], + prompts: [], + themes: [], + }, + ]); + + 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); + }); + + it("should filter themes from package", async () => { + const pkgDir = join(tempDir, "theme-pkg"); + mkdirSync(join(pkgDir, "themes"), { recursive: true }); + writeFileSync(join(pkgDir, "themes", "nice.json"), "{}"); + writeFileSync(join(pkgDir, "themes", "ugly.json"), "{}"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: [], + skills: [], + prompts: [], + themes: ["!ugly.json"], + }, + ]); + + 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); + }); + + it("should combine include and exclude patterns", async () => { + const pkgDir = join(tempDir, "combo-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: ["**/alpha.ts", "**/beta.ts", "!**/beta.ts"], + skills: [], + prompts: [], + themes: [], + }, + ]); + + 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); + }); + + it("should work with direct paths (no patterns)", async () => { + const pkgDir = join(tempDir, "direct-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() {}"); + + settingsManager.setPackages([ + { + source: pkgDir, + extensions: ["extensions/one.ts"], + skills: [], + prompts: [], + themes: [], + }, + ]); + + 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); + }); + }); });