mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
feat(coding-agent): add pi config TUI to manage resources
Adds a new 'pi config' command with a TUI to list and toggle package resources (extensions, skills, prompts, themes). - Displays resources grouped by source (packages, user, project) - Subgroups by resource type (Extensions, Skills, Prompts, Themes) - Toggle enabled/disabled state with space - Filter resources by typing - Supports +pattern for force-include, !pattern for exclude - Properly reads exclusion patterns from settings.json fixes #938
This commit is contained in:
parent
ef5149fdf6
commit
150128fd2c
5 changed files with 977 additions and 0 deletions
|
|
@ -184,6 +184,7 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} update [source] Update installed extensions (skips pinned sources)
|
||||
${APP_NAME} list List installed extensions from settings
|
||||
${APP_NAME} config Open TUI to enable/disable package resources
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--provider <name> Provider name (default: google)
|
||||
|
|
|
|||
353
packages/coding-agent/src/cli/config-selector.ts
Normal file
353
packages/coding-agent/src/cli/config-selector.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* 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<ResourceType, RegExp> = {
|
||||
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<string>,
|
||||
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<void> {
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue