mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 22:03:45 +00:00
fix(coding-agent): add force exclude pattern and fix config toggle persistence
- Add `-path` force-exclude pattern (exact path match, highest precedence) - Change `+path` force-include to exact path match (no glob) - Manifest-excluded resources are now hidden from config (not toggleable) - Config toggle uses `+` to enable and `-` to disable - Settings paths resolve relative to settings.json location: - Global: relative to ~/.pi/agent - Project: relative to .pi - Add baseDir to PathMetadata for proper relative path computation - Update tests for new base directory and pattern behavior fixes #951
This commit is contained in:
parent
7a0b435923
commit
ea93e2f3da
5 changed files with 259 additions and 102 deletions
|
|
@ -131,6 +131,8 @@ Edit directly or use `/settings` for common options.
|
||||||
|
|
||||||
These settings define where to load extensions, skills, prompts, and themes from.
|
These settings define where to load extensions, skills, prompts, and themes from.
|
||||||
|
|
||||||
|
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.
|
||||||
|
|
||||||
| Setting | Type | Default | Description |
|
| Setting | Type | Default | Description |
|
||||||
|---------|------|---------|-------------|
|
|---------|------|---------|-------------|
|
||||||
| `packages` | array | `[]` | npm/git packages to load resources from |
|
| `packages` | array | `[]` | npm/git packages to load resources from |
|
||||||
|
|
@ -140,6 +142,8 @@ These settings define where to load extensions, skills, prompts, and themes from
|
||||||
| `themes` | string[] | `[]` | Local theme file paths or directories |
|
| `themes` | string[] | `[]` | Local theme file paths or directories |
|
||||||
| `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands |
|
| `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands |
|
||||||
|
|
||||||
|
Arrays support glob patterns and exclusions. Use `!pattern` to exclude. Use `+path` to force-include an exact path and `-path` to force-exclude an exact path.
|
||||||
|
|
||||||
#### packages
|
#### packages
|
||||||
|
|
||||||
String form loads all resources from a package:
|
String form loads all resources from a package:
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join, relative } from "node:path";
|
||||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||||
|
import { minimatch } from "minimatch";
|
||||||
import { CONFIG_DIR_NAME } from "../config.js";
|
import { CONFIG_DIR_NAME } from "../config.js";
|
||||||
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../core/package-manager.js";
|
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../core/package-manager.js";
|
||||||
import type { SettingsManager } from "../core/settings-manager.js";
|
import type { SettingsManager } from "../core/settings-manager.js";
|
||||||
|
|
@ -164,18 +165,49 @@ function collectExtensionEntries(dir: string): string[] {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExcludedByPatterns(filePath: string, patterns: string[]): boolean {
|
function normalizeExactPattern(pattern: string): string {
|
||||||
const name = basename(filePath);
|
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
|
||||||
for (const pattern of patterns) {
|
return pattern.slice(2);
|
||||||
if (pattern.startsWith("!")) {
|
|
||||||
const excludePattern = pattern.slice(1);
|
|
||||||
// Match against basename or full path
|
|
||||||
if (name === excludePattern || filePath.endsWith(excludePattern)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
||||||
|
if (patterns.length === 0) return false;
|
||||||
|
const rel = relative(baseDir, filePath);
|
||||||
|
return patterns.some((pattern) => {
|
||||||
|
const normalized = normalizeExactPattern(pattern);
|
||||||
|
return normalized === rel || normalized === filePath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {
|
||||||
|
const overrides = patterns.filter(
|
||||||
|
(pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"),
|
||||||
|
);
|
||||||
|
const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
|
||||||
|
const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));
|
||||||
|
const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));
|
||||||
|
|
||||||
|
let enabled = true;
|
||||||
|
if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -202,18 +234,18 @@ function mergeAutoDiscoveredResources(
|
||||||
themes: new Set(resolvedPaths.themes.map((r) => r.path)),
|
themes: new Set(resolvedPaths.themes.map((r) => r.path)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get exclusion patterns from settings
|
// Get override patterns from settings
|
||||||
const globalSettings = settingsManager.getGlobalSettings();
|
const globalSettings = settingsManager.getGlobalSettings();
|
||||||
const projectSettings = settingsManager.getProjectSettings();
|
const projectSettings = settingsManager.getProjectSettings();
|
||||||
|
|
||||||
const userExclusions = {
|
const userOverrides = {
|
||||||
extensions: globalSettings.extensions ?? [],
|
extensions: globalSettings.extensions ?? [],
|
||||||
skills: globalSettings.skills ?? [],
|
skills: globalSettings.skills ?? [],
|
||||||
prompts: globalSettings.prompts ?? [],
|
prompts: globalSettings.prompts ?? [],
|
||||||
themes: globalSettings.themes ?? [],
|
themes: globalSettings.themes ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectExclusions = {
|
const projectOverrides = {
|
||||||
extensions: projectSettings.extensions ?? [],
|
extensions: projectSettings.extensions ?? [],
|
||||||
skills: projectSettings.skills ?? [],
|
skills: projectSettings.skills ?? [],
|
||||||
prompts: projectSettings.prompts ?? [],
|
prompts: projectSettings.prompts ?? [],
|
||||||
|
|
@ -225,17 +257,21 @@ function mergeAutoDiscoveredResources(
|
||||||
existing: Set<string>,
|
existing: Set<string>,
|
||||||
paths: string[],
|
paths: string[],
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
exclusions: string[],
|
overrides: string[],
|
||||||
|
baseDir: string,
|
||||||
) => {
|
) => {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (!existing.has(path)) {
|
if (!existing.has(path)) {
|
||||||
const enabled = !isExcludedByPatterns(path, exclusions);
|
const enabled = isEnabledByOverrides(path, overrides, baseDir);
|
||||||
target.push({ path, enabled, metadata });
|
target.push({ path, enabled, metadata });
|
||||||
existing.add(path);
|
existing.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userBaseDir = agentDir;
|
||||||
|
const projectBaseDir = join(cwd, CONFIG_DIR_NAME);
|
||||||
|
|
||||||
// User scope auto-discovery
|
// User scope auto-discovery
|
||||||
const userExtDir = join(agentDir, "extensions");
|
const userExtDir = join(agentDir, "extensions");
|
||||||
const userSkillsDir = join(agentDir, "skills");
|
const userSkillsDir = join(agentDir, "skills");
|
||||||
|
|
@ -247,28 +283,32 @@ function mergeAutoDiscoveredResources(
|
||||||
existingPaths.extensions,
|
existingPaths.extensions,
|
||||||
collectExtensionEntries(userExtDir),
|
collectExtensionEntries(userExtDir),
|
||||||
{ source: "auto", scope: "user", origin: "top-level" },
|
{ source: "auto", scope: "user", origin: "top-level" },
|
||||||
userExclusions.extensions,
|
userOverrides.extensions,
|
||||||
|
userBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.skills,
|
result.skills,
|
||||||
existingPaths.skills,
|
existingPaths.skills,
|
||||||
collectSkillEntries(userSkillsDir),
|
collectSkillEntries(userSkillsDir),
|
||||||
{ source: "auto", scope: "user", origin: "top-level" },
|
{ source: "auto", scope: "user", origin: "top-level" },
|
||||||
userExclusions.skills,
|
userOverrides.skills,
|
||||||
|
userBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.prompts,
|
result.prompts,
|
||||||
existingPaths.prompts,
|
existingPaths.prompts,
|
||||||
collectFiles(userPromptsDir, FILE_PATTERNS.prompts),
|
collectFiles(userPromptsDir, FILE_PATTERNS.prompts),
|
||||||
{ source: "auto", scope: "user", origin: "top-level" },
|
{ source: "auto", scope: "user", origin: "top-level" },
|
||||||
userExclusions.prompts,
|
userOverrides.prompts,
|
||||||
|
userBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.themes,
|
result.themes,
|
||||||
existingPaths.themes,
|
existingPaths.themes,
|
||||||
collectFiles(userThemesDir, FILE_PATTERNS.themes),
|
collectFiles(userThemesDir, FILE_PATTERNS.themes),
|
||||||
{ source: "auto", scope: "user", origin: "top-level" },
|
{ source: "auto", scope: "user", origin: "top-level" },
|
||||||
userExclusions.themes,
|
userOverrides.themes,
|
||||||
|
userBaseDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Project scope auto-discovery
|
// Project scope auto-discovery
|
||||||
|
|
@ -282,28 +322,32 @@ function mergeAutoDiscoveredResources(
|
||||||
existingPaths.extensions,
|
existingPaths.extensions,
|
||||||
collectExtensionEntries(projectExtDir),
|
collectExtensionEntries(projectExtDir),
|
||||||
{ source: "auto", scope: "project", origin: "top-level" },
|
{ source: "auto", scope: "project", origin: "top-level" },
|
||||||
projectExclusions.extensions,
|
projectOverrides.extensions,
|
||||||
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.skills,
|
result.skills,
|
||||||
existingPaths.skills,
|
existingPaths.skills,
|
||||||
collectSkillEntries(projectSkillsDir),
|
collectSkillEntries(projectSkillsDir),
|
||||||
{ source: "auto", scope: "project", origin: "top-level" },
|
{ source: "auto", scope: "project", origin: "top-level" },
|
||||||
projectExclusions.skills,
|
projectOverrides.skills,
|
||||||
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.prompts,
|
result.prompts,
|
||||||
existingPaths.prompts,
|
existingPaths.prompts,
|
||||||
collectFiles(projectPromptsDir, FILE_PATTERNS.prompts),
|
collectFiles(projectPromptsDir, FILE_PATTERNS.prompts),
|
||||||
{ source: "auto", scope: "project", origin: "top-level" },
|
{ source: "auto", scope: "project", origin: "top-level" },
|
||||||
projectExclusions.prompts,
|
projectOverrides.prompts,
|
||||||
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
addResources(
|
addResources(
|
||||||
result.themes,
|
result.themes,
|
||||||
existingPaths.themes,
|
existingPaths.themes,
|
||||||
collectFiles(projectThemesDir, FILE_PATTERNS.themes),
|
collectFiles(projectThemesDir, FILE_PATTERNS.themes),
|
||||||
{ source: "auto", scope: "project", origin: "top-level" },
|
{ source: "auto", scope: "project", origin: "top-level" },
|
||||||
projectExclusions.themes,
|
projectOverrides.themes,
|
||||||
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -330,6 +374,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
|
||||||
allPaths,
|
allPaths,
|
||||||
options.settingsManager,
|
options.settingsManager,
|
||||||
options.cwd,
|
options.cwd,
|
||||||
|
options.agentDir,
|
||||||
() => {
|
() => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface PathMetadata {
|
||||||
source: string;
|
source: string;
|
||||||
scope: SourceScope;
|
scope: SourceScope;
|
||||||
origin: "package" | "top-level";
|
origin: "package" | "top-level";
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedResource {
|
export interface ResolvedResource {
|
||||||
|
|
@ -115,7 +116,7 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPattern(s: string): boolean {
|
function isPattern(s: string): boolean {
|
||||||
return s.startsWith("!") || s.startsWith("+") || s.includes("*") || s.includes("?");
|
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
|
function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } {
|
||||||
|
|
@ -218,21 +219,41 @@ function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExactPattern(pattern: string): string {
|
||||||
|
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
|
||||||
|
return pattern.slice(2);
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
||||||
|
if (patterns.length === 0) return false;
|
||||||
|
const rel = relative(baseDir, filePath);
|
||||||
|
return patterns.some((pattern) => {
|
||||||
|
const normalized = normalizeExactPattern(pattern);
|
||||||
|
return normalized === rel || normalized === filePath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply patterns to paths and return a Set of enabled paths.
|
* Apply patterns to paths and return a Set of enabled paths.
|
||||||
* Pattern types:
|
* Pattern types:
|
||||||
* - Plain patterns: include matching paths
|
* - Plain patterns: include matching paths
|
||||||
* - `!pattern`: exclude matching paths
|
* - `!pattern`: exclude matching paths
|
||||||
* - `+pattern`: force-include matching paths (overrides exclusions)
|
* - `+path`: force-include exact path (overrides exclusions)
|
||||||
|
* - `-path`: force-exclude exact path (overrides force-includes)
|
||||||
*/
|
*/
|
||||||
function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set<string> {
|
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[] = [];
|
const forceIncludes: string[] = [];
|
||||||
|
const forceExcludes: string[] = [];
|
||||||
|
|
||||||
for (const p of patterns) {
|
for (const p of patterns) {
|
||||||
if (p.startsWith("+")) {
|
if (p.startsWith("+")) {
|
||||||
forceIncludes.push(p.slice(1));
|
forceIncludes.push(p.slice(1));
|
||||||
|
} else if (p.startsWith("-")) {
|
||||||
|
forceExcludes.push(p.slice(1));
|
||||||
} else if (p.startsWith("!")) {
|
} else if (p.startsWith("!")) {
|
||||||
excludes.push(p.slice(1));
|
excludes.push(p.slice(1));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -256,12 +277,17 @@ function applyPatterns(allPaths: string[], patterns: string[], baseDir: string):
|
||||||
// Step 3: Force-include (add back from allPaths, overriding exclusions)
|
// Step 3: Force-include (add back from allPaths, overriding exclusions)
|
||||||
if (forceIncludes.length > 0) {
|
if (forceIncludes.length > 0) {
|
||||||
for (const filePath of allPaths) {
|
for (const filePath of allPaths) {
|
||||||
if (!result.includes(filePath) && matchesAnyPattern(filePath, forceIncludes, baseDir)) {
|
if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
|
||||||
result.push(filePath);
|
result.push(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Force-exclude (remove even if included or force-included)
|
||||||
|
if (forceExcludes.length > 0) {
|
||||||
|
result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir));
|
||||||
|
}
|
||||||
|
|
||||||
return new Set(result);
|
return new Set(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,20 +364,35 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const packageSources = this.dedupePackages(allPackages);
|
const packageSources = this.dedupePackages(allPackages);
|
||||||
await this.resolvePackageSources(packageSources, accumulator, onMissing);
|
await this.resolvePackageSources(packageSources, accumulator, onMissing);
|
||||||
|
|
||||||
|
const globalBaseDir = this.agentDir;
|
||||||
|
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
|
||||||
|
|
||||||
for (const resourceType of RESOURCE_TYPES) {
|
for (const resourceType of RESOURCE_TYPES) {
|
||||||
const target = this.getTargetMap(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(globalEntries, resourceType, target, {
|
this.resolveLocalEntries(
|
||||||
source: "local",
|
globalEntries,
|
||||||
scope: "user",
|
resourceType,
|
||||||
origin: "top-level",
|
target,
|
||||||
});
|
{
|
||||||
this.resolveLocalEntries(projectEntries, resourceType, target, {
|
source: "local",
|
||||||
source: "local",
|
scope: "user",
|
||||||
scope: "project",
|
origin: "top-level",
|
||||||
origin: "top-level",
|
},
|
||||||
});
|
globalBaseDir,
|
||||||
|
);
|
||||||
|
this.resolveLocalEntries(
|
||||||
|
projectEntries,
|
||||||
|
resourceType,
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
source: "local",
|
||||||
|
scope: "project",
|
||||||
|
origin: "top-level",
|
||||||
|
},
|
||||||
|
projectBaseDir,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResolvedPaths(accumulator);
|
return this.toResolvedPaths(accumulator);
|
||||||
|
|
@ -470,6 +511,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const installed = await installMissing();
|
const installed = await installMissing();
|
||||||
if (!installed) continue;
|
if (!installed) continue;
|
||||||
}
|
}
|
||||||
|
metadata.baseDir = installedPath;
|
||||||
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +522,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const installed = await installMissing();
|
const installed = await installMissing();
|
||||||
if (!installed) continue;
|
if (!installed) continue;
|
||||||
}
|
}
|
||||||
|
metadata.baseDir = installedPath;
|
||||||
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
this.collectPackageResources(installedPath, accumulator, filter, metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -499,10 +542,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
try {
|
try {
|
||||||
const stats = statSync(resolved);
|
const stats = statSync(resolved);
|
||||||
if (stats.isFile()) {
|
if (stats.isFile()) {
|
||||||
|
metadata.baseDir = dirname(resolved);
|
||||||
this.addResource(accumulator.extensions, resolved, metadata, true);
|
this.addResource(accumulator.extensions, resolved, metadata, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
|
metadata.baseDir = resolved;
|
||||||
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
|
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
|
||||||
if (!resources) {
|
if (!resources) {
|
||||||
this.addResource(accumulator.extensions, resolved, metadata, true);
|
this.addResource(accumulator.extensions, resolved, metadata, true);
|
||||||
|
|
@ -802,6 +847,14 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
return resolve(this.cwd, trimmed);
|
return resolve(this.cwd, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolvePathFromBase(input: string, baseDir: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed === "~") return homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||||
|
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||||
|
return resolve(baseDir, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
private collectPackageResources(
|
private collectPackageResources(
|
||||||
packageRoot: string,
|
packageRoot: string,
|
||||||
accumulator: ResourceAccumulator,
|
accumulator: ResourceAccumulator,
|
||||||
|
|
@ -893,18 +946,10 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply user patterns on top of manifest-enabled files
|
// Apply user patterns on top of manifest-enabled files
|
||||||
const enabledByUser = applyPatterns(Array.from(enabledByManifest), userPatterns, packageRoot);
|
const enabledByUser = applyPatterns(allFiles, 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) {
|
for (const f of allFiles) {
|
||||||
const enabled = enabledByUser.has(f) || forceEnabled.has(f);
|
const enabled = enabledByUser.has(f);
|
||||||
this.addResource(target, f, metadata, enabled);
|
this.addResource(target, f, metadata, enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -925,7 +970,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const manifestPatterns = entries.filter(isPattern);
|
const manifestPatterns = entries.filter(isPattern);
|
||||||
const enabledByManifest =
|
const enabledByManifest =
|
||||||
manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles);
|
manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles);
|
||||||
return { allFiles, enabledByManifest };
|
return { allFiles: Array.from(enabledByManifest), enabledByManifest };
|
||||||
}
|
}
|
||||||
|
|
||||||
const conventionDir = join(packageRoot, resourceType);
|
const conventionDir = join(packageRoot, resourceType);
|
||||||
|
|
@ -968,7 +1013,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const enabledPaths = applyPatterns(allFiles, patterns, root);
|
const enabledPaths = applyPatterns(allFiles, patterns, root);
|
||||||
|
|
||||||
for (const f of allFiles) {
|
for (const f of allFiles) {
|
||||||
this.addResource(target, f, metadata, enabledPaths.has(f));
|
if (enabledPaths.has(f)) {
|
||||||
|
this.addResource(target, f, metadata, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -983,16 +1030,17 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
target: Map<string, { metadata: PathMetadata; enabled: boolean }>,
|
||||||
metadata: PathMetadata,
|
metadata: PathMetadata,
|
||||||
|
baseDir: string,
|
||||||
): void {
|
): void {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
// Collect all files from plain entries (non-pattern entries)
|
// Collect all files from plain entries (non-pattern entries)
|
||||||
const { plain, patterns } = splitPatterns(entries);
|
const { plain, patterns } = splitPatterns(entries);
|
||||||
const resolvedPlain = plain.map((p) => this.resolvePath(p));
|
const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir));
|
||||||
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
|
const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType);
|
||||||
|
|
||||||
// Determine which files are enabled based on patterns
|
// Determine which files are enabled based on patterns
|
||||||
const enabledPaths = applyPatterns(allFiles, patterns, this.cwd);
|
const enabledPaths = applyPatterns(allFiles, patterns, baseDir);
|
||||||
|
|
||||||
// Add all files with their enabled state
|
// Add all files with their enabled state
|
||||||
for (const f of allFiles) {
|
for (const f of allFiles) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* TUI component for managing package resources (enable/disable)
|
* TUI component for managing package resources (enable/disable)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { basename, relative } from "node:path";
|
import { basename, dirname, join, relative } from "node:path";
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
|
import { CONFIG_DIR_NAME } from "../../../config.js";
|
||||||
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js";
|
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js";
|
||||||
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js";
|
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js";
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
|
|
@ -170,6 +171,7 @@ class ResourceList implements Component, Focusable {
|
||||||
private maxVisible = 15;
|
private maxVisible = 15;
|
||||||
private settingsManager: SettingsManager;
|
private settingsManager: SettingsManager;
|
||||||
private cwd: string;
|
private cwd: string;
|
||||||
|
private agentDir: string;
|
||||||
|
|
||||||
public onCancel?: () => void;
|
public onCancel?: () => void;
|
||||||
public onExit?: () => void;
|
public onExit?: () => void;
|
||||||
|
|
@ -184,10 +186,11 @@ class ResourceList implements Component, Focusable {
|
||||||
this.searchInput.focused = value;
|
this.searchInput.focused = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string) {
|
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string, agentDir: string) {
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
this.settingsManager = settingsManager;
|
this.settingsManager = settingsManager;
|
||||||
this.cwd = cwd;
|
this.cwd = cwd;
|
||||||
|
this.agentDir = agentDir;
|
||||||
this.searchInput = new Input();
|
this.searchInput = new Input();
|
||||||
this.buildFlatList();
|
this.buildFlatList();
|
||||||
this.filteredItems = [...this.flatItems];
|
this.filteredItems = [...this.flatItems];
|
||||||
|
|
@ -416,19 +419,20 @@ class ResourceList implements Component, Focusable {
|
||||||
|
|
||||||
// Generate pattern for this resource
|
// Generate pattern for this resource
|
||||||
const pattern = this.getResourcePattern(item);
|
const pattern = this.getResourcePattern(item);
|
||||||
const disablePattern = `!${pattern}`;
|
const disablePattern = `-${pattern}`;
|
||||||
|
const enablePattern = `+${pattern}`;
|
||||||
|
|
||||||
// Filter out existing patterns for this resource
|
// Filter out existing patterns for this resource
|
||||||
const updated = current.filter((p) => {
|
const updated = current.filter((p) => {
|
||||||
const stripped = p.startsWith("!") || p.startsWith("+") ? p.slice(1) : p;
|
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
|
||||||
return stripped !== pattern;
|
return stripped !== pattern;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!enabled) {
|
if (enabled) {
|
||||||
// Add !pattern to disable
|
updated.push(enablePattern);
|
||||||
|
} else {
|
||||||
updated.push(disablePattern);
|
updated.push(disablePattern);
|
||||||
}
|
}
|
||||||
// For enabling, just remove the !pattern (done above)
|
|
||||||
|
|
||||||
if (scope === "project") {
|
if (scope === "project") {
|
||||||
if (arrayKey === "extensions") {
|
if (arrayKey === "extensions") {
|
||||||
|
|
@ -480,18 +484,20 @@ class ResourceList implements Component, Focusable {
|
||||||
|
|
||||||
// Generate pattern relative to package root
|
// Generate pattern relative to package root
|
||||||
const pattern = this.getPackageResourcePattern(item);
|
const pattern = this.getPackageResourcePattern(item);
|
||||||
const disablePattern = `!${pattern}`;
|
const disablePattern = `-${pattern}`;
|
||||||
|
const enablePattern = `+${pattern}`;
|
||||||
|
|
||||||
// Filter out existing patterns for this resource
|
// Filter out existing patterns for this resource
|
||||||
const updated = current.filter((p) => {
|
const updated = current.filter((p) => {
|
||||||
const stripped = p.startsWith("!") || p.startsWith("+") ? p.slice(1) : p;
|
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
|
||||||
return stripped !== pattern;
|
return stripped !== pattern;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!enabled) {
|
if (enabled) {
|
||||||
|
updated.push(enablePattern);
|
||||||
|
} else {
|
||||||
updated.push(disablePattern);
|
updated.push(disablePattern);
|
||||||
}
|
}
|
||||||
// For enabling, just remove the !pattern (done above)
|
|
||||||
|
|
||||||
(pkg as Record<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
|
(pkg as Record<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
|
||||||
|
|
||||||
|
|
@ -510,19 +516,19 @@ class ResourceList implements Component, Focusable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTopLevelBaseDir(scope: "user" | "project"): string {
|
||||||
|
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
|
||||||
|
}
|
||||||
|
|
||||||
private getResourcePattern(item: ResourceItem): string {
|
private getResourcePattern(item: ResourceItem): string {
|
||||||
// Try relative path from cwd first
|
const scope = item.metadata.scope as "user" | "project";
|
||||||
const rel = relative(this.cwd, item.path);
|
const baseDir = this.getTopLevelBaseDir(scope);
|
||||||
if (!rel.startsWith("..") && !rel.startsWith("/")) {
|
return relative(baseDir, item.path);
|
||||||
return rel;
|
|
||||||
}
|
|
||||||
// Fall back to basename
|
|
||||||
return basename(item.path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPackageResourcePattern(item: ResourceItem): string {
|
private getPackageResourcePattern(item: ResourceItem): string {
|
||||||
// For package resources, use just the filename
|
const baseDir = item.metadata.baseDir ?? dirname(item.path);
|
||||||
return basename(item.path);
|
return relative(baseDir, item.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -542,6 +548,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
|
||||||
resolvedPaths: ResolvedPaths,
|
resolvedPaths: ResolvedPaths,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
|
agentDir: string,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
requestRender: () => void,
|
requestRender: () => void,
|
||||||
|
|
@ -558,7 +565,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
|
||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
// Resource list
|
// Resource list
|
||||||
this.resourceList = new ResourceList(groups, settingsManager, cwd);
|
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir);
|
||||||
this.resourceList.onCancel = onClose;
|
this.resourceList.onCancel = onClose;
|
||||||
this.resourceList.onExit = onExit;
|
this.resourceList.onExit = onExit;
|
||||||
this.resourceList.onToggle = () => requestRender();
|
this.resourceList.onToggle = () => requestRender();
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,14 @@ const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith"
|
||||||
|
|
||||||
describe("DefaultPackageManager", () => {
|
describe("DefaultPackageManager", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
let agentDir: string;
|
||||||
let settingsManager: SettingsManager;
|
let settingsManager: SettingsManager;
|
||||||
let packageManager: DefaultPackageManager;
|
let packageManager: DefaultPackageManager;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
mkdirSync(tempDir, { recursive: true });
|
mkdirSync(tempDir, { recursive: true });
|
||||||
const agentDir = join(tempDir, "agent");
|
agentDir = join(tempDir, "agent");
|
||||||
mkdirSync(agentDir, { recursive: true });
|
mkdirSync(agentDir, { recursive: true });
|
||||||
|
|
||||||
settingsManager = SettingsManager.inMemory();
|
settingsManager = SettingsManager.inMemory();
|
||||||
|
|
@ -45,16 +46,18 @@ describe("DefaultPackageManager", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve local extension paths from settings", async () => {
|
it("should resolve local extension paths from settings", async () => {
|
||||||
const extPath = join(tempDir, "my-extension.ts");
|
const extDir = join(agentDir, "extensions");
|
||||||
|
mkdirSync(extDir, { recursive: true });
|
||||||
|
const extPath = join(extDir, "my-extension.ts");
|
||||||
writeFileSync(extPath, "export default function() {}");
|
writeFileSync(extPath, "export default function() {}");
|
||||||
settingsManager.setExtensionPaths([extPath]);
|
settingsManager.setExtensionPaths(["extensions/my-extension.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
|
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", "my-skill");
|
const skillDir = join(agentDir, "skills", "my-skill");
|
||||||
mkdirSync(skillDir, { recursive: true });
|
mkdirSync(skillDir, { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(skillDir, "SKILL.md"),
|
join(skillDir, "SKILL.md"),
|
||||||
|
|
@ -65,12 +68,24 @@ description: A test skill
|
||||||
Content`,
|
Content`,
|
||||||
);
|
);
|
||||||
|
|
||||||
settingsManager.setSkillPaths([join(tempDir, "skills")]);
|
settingsManager.setSkillPaths(["skills"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
// Skills with SKILL.md are returned as directory paths
|
// Skills with SKILL.md are returned as directory paths
|
||||||
expect(result.skills.some((r) => r.path === skillDir && r.enabled)).toBe(true);
|
expect(result.skills.some((r) => r.path === skillDir && r.enabled)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should resolve project paths relative to .pi", async () => {
|
||||||
|
const extDir = join(tempDir, ".pi", "extensions");
|
||||||
|
mkdirSync(extDir, { recursive: true });
|
||||||
|
const extPath = join(extDir, "project-ext.ts");
|
||||||
|
writeFileSync(extPath, "export default function() {}");
|
||||||
|
|
||||||
|
settingsManager.setProjectExtensionPaths(["extensions/project-ext.ts"]);
|
||||||
|
|
||||||
|
const result = await packageManager.resolve();
|
||||||
|
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveExtensionSources", () => {
|
describe("resolveExtensionSources", () => {
|
||||||
|
|
@ -174,12 +189,12 @@ Content`,
|
||||||
|
|
||||||
describe("pattern filtering in top-level arrays", () => {
|
describe("pattern filtering in top-level arrays", () => {
|
||||||
it("should exclude extensions with ! pattern", async () => {
|
it("should exclude extensions with ! pattern", async () => {
|
||||||
const extDir = join(tempDir, "extensions");
|
const extDir = join(agentDir, "extensions");
|
||||||
mkdirSync(extDir, { recursive: true });
|
mkdirSync(extDir, { recursive: true });
|
||||||
writeFileSync(join(extDir, "keep.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "keep.ts"), "export default function() {}");
|
||||||
writeFileSync(join(extDir, "remove.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "remove.ts"), "export default function() {}");
|
||||||
|
|
||||||
settingsManager.setExtensionPaths([extDir, "!**/remove.ts"]);
|
settingsManager.setExtensionPaths(["extensions", "!**/remove.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((r) => isEnabled(r, "keep.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "keep.ts"))).toBe(true);
|
||||||
|
|
@ -187,13 +202,13 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter themes with glob patterns", async () => {
|
it("should filter themes with glob patterns", async () => {
|
||||||
const themesDir = join(tempDir, "themes");
|
const themesDir = join(agentDir, "themes");
|
||||||
mkdirSync(themesDir, { recursive: true });
|
mkdirSync(themesDir, { recursive: true });
|
||||||
writeFileSync(join(themesDir, "dark.json"), "{}");
|
writeFileSync(join(themesDir, "dark.json"), "{}");
|
||||||
writeFileSync(join(themesDir, "light.json"), "{}");
|
writeFileSync(join(themesDir, "light.json"), "{}");
|
||||||
writeFileSync(join(themesDir, "funky.json"), "{}");
|
writeFileSync(join(themesDir, "funky.json"), "{}");
|
||||||
|
|
||||||
settingsManager.setThemePaths([themesDir, "!funky.json"]);
|
settingsManager.setThemePaths(["themes", "!funky.json"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.themes.some((r) => isEnabled(r, "dark.json"))).toBe(true);
|
expect(result.themes.some((r) => isEnabled(r, "dark.json"))).toBe(true);
|
||||||
|
|
@ -202,12 +217,12 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter prompts with exclusion pattern", async () => {
|
it("should filter prompts with exclusion pattern", async () => {
|
||||||
const promptsDir = join(tempDir, "prompts");
|
const promptsDir = join(agentDir, "prompts");
|
||||||
mkdirSync(promptsDir, { recursive: true });
|
mkdirSync(promptsDir, { recursive: true });
|
||||||
writeFileSync(join(promptsDir, "review.md"), "Review code");
|
writeFileSync(join(promptsDir, "review.md"), "Review code");
|
||||||
writeFileSync(join(promptsDir, "explain.md"), "Explain code");
|
writeFileSync(join(promptsDir, "explain.md"), "Explain code");
|
||||||
|
|
||||||
settingsManager.setPromptTemplatePaths([promptsDir, "!explain.md"]);
|
settingsManager.setPromptTemplatePaths(["prompts", "!explain.md"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.prompts.some((r) => isEnabled(r, "review.md"))).toBe(true);
|
expect(result.prompts.some((r) => isEnabled(r, "review.md"))).toBe(true);
|
||||||
|
|
@ -215,7 +230,7 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter skills with exclusion pattern", async () => {
|
it("should filter skills with exclusion pattern", async () => {
|
||||||
const skillsDir = join(tempDir, "skills");
|
const skillsDir = join(agentDir, "skills");
|
||||||
mkdirSync(join(skillsDir, "good-skill"), { recursive: true });
|
mkdirSync(join(skillsDir, "good-skill"), { recursive: true });
|
||||||
mkdirSync(join(skillsDir, "bad-skill"), { recursive: true });
|
mkdirSync(join(skillsDir, "bad-skill"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|
@ -227,7 +242,7 @@ Content`,
|
||||||
"---\nname: bad-skill\ndescription: Bad\n---\nContent",
|
"---\nname: bad-skill\ndescription: Bad\n---\nContent",
|
||||||
);
|
);
|
||||||
|
|
||||||
settingsManager.setSkillPaths([skillsDir, "!**/bad-skill"]);
|
settingsManager.setSkillPaths(["skills", "!**/bad-skill"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
||||||
|
|
@ -235,10 +250,12 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should work without patterns (backward compatible)", async () => {
|
it("should work without patterns (backward compatible)", async () => {
|
||||||
const extPath = join(tempDir, "my-ext.ts");
|
const extDir = join(agentDir, "extensions");
|
||||||
|
mkdirSync(extDir, { recursive: true });
|
||||||
|
const extPath = join(extDir, "my-ext.ts");
|
||||||
writeFileSync(extPath, "export default function() {}");
|
writeFileSync(extPath, "export default function() {}");
|
||||||
|
|
||||||
settingsManager.setExtensionPaths([extPath]);
|
settingsManager.setExtensionPaths(["extensions/my-ext.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
|
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
|
||||||
|
|
@ -266,7 +283,7 @@ Content`,
|
||||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.extensions.some((r) => isEnabled(r, "local.ts"))).toBe(true);
|
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) => isEnabled(r, "remote.ts"))).toBe(true);
|
||||||
expect(result.extensions.some((r) => isDisabled(r, "skip.ts"))).toBe(true);
|
expect(result.extensions.some((r) => r.path.endsWith("skip.ts"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should support glob patterns in manifest skills", async () => {
|
it("should support glob patterns in manifest skills", async () => {
|
||||||
|
|
@ -293,7 +310,7 @@ Content`,
|
||||||
|
|
||||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||||
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
expect(result.skills.some((r) => isEnabled(r, "good-skill", "includes"))).toBe(true);
|
||||||
expect(result.skills.some((r) => isDisabled(r, "bad-skill", "includes"))).toBe(true);
|
expect(result.skills.some((r) => r.path.includes("bad-skill"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -333,7 +350,7 @@ Content`,
|
||||||
// bar.ts should be excluded (by user)
|
// bar.ts should be excluded (by user)
|
||||||
expect(result.extensions.some((r) => isDisabled(r, "bar.ts"))).toBe(true);
|
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((r) => isDisabled(r, "baz.ts"))).toBe(true);
|
expect(result.extensions.some((r) => r.path.endsWith("baz.ts"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should exclude extensions from package with ! pattern", async () => {
|
it("should exclude extensions from package with ! pattern", async () => {
|
||||||
|
|
@ -427,14 +444,14 @@ Content`,
|
||||||
|
|
||||||
describe("force-include patterns", () => {
|
describe("force-include patterns", () => {
|
||||||
it("should force-include extensions with + pattern after exclusion", async () => {
|
it("should force-include extensions with + pattern after exclusion", async () => {
|
||||||
const extDir = join(tempDir, "extensions");
|
const extDir = join(agentDir, "extensions");
|
||||||
mkdirSync(extDir, { recursive: true });
|
mkdirSync(extDir, { recursive: true });
|
||||||
writeFileSync(join(extDir, "keep.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "keep.ts"), "export default function() {}");
|
||||||
writeFileSync(join(extDir, "excluded.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "excluded.ts"), "export default function() {}");
|
||||||
writeFileSync(join(extDir, "force-back.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "force-back.ts"), "export default function() {}");
|
||||||
|
|
||||||
// Exclude all, then force-include one back
|
// Exclude all, then force-include one back
|
||||||
settingsManager.setExtensionPaths([extDir, "!**/*.ts", "+**/force-back.ts"]);
|
settingsManager.setExtensionPaths(["extensions", "!extensions/*.ts", "+extensions/force-back.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((r) => isDisabled(r, "keep.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isDisabled(r, "keep.ts"))).toBe(true);
|
||||||
|
|
@ -452,7 +469,7 @@ Content`,
|
||||||
settingsManager.setPackages([
|
settingsManager.setPackages([
|
||||||
{
|
{
|
||||||
source: pkgDir,
|
source: pkgDir,
|
||||||
extensions: ["!*", "+**/beta.ts"],
|
extensions: ["!**/*.ts", "+extensions/beta.ts"],
|
||||||
skills: [],
|
skills: [],
|
||||||
prompts: [],
|
prompts: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
|
|
@ -478,7 +495,7 @@ Content`,
|
||||||
{
|
{
|
||||||
source: pkgDir,
|
source: pkgDir,
|
||||||
extensions: [],
|
extensions: [],
|
||||||
skills: ["!*", "+**/skill-a", "+**/skill-c"],
|
skills: ["!**/*", "+skills/skill-a", "+skills/skill-c"],
|
||||||
prompts: [],
|
prompts: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -491,13 +508,13 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should force-include after specific exclusion", async () => {
|
it("should force-include after specific exclusion", async () => {
|
||||||
const extDir = join(tempDir, "specific-force");
|
const extDir = join(agentDir, "extensions");
|
||||||
mkdirSync(extDir, { recursive: true });
|
mkdirSync(extDir, { recursive: true });
|
||||||
writeFileSync(join(extDir, "a.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "a.ts"), "export default function() {}");
|
||||||
writeFileSync(join(extDir, "b.ts"), "export default function() {}");
|
writeFileSync(join(extDir, "b.ts"), "export default function() {}");
|
||||||
|
|
||||||
// Specifically exclude b.ts, then force it back
|
// Specifically exclude b.ts, then force it back
|
||||||
settingsManager.setExtensionPaths([extDir, "!**/b.ts", "+**/b.ts"]);
|
settingsManager.setExtensionPaths(["extensions", "!extensions/b.ts", "+extensions/b.ts"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions.some((r) => isEnabled(r, "a.ts"))).toBe(true);
|
expect(result.extensions.some((r) => isEnabled(r, "a.ts"))).toBe(true);
|
||||||
|
|
@ -515,7 +532,7 @@ Content`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: "manifest-force-pkg",
|
name: "manifest-force-pkg",
|
||||||
pi: {
|
pi: {
|
||||||
extensions: ["extensions", "!**/two.ts", "+**/two.ts"],
|
extensions: ["extensions", "!**/two.ts", "+extensions/two.ts"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -527,13 +544,13 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should force-include themes", async () => {
|
it("should force-include themes", async () => {
|
||||||
const themesDir = join(tempDir, "force-themes");
|
const themesDir = join(agentDir, "themes");
|
||||||
mkdirSync(themesDir, { recursive: true });
|
mkdirSync(themesDir, { recursive: true });
|
||||||
writeFileSync(join(themesDir, "dark.json"), "{}");
|
writeFileSync(join(themesDir, "dark.json"), "{}");
|
||||||
writeFileSync(join(themesDir, "light.json"), "{}");
|
writeFileSync(join(themesDir, "light.json"), "{}");
|
||||||
writeFileSync(join(themesDir, "special.json"), "{}");
|
writeFileSync(join(themesDir, "special.json"), "{}");
|
||||||
|
|
||||||
settingsManager.setThemePaths([themesDir, "!*.json", "+special.json"]);
|
settingsManager.setThemePaths(["themes", "!themes/*.json", "+themes/special.json"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.themes.some((r) => isDisabled(r, "dark.json"))).toBe(true);
|
expect(result.themes.some((r) => isDisabled(r, "dark.json"))).toBe(true);
|
||||||
|
|
@ -542,13 +559,13 @@ Content`,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should force-include prompts", async () => {
|
it("should force-include prompts", async () => {
|
||||||
const promptsDir = join(tempDir, "force-prompts");
|
const promptsDir = join(agentDir, "prompts");
|
||||||
mkdirSync(promptsDir, { recursive: true });
|
mkdirSync(promptsDir, { recursive: true });
|
||||||
writeFileSync(join(promptsDir, "review.md"), "Review");
|
writeFileSync(join(promptsDir, "review.md"), "Review");
|
||||||
writeFileSync(join(promptsDir, "explain.md"), "Explain");
|
writeFileSync(join(promptsDir, "explain.md"), "Explain");
|
||||||
writeFileSync(join(promptsDir, "debug.md"), "Debug");
|
writeFileSync(join(promptsDir, "debug.md"), "Debug");
|
||||||
|
|
||||||
settingsManager.setPromptTemplatePaths([promptsDir, "!*", "+debug.md"]);
|
settingsManager.setPromptTemplatePaths(["prompts", "!prompts/*.md", "+prompts/debug.md"]);
|
||||||
|
|
||||||
const result = await packageManager.resolve();
|
const result = await packageManager.resolve();
|
||||||
expect(result.prompts.some((r) => isDisabled(r, "review.md"))).toBe(true);
|
expect(result.prompts.some((r) => isDisabled(r, "review.md"))).toBe(true);
|
||||||
|
|
@ -557,6 +574,42 @@ Content`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("force-exclude patterns", () => {
|
||||||
|
it("should force-exclude top-level resources", async () => {
|
||||||
|
const extDir = join(agentDir, "extensions");
|
||||||
|
mkdirSync(extDir, { recursive: true });
|
||||||
|
writeFileSync(join(extDir, "alpha.ts"), "export default function() {}");
|
||||||
|
writeFileSync(join(extDir, "beta.ts"), "export default function() {}");
|
||||||
|
|
||||||
|
settingsManager.setExtensionPaths(["extensions", "+extensions/alpha.ts", "-extensions/alpha.ts"]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should force-exclude in package filters", async () => {
|
||||||
|
const pkgDir = join(tempDir, "force-exclude-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() {}");
|
||||||
|
|
||||||
|
settingsManager.setPackages([
|
||||||
|
{
|
||||||
|
source: pkgDir,
|
||||||
|
extensions: ["extensions/*.ts", "+extensions/alpha.ts", "-extensions/alpha.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("package deduplication", () => {
|
describe("package deduplication", () => {
|
||||||
it("should dedupe same local package in global and project (project wins)", async () => {
|
it("should dedupe same local package in global and project (project wins)", async () => {
|
||||||
const pkgDir = join(tempDir, "shared-pkg");
|
const pkgDir = join(tempDir, "shared-pkg");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue