From b270e7b5853897a4d7915a08237dcbf3c2421043 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 26 Jan 2026 13:37:08 +0100 Subject: [PATCH] fix(coding-agent): apply config overrides to auto-discovery --- .../coding-agent/src/cli/config-selector.ts | 350 +----------------- .../coding-agent/src/core/package-manager.ts | 330 +++++++++++++++++ .../coding-agent/src/core/prompt-templates.ts | 48 ++- .../coding-agent/src/core/resource-loader.ts | 34 +- packages/coding-agent/src/core/skills.ts | 37 +- .../coding-agent/test/package-manager.test.ts | 24 ++ 6 files changed, 443 insertions(+), 380 deletions(-) diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index b30bfd3b..93d591cd 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -2,12 +2,8 @@ * TUI config selector for `pi config` command */ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { basename, join, relative } from "node:path"; import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; -import { minimatch } from "minimatch"; -import { CONFIG_DIR_NAME } from "../config.js"; -import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../core/package-manager.js"; +import type { ResolvedPaths } from "../core/package-manager.js"; import type { SettingsManager } from "../core/settings-manager.js"; import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.js"; import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.js"; @@ -19,359 +15,17 @@ export interface ConfigSelectorOptions { agentDir: string; } -type ResourceType = "extensions" | "skills" | "prompts" | "themes"; - -const FILE_PATTERNS: Record = { - extensions: /\.(ts|js)$/, - skills: /\.md$/, - prompts: /\.md$/, - themes: /\.json$/, -}; - -function collectFiles(dir: string, pattern: RegExp): 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 (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, pattern)); - } else if (isFile && pattern.test(entry.name)) { - files.push(fullPath); - } - } - } catch { - // Ignore errors - } - - return files; -} - -/** - * Collect skill entries from a directory. - * Matches the behavior of loadSkillsFromDirInternal in skills.ts: - * - Direct .md files in the root directory - * - Subdirectories containing SKILL.md (returns the directory path) - * - Recursively checks subdirectories that don't have SKILL.md - */ -function collectSkillEntries(dir: string, isRoot = true): 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) { - // Check for SKILL.md in subdirectory - const skillMd = join(fullPath, "SKILL.md"); - if (existsSync(skillMd)) { - // This is a skill directory, add it - entries.push(fullPath); - } else { - // Recurse into subdirectory to find skills - entries.push(...collectSkillEntries(fullPath, false)); - } - } else if (isFile && entry.name.endsWith(".md")) { - // Only include direct .md files at root level, or SKILL.md anywhere - if (isRoot || entry.name === "SKILL.md") { - entries.push(fullPath); - } - } - } - } catch { - // Ignore errors - } - - return entries; -} - -function collectExtensionEntries(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 (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { - entries.push(fullPath); - } else if (isDir) { - // Check for index.ts/js or package.json with pi field - const indexTs = join(fullPath, "index.ts"); - const indexJs = join(fullPath, "index.js"); - if (existsSync(indexTs)) { - entries.push(indexTs); - } else if (existsSync(indexJs)) { - entries.push(indexJs); - } - // Skip subdirectories that don't have an entry point - } - } - } catch { - // Ignore errors - } - - return entries; -} - -function normalizeExactPattern(pattern: string): string { - if (pattern.startsWith("./") || pattern.startsWith(".\\")) { - return pattern.slice(2); - } - 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; -} - -/** - * Merge auto-discovered resources into resolved paths. - * Auto-discovered resources are enabled by default unless explicitly disabled via settings. - */ -function mergeAutoDiscoveredResources( - resolvedPaths: ResolvedPaths, - settingsManager: SettingsManager, - cwd: string, - agentDir: string, -): ResolvedPaths { - const result: ResolvedPaths = { - extensions: [...resolvedPaths.extensions], - skills: [...resolvedPaths.skills], - prompts: [...resolvedPaths.prompts], - themes: [...resolvedPaths.themes], - }; - - const existingPaths = { - extensions: new Set(resolvedPaths.extensions.map((r) => r.path)), - skills: new Set(resolvedPaths.skills.map((r) => r.path)), - prompts: new Set(resolvedPaths.prompts.map((r) => r.path)), - themes: new Set(resolvedPaths.themes.map((r) => r.path)), - }; - - // Get override patterns from settings - const globalSettings = settingsManager.getGlobalSettings(); - const projectSettings = settingsManager.getProjectSettings(); - - const userOverrides = { - extensions: globalSettings.extensions ?? [], - skills: globalSettings.skills ?? [], - prompts: globalSettings.prompts ?? [], - themes: globalSettings.themes ?? [], - }; - - const projectOverrides = { - extensions: projectSettings.extensions ?? [], - skills: projectSettings.skills ?? [], - prompts: projectSettings.prompts ?? [], - themes: projectSettings.themes ?? [], - }; - - const addResources = ( - target: ResolvedResource[], - existing: Set, - paths: string[], - metadata: PathMetadata, - overrides: string[], - baseDir: string, - ) => { - for (const path of paths) { - if (!existing.has(path)) { - const enabled = isEnabledByOverrides(path, overrides, baseDir); - target.push({ path, enabled, metadata }); - existing.add(path); - } - } - }; - - const userBaseDir = agentDir; - const projectBaseDir = join(cwd, CONFIG_DIR_NAME); - - // User scope auto-discovery - const userExtDir = join(agentDir, "extensions"); - const userSkillsDir = join(agentDir, "skills"); - const userPromptsDir = join(agentDir, "prompts"); - const userThemesDir = join(agentDir, "themes"); - - addResources( - result.extensions, - existingPaths.extensions, - collectExtensionEntries(userExtDir), - { source: "auto", scope: "user", origin: "top-level" }, - userOverrides.extensions, - userBaseDir, - ); - addResources( - result.skills, - existingPaths.skills, - collectSkillEntries(userSkillsDir), - { source: "auto", scope: "user", origin: "top-level" }, - userOverrides.skills, - userBaseDir, - ); - addResources( - result.prompts, - existingPaths.prompts, - collectFiles(userPromptsDir, FILE_PATTERNS.prompts), - { source: "auto", scope: "user", origin: "top-level" }, - userOverrides.prompts, - userBaseDir, - ); - addResources( - result.themes, - existingPaths.themes, - collectFiles(userThemesDir, FILE_PATTERNS.themes), - { source: "auto", scope: "user", origin: "top-level" }, - userOverrides.themes, - userBaseDir, - ); - - // Project scope auto-discovery - const projectExtDir = join(cwd, CONFIG_DIR_NAME, "extensions"); - const projectSkillsDir = join(cwd, CONFIG_DIR_NAME, "skills"); - const projectPromptsDir = join(cwd, CONFIG_DIR_NAME, "prompts"); - const projectThemesDir = join(cwd, CONFIG_DIR_NAME, "themes"); - - addResources( - result.extensions, - existingPaths.extensions, - collectExtensionEntries(projectExtDir), - { source: "auto", scope: "project", origin: "top-level" }, - projectOverrides.extensions, - projectBaseDir, - ); - addResources( - result.skills, - existingPaths.skills, - collectSkillEntries(projectSkillsDir), - { source: "auto", scope: "project", origin: "top-level" }, - projectOverrides.skills, - projectBaseDir, - ); - addResources( - result.prompts, - existingPaths.prompts, - collectFiles(projectPromptsDir, FILE_PATTERNS.prompts), - { source: "auto", scope: "project", origin: "top-level" }, - projectOverrides.prompts, - projectBaseDir, - ); - addResources( - result.themes, - existingPaths.themes, - collectFiles(projectThemesDir, FILE_PATTERNS.themes), - { source: "auto", scope: "project", origin: "top-level" }, - projectOverrides.themes, - projectBaseDir, - ); - - return result; -} - /** Show TUI config selector and return when closed */ export async function selectConfig(options: ConfigSelectorOptions): Promise { // Initialize theme before showing TUI initTheme(options.settingsManager.getTheme(), true); - // Merge auto-discovered resources with package manager results - const allPaths = mergeAutoDiscoveredResources( - options.resolvedPaths, - options.settingsManager, - options.cwd, - options.agentDir, - ); - return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new ConfigSelectorComponent( - allPaths, + options.resolvedPaths, options.settingsManager, options.cwd, options.agentDir, diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 6849744f..18f21759 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -211,6 +211,192 @@ function collectSkillEntries(dir: string): string[] { return entries; } +function collectAutoSkillEntries(dir: string, isRoot = true): 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) { + const skillMd = join(fullPath, "SKILL.md"); + if (existsSync(skillMd)) { + entries.push(fullPath); + } else { + entries.push(...collectAutoSkillEntries(fullPath, false)); + } + } else if (isFile && entry.name.endsWith(".md")) { + if (isRoot || entry.name === "SKILL.md") { + entries.push(fullPath); + } + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoPromptEntries(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 isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + if (isFile && entry.name.endsWith(".md")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoThemeEntries(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 isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + if (isFile && entry.name.endsWith(".json")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function readPiManifestFile(packageJsonPath: string): PiManifest | null { + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { pi?: PiManifest }; + return pkg.pi ?? null; + } catch { + return null; + } +} + +function resolveExtensionEntries(dir: string): string[] | null { + const packageJsonPath = join(dir, "package.json"); + if (existsSync(packageJsonPath)) { + const manifest = readPiManifestFile(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = resolve(dir, extPath); + if (existsSync(resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + const indexTs = join(dir, "index.ts"); + const indexJs = join(dir, "index.js"); + if (existsSync(indexTs)) { + return [indexTs]; + } + if (existsSync(indexJs)) { + return [indexJs]; + } + + return null; +} + +function collectAutoExtensionEntries(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 (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + entries.push(fullPath); + } else if (isDir) { + const resolvedEntries = resolveExtensionEntries(fullPath); + if (resolvedEntries) { + entries.push(...resolvedEntries); + } + } + } + } catch { + // Ignore errors + } + + return entries; +} + function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { const rel = relative(baseDir, filePath); const name = basename(filePath); @@ -235,6 +421,29 @@ function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: s }); } +function getOverridePatterns(entries: string[]): string[] { + return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-")); +} + +function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean { + const overrides = getOverridePatterns(patterns); + 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; +} + /** * Apply patterns to paths and return a Set of enabled paths. * Pattern types: @@ -395,6 +604,8 @@ export class DefaultPackageManager implements PackageManager { ); } + this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); + return this.toResolvedPaths(accumulator); } @@ -1048,6 +1259,125 @@ export class DefaultPackageManager implements PackageManager { } } + private addAutoDiscoveredResources( + accumulator: ResourceAccumulator, + globalSettings: ReturnType, + projectSettings: ReturnType, + globalBaseDir: string, + projectBaseDir: string, + ): void { + const userMetadata: PathMetadata = { + source: "auto", + scope: "user", + origin: "top-level", + baseDir: globalBaseDir, + }; + const projectMetadata: PathMetadata = { + source: "auto", + scope: "project", + origin: "top-level", + baseDir: projectBaseDir, + }; + + const userOverrides = { + extensions: (globalSettings.extensions ?? []) as string[], + skills: (globalSettings.skills ?? []) as string[], + prompts: (globalSettings.prompts ?? []) as string[], + themes: (globalSettings.themes ?? []) as string[], + }; + const projectOverrides = { + extensions: (projectSettings.extensions ?? []) as string[], + skills: (projectSettings.skills ?? []) as string[], + prompts: (projectSettings.prompts ?? []) as string[], + themes: (projectSettings.themes ?? []) as string[], + }; + + const userDirs = { + extensions: join(globalBaseDir, "extensions"), + skills: join(globalBaseDir, "skills"), + prompts: join(globalBaseDir, "prompts"), + themes: join(globalBaseDir, "themes"), + }; + const projectDirs = { + extensions: join(projectBaseDir, "extensions"), + skills: join(projectBaseDir, "skills"), + prompts: join(projectBaseDir, "prompts"), + themes: join(projectBaseDir, "themes"), + }; + + const addResources = ( + resourceType: ResourceType, + paths: string[], + metadata: PathMetadata, + overrides: string[], + baseDir: string, + ) => { + const target = this.getTargetMap(accumulator, resourceType); + for (const path of paths) { + const enabled = isEnabledByOverrides(path, overrides, baseDir); + this.addResource(target, path, metadata, enabled); + } + }; + + addResources( + "extensions", + collectAutoExtensionEntries(userDirs.extensions), + userMetadata, + userOverrides.extensions, + globalBaseDir, + ); + addResources( + "skills", + collectAutoSkillEntries(userDirs.skills), + userMetadata, + userOverrides.skills, + globalBaseDir, + ); + addResources( + "prompts", + collectAutoPromptEntries(userDirs.prompts), + userMetadata, + userOverrides.prompts, + globalBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(userDirs.themes), + userMetadata, + userOverrides.themes, + globalBaseDir, + ); + + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } + private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] { const files: string[] = []; for (const p of paths) { diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 262d2319..57512533 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { homedir } from "os"; -import { basename, isAbsolute, join, resolve } from "path"; +import { basename, isAbsolute, join, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; @@ -181,6 +181,8 @@ export interface LoadPromptTemplatesOptions { agentDir?: string; /** Explicit prompt template paths (files or directories) */ promptPaths?: string[]; + /** Include default prompt directories. Default: true */ + includeDefaults?: boolean; } function normalizePath(input: string): string { @@ -211,17 +213,44 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P const resolvedCwd = options.cwd ?? process.cwd(); const resolvedAgentDir = options.agentDir ?? getPromptsDir(); const promptPaths = options.promptPaths ?? []; + const includeDefaults = options.includeDefaults ?? true; const templates: PromptTemplate[] = []; - // 1. Load global templates from agentDir/prompts/ - // Note: if agentDir is provided, it should be the agent dir, not the prompts dir - const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; - templates.push(...loadTemplatesFromDir(globalPromptsDir, "user", "(user)")); + if (includeDefaults) { + // 1. Load global templates from agentDir/prompts/ + // Note: if agentDir is provided, it should be the agent dir, not the prompts dir + const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + templates.push(...loadTemplatesFromDir(globalPromptsDir, "user", "(user)")); - // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ + // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ + const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + templates.push(...loadTemplatesFromDir(projectPromptsDir, "project", "(project)")); + } + + const userPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); - templates.push(...loadTemplatesFromDir(projectPromptsDir, "project", "(project)")); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSourceInfo = (resolvedPath: string): { source: string; label: string } => { + if (!includeDefaults) { + if (isUnderPath(resolvedPath, userPromptsDir)) { + return { source: "user", label: "(user)" }; + } + if (isUnderPath(resolvedPath, projectPromptsDir)) { + return { source: "project", label: "(project)" }; + } + } + return { source: "path", label: buildPathSourceLabel(resolvedPath) }; + }; // 3. Load explicit prompt paths for (const rawPath of promptPaths) { @@ -232,10 +261,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P try { const stats = statSync(resolvedPath); + const { source, label } = getSourceInfo(resolvedPath); if (stats.isDirectory()) { - templates.push(...loadTemplatesFromDir(resolvedPath, "path", buildPathSourceLabel(resolvedPath))); + templates.push(...loadTemplatesFromDir(resolvedPath, source, label)); } else if (stats.isFile() && resolvedPath.endsWith(".md")) { - const template = loadTemplateFromFile(resolvedPath, "path", buildPathSourceLabel(resolvedPath)); + const template = loadTemplateFromFile(resolvedPath, source, label); if (template) { templates.push(template); } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 47ca6bf1..02560733 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -9,12 +9,7 @@ import type { ResourceDiagnostic } from "./diagnostics.js"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js"; import { createEventBus, type EventBus } from "./event-bus.js"; -import { - createExtensionRuntime, - discoverAndLoadExtensions, - loadExtensionFromFactory, - loadExtensions, -} from "./extensions/loader.js"; +import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.js"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js"; import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; import type { PromptTemplate } from "./prompt-templates.js"; @@ -312,12 +307,7 @@ export class DefaultResourceLoader implements ResourceLoader { ? cliEnabledExtensions : this.mergePaths(enabledExtensions, cliEnabledExtensions); - let extensionsResult: LoadExtensionsResult; - if (this.noExtensions) { - extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); - } else { - extensionsResult = await discoverAndLoadExtensions(extensionPaths, this.cwd, this.agentDir, this.eventBus); - } + const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); @@ -346,6 +336,7 @@ export class DefaultResourceLoader implements ResourceLoader { cwd: this.cwd, agentDir: this.agentDir, skillPaths, + includeDefaults: false, }); } const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; @@ -367,6 +358,7 @@ export class DefaultResourceLoader implements ResourceLoader { cwd: this.cwd, agentDir: this.agentDir, promptPaths, + includeDefaults: false, }); promptsResult = this.dedupePrompts(allPrompts); } @@ -385,7 +377,7 @@ export class DefaultResourceLoader implements ResourceLoader { if (this.noThemes && themePaths.length === 0) { themesResult = { themes: [], diagnostics: [] }; } else { - const loaded = this.loadThemes(themePaths); + const loaded = this.loadThemes(themePaths, false); const deduped = this.dedupeThemes(loaded.themes); themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; } @@ -447,13 +439,21 @@ export class DefaultResourceLoader implements ResourceLoader { return resolve(this.cwd, expanded); } - private loadThemes(paths: string[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + private loadThemes( + paths: string[], + includeDefaults: boolean = true, + ): { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + } { const themes: Theme[] = []; const diagnostics: ResourceDiagnostic[] = []; - const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")]; + if (includeDefaults) { + const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")]; - for (const dir of defaultDirs) { - this.loadThemesFromDir(dir, themes, diagnostics); + for (const dir of defaultDirs) { + this.loadThemesFromDir(dir, themes, diagnostics); + } } for (const p of paths) { diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index fbe74839..f0dc8d45 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs"; import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, resolve } from "path"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import type { ResourceDiagnostic } from "./diagnostics.js"; @@ -296,6 +296,8 @@ export interface LoadSkillsOptions { agentDir?: string; /** Explicit skill paths (files or directories) */ skillPaths?: string[]; + /** Include default skills directories. Default: true */ + includeDefaults?: boolean; } function normalizePath(input: string): string { @@ -316,7 +318,7 @@ function resolveSkillPath(p: string, cwd: string): string { * Returns skills and any validation diagnostics. */ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { - const { cwd = process.cwd(), agentDir, skillPaths = [] } = options; + const { cwd = process.cwd(), agentDir, skillPaths = [], includeDefaults = true } = options; // Resolve agentDir - if not provided, use default from config const resolvedAgentDir = agentDir ?? getAgentDir(); @@ -362,8 +364,30 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { } } - addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); - addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); + if (includeDefaults) { + addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); + addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); + } + + const userSkillsDir = join(resolvedAgentDir, "skills"); + const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSource = (resolvedPath: string): "user" | "project" | "path" => { + if (!includeDefaults) { + if (isUnderPath(resolvedPath, userSkillsDir)) return "user"; + if (isUnderPath(resolvedPath, projectSkillsDir)) return "project"; + } + return "path"; + }; for (const rawPath of skillPaths) { const resolvedPath = resolveSkillPath(rawPath, cwd); @@ -374,10 +398,11 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { try { const stats = statSync(resolvedPath); + const source = getSource(resolvedPath); if (stats.isDirectory()) { - addSkills(loadSkillsFromDirInternal(resolvedPath, "path", true)); + addSkills(loadSkillsFromDirInternal(resolvedPath, source, true)); } else if (stats.isFile() && resolvedPath.endsWith(".md")) { - const result = loadSkillFromFile(resolvedPath, "path"); + const result = loadSkillFromFile(resolvedPath, source); if (result.skill) { addSkills({ skills: [result.skill], diagnostics: result.diagnostics }); } else { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 003c9762..3977aaa3 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -86,6 +86,30 @@ Content`, const result = await packageManager.resolve(); expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); }); + + it("should auto-discover user prompts with overrides", async () => { + const promptsDir = join(agentDir, "prompts"); + mkdirSync(promptsDir, { recursive: true }); + const promptPath = join(promptsDir, "auto.md"); + writeFileSync(promptPath, "Auto prompt"); + + settingsManager.setPromptTemplatePaths(["!prompts/auto.md"]); + + const result = await packageManager.resolve(); + expect(result.prompts.some((r) => r.path === promptPath && !r.enabled)).toBe(true); + }); + + it("should auto-discover project prompts with overrides", async () => { + const promptsDir = join(tempDir, ".pi", "prompts"); + mkdirSync(promptsDir, { recursive: true }); + const promptPath = join(promptsDir, "is.md"); + writeFileSync(promptPath, "Is prompt"); + + settingsManager.setProjectPromptTemplatePaths(["!prompts/is.md"]); + + const result = await packageManager.resolve(); + expect(result.prompts.some((r) => r.path === promptPath && !r.enabled)).toBe(true); + }); }); describe("resolveExtensionSources", () => {