feat(coding-agent): support glob patterns in pi manifest arrays

- Manifest extensions/skills/prompts/themes arrays now support glob patterns
- Use !pattern for exclusions (e.g., '!**/deprecated/*')
- Enables packages to bundle dependencies and selectively include resources
This commit is contained in:
Mario Zechner 2026-01-23 20:33:16 +01:00
parent 6beeafed17
commit ed75a8320b
2 changed files with 112 additions and 12 deletions

View file

@ -797,10 +797,10 @@ export class DefaultPackageManager implements PackageManager {
// No filter: load everything based on manifest or directory structure
const manifest = this.readPiManifest(packageRoot);
if (manifest) {
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
this.addManifestEntries(manifest.extensions, packageRoot, "extensions", accumulator.extensions);
this.addManifestEntries(manifest.skills, packageRoot, "skills", accumulator.skills);
this.addManifestEntries(manifest.prompts, packageRoot, "prompts", accumulator.prompts);
this.addManifestEntries(manifest.themes, packageRoot, "themes", accumulator.themes);
return true;
}
@ -833,7 +833,7 @@ export class DefaultPackageManager implements PackageManager {
private collectDefaultExtensions(packageRoot: string, accumulator: ResourceAccumulator): void {
const manifest = this.readPiManifest(packageRoot);
if (manifest?.extensions) {
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
this.addManifestEntries(manifest.extensions, packageRoot, "extensions", accumulator.extensions);
return;
}
const extensionsDir = join(packageRoot, "extensions");
@ -845,7 +845,7 @@ export class DefaultPackageManager implements PackageManager {
private collectDefaultSkills(packageRoot: string, accumulator: ResourceAccumulator): void {
const manifest = this.readPiManifest(packageRoot);
if (manifest?.skills) {
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
this.addManifestEntries(manifest.skills, packageRoot, "skills", accumulator.skills);
return;
}
const skillsDir = join(packageRoot, "skills");
@ -857,7 +857,7 @@ export class DefaultPackageManager implements PackageManager {
private collectDefaultPrompts(packageRoot: string, accumulator: ResourceAccumulator): void {
const manifest = this.readPiManifest(packageRoot);
if (manifest?.prompts) {
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
this.addManifestEntries(manifest.prompts, packageRoot, "prompts", accumulator.prompts);
return;
}
const promptsDir = join(packageRoot, "prompts");
@ -869,7 +869,7 @@ export class DefaultPackageManager implements PackageManager {
private collectDefaultThemes(packageRoot: string, accumulator: ResourceAccumulator): void {
const manifest = this.readPiManifest(packageRoot);
if (manifest?.themes) {
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
this.addManifestEntries(manifest.themes, packageRoot, "themes", accumulator.themes);
return;
}
const themesDir = join(packageRoot, "themes");
@ -972,12 +972,60 @@ export class DefaultPackageManager implements PackageManager {
}
}
private addManifestEntries(entries: string[] | undefined, root: string, target: Set<string>): void {
private addManifestEntries(
entries: string[] | undefined,
root: string,
resourceType: ResourceType,
target: Set<string>,
): void {
if (!entries) return;
for (const entry of entries) {
const resolved = resolve(root, entry);
this.addPath(target, resolved);
if (!hasPatterns(entries)) {
// No patterns - resolve directly
for (const entry of entries) {
const resolved = resolve(root, entry);
this.addPath(target, resolved);
}
return;
}
// Has patterns - enumerate and filter
const allFiles = this.collectAllManifestFiles(entries, root, resourceType);
const patterns = entries.filter(isPattern);
const filtered = applyPatterns(allFiles, patterns, root);
for (const f of filtered) {
this.addPath(target, f);
}
}
/**
* Collect files from manifest entries for pattern matching.
* Plain paths are resolved and enumerated; pattern strings are skipped (used for filtering).
*/
private collectAllManifestFiles(entries: string[], root: string, resourceType: ResourceType): string[] {
const files: string[] = [];
for (const entry of entries) {
if (isPattern(entry)) continue;
const resolved = resolve(root, entry);
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;
}
private addPath(set: Set<string>, value: string): void {

View file

@ -232,6 +232,58 @@ Content`,
});
});
describe("pattern filtering in pi manifest", () => {
it("should support glob patterns in manifest extensions", async () => {
const pkgDir = join(tempDir, "manifest-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "node_modules/dep/extensions"), { recursive: true });
writeFileSync(join(pkgDir, "extensions", "local.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "node_modules/dep/extensions", "remote.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "node_modules/dep/extensions", "skip.ts"), "export default function() {}");
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "manifest-pkg",
pi: {
extensions: ["extensions", "node_modules/dep/extensions", "!**/skip.ts"],
},
}),
);
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);
});
it("should support glob patterns in manifest skills", async () => {
const pkgDir = join(tempDir, "skill-manifest-pkg");
mkdirSync(join(pkgDir, "skills/good-skill"), { recursive: true });
mkdirSync(join(pkgDir, "skills/bad-skill"), { recursive: true });
writeFileSync(
join(pkgDir, "skills/good-skill", "SKILL.md"),
"---\nname: good-skill\ndescription: Good\n---\nContent",
);
writeFileSync(
join(pkgDir, "skills/bad-skill", "SKILL.md"),
"---\nname: bad-skill\ndescription: Bad\n---\nContent",
);
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "skill-manifest-pkg",
pi: {
skills: ["skills", "!**/bad-skill"],
},
}),
);
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);
});
});
describe("pattern filtering in package filters", () => {
it("should exclude extensions from package with ! pattern", async () => {
const pkgDir = join(tempDir, "pattern-pkg");