/** * TUI config selector for `pi config` command */ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; import { CONFIG_DIR_NAME } from "../config.js"; import type { PathMetadata, ResolvedPaths, ResolvedResource } 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"; export interface ConfigSelectorOptions { resolvedPaths: ResolvedPaths; settingsManager: SettingsManager; cwd: string; 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 isExcludedByPatterns(filePath: string, patterns: string[]): boolean { const name = basename(filePath); for (const pattern of patterns) { if (pattern.startsWith("!")) { const excludePattern = pattern.slice(1); // Match against basename or full path if (name === excludePattern || filePath.endsWith(excludePattern)) { return true; } } } return false; } /** * 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 exclusion patterns from settings const globalSettings = settingsManager.getGlobalSettings(); const projectSettings = settingsManager.getProjectSettings(); const userExclusions = { extensions: globalSettings.extensions ?? [], skills: globalSettings.skills ?? [], prompts: globalSettings.prompts ?? [], themes: globalSettings.themes ?? [], }; const projectExclusions = { extensions: projectSettings.extensions ?? [], skills: projectSettings.skills ?? [], prompts: projectSettings.prompts ?? [], themes: projectSettings.themes ?? [], }; const addResources = ( target: ResolvedResource[], existing: Set, paths: string[], metadata: PathMetadata, exclusions: string[], ) => { for (const path of paths) { if (!existing.has(path)) { const enabled = !isExcludedByPatterns(path, exclusions); target.push({ path, enabled, metadata }); existing.add(path); } } }; // 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" }, userExclusions.extensions, ); addResources( result.skills, existingPaths.skills, collectSkillEntries(userSkillsDir), { source: "auto", scope: "user", origin: "top-level" }, userExclusions.skills, ); addResources( result.prompts, existingPaths.prompts, collectFiles(userPromptsDir, FILE_PATTERNS.prompts), { source: "auto", scope: "user", origin: "top-level" }, userExclusions.prompts, ); addResources( result.themes, existingPaths.themes, collectFiles(userThemesDir, FILE_PATTERNS.themes), { source: "auto", scope: "user", origin: "top-level" }, userExclusions.themes, ); // 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" }, projectExclusions.extensions, ); addResources( result.skills, existingPaths.skills, collectSkillEntries(projectSkillsDir), { source: "auto", scope: "project", origin: "top-level" }, projectExclusions.skills, ); addResources( result.prompts, existingPaths.prompts, collectFiles(projectPromptsDir, FILE_PATTERNS.prompts), { source: "auto", scope: "project", origin: "top-level" }, projectExclusions.prompts, ); addResources( result.themes, existingPaths.themes, collectFiles(projectThemesDir, FILE_PATTERNS.themes), { source: "auto", scope: "project", origin: "top-level" }, projectExclusions.themes, ); 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.settingsManager, options.cwd, () => { if (!resolved) { resolved = true; ui.stop(); stopThemeWatcher(); resolve(); } }, () => { ui.stop(); stopThemeWatcher(); process.exit(0); }, () => ui.requestRender(), ); ui.addChild(selector); ui.setFocus(selector.getResourceList()); ui.start(); }); }