import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { ThinkingLevel } from "@f5-sales-demo/pi-agent-core"; import { FileType, glob } from "@f5-sales-demo/pi-natives"; import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, parseFrontmatter, tryParseJson, } from "@f5-sales-demo/pi-utils"; import { readDirEntries, readFile } from "../capability/fs"; import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule"; import type { Skill, SkillFrontmatter } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { parseThinkingLevel } from "../thinking"; import { buildPluginDirRoot } from "./plugin-dir-roots"; /** * Standard paths for each config source. */ export const SOURCE_PATHS = { native: { get userBase() { return getConfigDirName(); }, get userAgent() { return `${getConfigDirName()}/agent`; }, projectDir: CONFIG_DIR_NAME, }, codex: { userBase: ".codex", userAgent: ".codex", projectDir: ".codex", }, gemini: { userBase: ".gemini", userAgent: ".gemini", projectDir: ".gemini", }, opencode: { userBase: ".config/opencode", userAgent: ".config/opencode", projectDir: ".opencode", }, cursor: { userBase: ".cursor", userAgent: ".cursor", projectDir: ".cursor", }, windsurf: { userBase: ".codeium/windsurf", userAgent: ".codeium/windsurf", projectDir: ".windsurf", }, cline: { userBase: ".cline", userAgent: ".cline", projectDir: null, // Cline uses root-level .clinerules }, github: { userBase: null, userAgent: null, projectDir: ".github", }, vscode: { userBase: ".vscode", userAgent: ".vscode", projectDir: ".vscode", }, } as const; export type SourceId = keyof typeof SOURCE_PATHS; /** * Get user-level path for a source. */ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null { const paths = SOURCE_PATHS[source]; if (!paths.userAgent) return null; return path.join(ctx.home, paths.userAgent, subpath); } /** * Get project-level path for a source (cwd only). */ export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: string): string | null { const paths = SOURCE_PATHS[source]; if (!paths.projectDir) return null; return path.join(ctx.cwd, paths.projectDir, subpath); } /** * Create source metadata for an item. */ export function createSourceMeta(provider: string, filePath: string, level: "user" | "project"): SourceMeta { return { provider, providerName: "", // Filled in by registry path: path.resolve(filePath), level, }; } export function parseBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "true") return true; if (normalized === "false") return false; } return undefined; } /** * Parse a comma-separated string into an array of trimmed, non-empty strings. */ export function parseCSV(value: string): string[] { return value .split(",") .map(s => s.trim()) .filter(Boolean); } /** * Parse a value that may be an array of strings or a comma-separated string. * Returns undefined if the result would be empty. */ export function parseArrayOrCSV(value: unknown): string[] | undefined { if (Array.isArray(value)) { const filtered = value.filter((item): item is string => typeof item === "string"); return filtered.length > 0 ? filtered : undefined; } if (typeof value === "string") { const parsed = parseCSV(value); return parsed.length > 0 ? parsed : undefined; } return undefined; } /** * Build a canonical rule item from a markdown/markdown-frontmatter document. */ export function buildRuleFromMarkdown( name: string, content: string, filePath: string, source: SourceMeta, options?: { ruleName?: string; stripNamePattern?: RegExp; }, ): Rule { const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); let globs: string[] | undefined; if (Array.isArray(frontmatter.globs)) { globs = frontmatter.globs.filter((item): item is string => typeof item === "string"); } else if (typeof frontmatter.globs === "string") { globs = [frontmatter.globs]; } const resolvedName = options?.ruleName ?? name.replace(options?.stripNamePattern ?? /\.(md|mdc)$/, ""); const rawMode = frontmatter.interruptMode; const interruptMode: Rule["interruptMode"] = rawMode === "never" || rawMode === "prose-only" || rawMode === "tool-only" || rawMode === "always" ? rawMode : undefined; return { name: resolvedName, path: filePath, content: body, globs, alwaysApply: frontmatter.alwaysApply === true, description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, condition, scope, interruptMode, _source: source, }; } /** * Parse model field into a prioritized list. */ export function parseModelList(value: unknown): string[] | undefined { const parsed = parseArrayOrCSV(value); if (!parsed) return undefined; const normalized = parsed.map(entry => entry.trim()).filter(Boolean); return normalized.length > 0 ? normalized : undefined; } /** Parsed agent fields from frontmatter (excludes source/filePath/systemPrompt) */ export interface ParsedAgentFields { name: string; description: string; tools?: string[]; spawns?: string[] | "*"; model?: string[]; output?: unknown; thinkingLevel?: ThinkingLevel; blocking?: boolean; } /** * Parse agent fields from frontmatter. * Returns null if required fields (name, description) are missing. */ export function parseAgentFields(frontmatter: Record): ParsedAgentFields | null { const name = typeof frontmatter.name === "string" ? frontmatter.name : undefined; const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; if (!name || !description) { return null; } let tools = parseArrayOrCSV(frontmatter.tools)?.map(tool => tool.toLowerCase()); // Subagents with explicit tool lists always need submit_result if (tools && !tools.includes("submit_result")) { tools = [...tools, "submit_result"]; } // Parse spawns field (array, "*", or CSV) let spawns: string[] | "*" | undefined; if (frontmatter.spawns === "*") { spawns = "*"; } else if (typeof frontmatter.spawns === "string") { const trimmed = frontmatter.spawns.trim(); if (trimmed === "*") { spawns = "*"; } else { spawns = parseArrayOrCSV(trimmed); } } else { spawns = parseArrayOrCSV(frontmatter.spawns); } // Backward compat: infer spawns: "*" when tools includes "task" if (spawns === undefined && tools?.includes("task")) { spawns = "*"; } const output = frontmatter.output !== undefined ? frontmatter.output : undefined; const rawThinkingLevel = typeof frontmatter.thinkingLevel === "string" ? frontmatter.thinkingLevel : typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined; const thinkingLevel = parseThinkingLevel(rawThinkingLevel); const model = parseModelList(frontmatter.model); const blocking = parseBoolean(frontmatter.blocking); return { name, description, tools, spawns, model, output, thinkingLevel, blocking }; } async function globIf( dir: string, pattern: string, fileType: FileType, recursive: boolean = true, ): Promise> { try { const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType, recursive }); return result.matches; } catch { return []; } } export interface ScanSkillsFromDirOptions { dir: string; providerId: string; level: "user" | "project"; requireDescription?: boolean; } // Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path. export function compareSkillOrder(aName: string, aPath: string, bName: string, bPath: string): number { const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); const lowerCompare = cmp(aName.toLowerCase(), bName.toLowerCase()); if (lowerCompare !== 0) return lowerCompare; const nameCompare = cmp(aName, bName); if (nameCompare !== 0) return nameCompare; return cmp(aPath, bPath); } export async function scanSkillsFromDir( _ctx: LoadContext, options: ScanSkillsFromDirOptions, ): Promise> { const items: Skill[] = []; const warnings: string[] = []; const { dir, level, providerId, requireDescription = false } = options; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`); } return { items, warnings }; } const loadSkill = async (skillPath: string) => { try { const content = await readFile(skillPath); if (!content) return; const { frontmatter, body } = parseFrontmatter(content, { source: skillPath }); if (frontmatter.enabled === false) { return; } if (requireDescription && !frontmatter.description) { return; } const skillDirName = path.basename(path.dirname(skillPath)); const rawName = frontmatter.name; const name = typeof rawName === "string" ? rawName.trim() || skillDirName : skillDirName; items.push({ name, path: skillPath, content: body, frontmatter: frontmatter as SkillFrontmatter, level, _source: createSourceMeta(providerId, skillPath, level), }); } catch { warnings.push(`Failed to read skill file: ${skillPath}`); } }; const work = []; for (const entry of entries) { if (entry.name.startsWith(".")) continue; if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; const skillPath = path.join(dir, entry.name, "SKILL.md"); if (fs.existsSync(skillPath)) { work.push(loadSkill(skillPath)); } } await Promise.all(work); // Deterministic ordering: async file reads complete nondeterministically, so sort after loading. items.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path)); return { items, warnings }; } /** * Expand environment variables in a string. * Supports ${VAR} and ${VAR:-default} syntax. */ function expandEnvVars(value: string, extraEnv?: Record): string { return value.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, varName: string, defaultValue?: string) => { const envValue = extraEnv?.[varName] ?? Bun.env[varName]; if (envValue !== undefined) return envValue; if (defaultValue !== undefined) return defaultValue; return `\${${varName}}`; }); } /** * Build plugin-specific environment variables for variable substitution. * These are injected as extraEnv when loading plugin configs (hooks, MCP, LSP). */ export function buildPluginEnvVars(pluginRoot: string, pluginId: string, projectDir?: string): Record { const dataDir = path.join(os.homedir(), getConfigDirName(), "plugins", "data", pluginId.replace("@", "__")); const vars: Record = { XCSH_PLUGIN_ROOT: pluginRoot, XCSH_PLUGIN_DATA: dataDir, }; if (projectDir) { vars.XCSH_PROJECT_DIR = projectDir; } return vars; } /** * Recursively expand environment variables in an object. */ export function expandEnvVarsDeep(obj: T, extraEnv?: Record): T { if (typeof obj === "string") { return expandEnvVars(obj, extraEnv) as T; } if (Array.isArray(obj)) { return obj.map(item => expandEnvVarsDeep(item, extraEnv)) as T; } if (obj !== null && typeof obj === "object") { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { result[key] = expandEnvVarsDeep(value, extraEnv); } return result as T; } return obj; } /** * Load files from a directory matching extensions. * Uses native glob for fast filesystem scanning with gitignore support. */ export async function loadFilesFromDir( _ctx: LoadContext, dir: string, provider: string, level: "user" | "project", options: { /** File extensions to match (without dot) */ extensions?: string[]; /** Transform file to item (return null to skip) */ transform: (name: string, content: string, path: string, source: SourceMeta) => T | null; /** Whether to recurse into subdirectories (default: false) */ recursive?: boolean; }, ): Promise> { const items: T[] = []; const warnings: string[] = []; // Build glob pattern based on extensions and recursion const { extensions, recursive = false } = options; let pattern: string; if (extensions && extensions.length > 0) { const extPattern = extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`; pattern = recursive ? `**/*.${extPattern}` : `*.${extPattern}`; } else { pattern = recursive ? "**/*" : "*"; } // Use native glob for fast scanning with gitignore support let matches: Array<{ path: string }>; try { const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType: FileType.File, }); matches = result.matches; } catch { // Directory doesn't exist or isn't readable return { items, warnings }; } // Read all matching files in parallel const fileResults = await Promise.all( matches.map(async match => { const filePath = path.join(dir, match.path); const content = await readFile(filePath); return { filePath, content }; }), ); for (const { filePath, content } of fileResults) { if (content === null) { warnings.push(`Failed to read file: ${filePath}`); continue; } const name = path.basename(filePath); const source = createSourceMeta(provider, filePath, level); try { const item = options.transform(name, content, filePath, source); if (item !== null) { items.push(item); } } catch (err) { warnings.push(`Failed to parse ${filePath}: ${err}`); } } return { items, warnings }; } /** * Calculate depth of target directory relative to current working directory. * Depth is the number of directory levels from cwd to target. * - Positive depth: target is above cwd (parent/ancestor) * - Zero depth: target is cwd * - This uses path splitting to count directory levels */ export function calculateDepth(cwd: string, targetDir: string, separator: string): number { return cwd.split(separator).length - targetDir.split(separator).length; } interface ExtensionModuleManifest { extensions?: string[]; } async function readExtensionModuleManifest( _ctx: LoadContext, packageJsonPath: string, ): Promise { const content = await readFile(packageJsonPath); if (!content) return null; const pkg = tryParseJson<{ xcsh?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content); const manifest = pkg?.xcsh ?? pkg?.pi; if (manifest && typeof manifest === "object") { return manifest; } return null; } /** * Discover extension module entry points in a directory. * * Discovery rules: * 1. Direct files: `extensions/*.ts` or `*.js` → load * 2. Subdirectory with index: `extensions//index.ts` or `index.js` → load * 3. Subdirectory with package.json: `extensions//package.json` with "xcsh"/"pi" field → load declared paths * * No recursion beyond one level. Complex packages must use package.json manifest. * Uses native glob for fast filesystem scanning with gitignore support. */ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise { const discovered = new Set(); // Find all candidate files in parallel using glob const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([ // 1. Direct *.ts or *.js files globIf(dir, "*.{ts,js}", FileType.File, false), // 2. Subdirectory index files globIf(dir, "*/index.{ts,js}", FileType.File, false), // 3. Subdirectory package.json files globIf(dir, "*/package.json", FileType.File, false), ]); // Process direct files for (const match of directFiles) { if (match.path.includes("/")) continue; discovered.add(path.join(dir, match.path)); } // Track which subdirectories have package.json manifests with declared extensions const subdirsWithDeclaredExtensions = new Set(); for (const match of packageJsonFiles) { const subdir = path.dirname(match.path); // e.g., "my-extension" const packageJsonPath = path.join(dir, match.path); const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath); const declaredExtensions = manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? []; if (declaredExtensions.length === 0) continue; subdirsWithDeclaredExtensions.add(subdir); const subdirPath = path.join(dir, subdir); for (const extPath of declaredExtensions) { let resolvedExtPath = path.resolve(subdirPath, extPath); const entries = await readDirEntries(resolvedExtPath); if (entries.length !== 0) { const pluginFilePath = entries.find( e => e.isFile() && (e.name === "index.ts" || e.name === "index.js"), )?.name; resolvedExtPath = pluginFilePath ? path.join(resolvedExtPath, pluginFilePath) : resolvedExtPath; } const content = await readFile(resolvedExtPath); if (content !== null) { discovered.add(resolvedExtPath); } } } const preferredIndexBySubdir = new Map(); for (const match of indexFiles) { if (match.path.split("/").length !== 2) continue; const subdir = path.dirname(match.path); if (subdirsWithDeclaredExtensions.has(subdir)) continue; const existing = preferredIndexBySubdir.get(subdir); if (!existing || (existing.endsWith("index.js") && match.path.endsWith("index.ts"))) { preferredIndexBySubdir.set(subdir, match.path); } } for (const preferredPath of preferredIndexBySubdir.values()) { discovered.add(path.join(dir, preferredPath)); } return [...discovered]; } /** * Derive a stable extension name from a path. */ export function getExtensionNameFromPath(extensionPath: string): string { const base = extensionPath.replace(/\\/g, "/").split("/").pop() ?? extensionPath; if (base === "index.ts" || base === "index.js") { const parts = extensionPath.replace(/\\/g, "/").split("/"); const parent = parts[parts.length - 2]; return parent ?? base; } const dot = base.lastIndexOf("."); if (dot > 0) { return base.slice(0, dot); } return base; } // ============================================================================= // xcsh Plugin Cache Helpers // ============================================================================= /** * Entry for an installed xcsh plugin. */ export interface XcshPluginEntry { scope: "user" | "project"; installPath: string; version: string; installedAt: string; lastUpdated: string; gitCommitSha?: string; enabled?: boolean; } /** * xcsh installed_plugins.json registry format. */ export interface XcshPluginsRegistry { version: number; plugins: Record; } /** * Resolved plugin root for loading. */ export interface XcshPluginRoot { /** Plugin ID (e.g., "simpleclaude-core@simpleclaude") */ id: string; /** Marketplace name */ marketplace: string; /** Plugin name */ plugin: string; /** Version string */ version: string; /** Absolute path to plugin root */ path: string; /** Whether this is a user, project, or local scope plugin */ scope: "user" | "project" | "local"; } /** Map plugin scope to the loading level — "local" behaves as "project" for capability loading. */ export function scopeToLevel(scope: "user" | "project" | "local"): "user" | "project" { return scope === "local" ? "project" : scope; } /** * Parse xcsh installed_plugins.json content. */ export function parseXcshPluginsRegistry(content: string): XcshPluginsRegistry | null { const data = tryParseJson(content); if (!data || typeof data !== "object") return null; if ( typeof data.version !== "number" || !data.plugins || typeof data.plugins !== "object" || Array.isArray(data.plugins) ) return null; return data; } /** * Resolve the active project registry path by walking up from `cwd`. * * Walk order: * 1. Walk up from `cwd` looking for the nearest directory containing `.xcsh/`. * The first match returns `/.xcsh/plugins/installed_plugins.json`. * 2. If no `.xcsh/` is found, rescan from `cwd` upward looking for `.git`. * The git root is used as an anchor: `/.xcsh/plugins/installed_plugins.json`. * 3. If neither is found, return `null` — no project context is active. * * This is the single source of truth for "active project root" used by install, * uninstall, list, upgrade, discovery, and doctor. Deterministic for a given `cwd`. */ export async function resolveActiveProjectRegistryPath(cwd: string): Promise { // Pass 1: walk up looking for an existing .xcsh/ directory (nearest wins). // Stop before os.homedir() — ~/.xcsh/ is the user-level config dir, not a project root. const homeDir = os.homedir(); let dir = path.resolve(cwd); while (dir !== homeDir) { try { const stat = await fs.promises.stat(path.join(dir, getConfigDirName())); if (stat.isDirectory()) { return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json"); } } catch { // not found at this level — continue up } const parent = path.dirname(dir); if (parent === dir) break; // filesystem root dir = parent; } // Pass 2: walk up looking for .git as a fallback anchor. dir = path.resolve(cwd); while (dir !== homeDir) { try { await fs.promises.stat(path.join(dir, ".git")); return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json"); } catch { // not found at this level — continue up } const parent = path.dirname(dir); if (parent === dir) break; // filesystem root dir = parent; } return null; // not inside any project } /** * Like resolveActiveProjectRegistryPath, but falls back to `/.xcsh/plugins/installed_plugins.json` * when no project anchor (.xcsh/ or .git/) is found. * * Use this when the caller accepts an explicit --scope project so that installing into a freshly * bootstrapped directory (no .xcsh/ or .git/ yet) works: writeInstalledPluginsRegistry auto-creates * the directory tree on first write. * * Returns undefined when cwd is os.homedir() — that path is already the user registry and must * never alias as the project registry. */ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise { const resolved = await resolveActiveProjectRegistryPath(cwd); if (resolved) return resolved; // Home directory must not be treated as a project root: the fallback path would alias // getInstalledPluginsRegistryPath(), causing MarketplaceManager to load the same file // as both user and project registry and producing duplicates / disambiguation errors. if (path.resolve(cwd) === os.homedir()) return undefined; return path.join(cwd, getConfigDirName(), "plugins", "installed_plugins.json"); } const pluginRootsCache = new Map(); /** * List all installed marketplace plugin roots from the plugin cache. * Reads ~/.xcsh/plugins/installed_plugins.json and optionally the nearest * project-scoped registry resolved from `cwd`. * * Results are cached per `home:resolvedProjectPath` key to avoid repeated parsing. */ export async function listXcshPluginRoots( home: string, cwd?: string, ): Promise<{ roots: XcshPluginRoot[]; warnings: string[] }> { const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null; const cacheKey = `${home}:${resolvedProjectPath ?? ""}`; const cached = pluginRootsCache.get(cacheKey); if (cached) return cached; const roots: XcshPluginRoot[] = []; const warnings: string[] = []; const projectRoots: XcshPluginRoot[] = []; // ── Installed plugins registry ─────────────────────────────────────────── // Path derived from `home` (not os.homedir()) so test isolation works when home is overridden. const registryPath = path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"); const content = await readFile(registryPath); if (content) { const registry = parseXcshPluginsRegistry(content); if (registry) { for (const [pluginId, entries] of Object.entries(registry.plugins)) { if (!Array.isArray(entries) || entries.length === 0) continue; const atIndex = pluginId.lastIndexOf("@"); if (atIndex === -1) { warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); continue; } const pluginName = pluginId.slice(0, atIndex); const marketplace = pluginId.slice(atIndex + 1); for (const entry of entries) { if (!entry.installPath || typeof entry.installPath !== "string") { warnings.push(`Plugin ${pluginId} entry has no installPath`); continue; } if (entry.enabled === false) continue; if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue; roots.push({ id: pluginId, marketplace, plugin: pluginName, version: entry.version || "unknown", path: entry.installPath, scope: entry.scope || "user", }); } } } else { warnings.push(`Failed to parse plugin registry: ${registryPath}`); } } // ── Project-scoped registry ────────────────────────────────────────── // Loaded from the nearest .xcsh/plugins/installed_plugins.json relative to cwd. // Project entries take precedence over user entries for the same plugin ID. // Skip if the project registry is the same file as the user registry (home === cwd). if (resolvedProjectPath && resolvedProjectPath !== registryPath) { const projectContent = await readFile(resolvedProjectPath); if (projectContent) { const projectRegistry = parseXcshPluginsRegistry(projectContent); if (projectRegistry) { for (const [pluginId, entries] of Object.entries(projectRegistry.plugins)) { if (!Array.isArray(entries) || entries.length === 0) continue; const atIndex = pluginId.lastIndexOf("@"); if (atIndex === -1) { warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); continue; } const pluginName = pluginId.slice(0, atIndex); const marketplace = pluginId.slice(atIndex + 1); for (const entry of entries) { if (!entry.installPath || typeof entry.installPath !== "string") { warnings.push(`Plugin ${pluginId} entry has no installPath`); continue; } if (entry.enabled === false) continue; projectRoots.push({ id: pluginId, marketplace, plugin: pluginName, version: entry.version || "unknown", path: entry.installPath, scope: "project", }); } } } else { warnings.push(`Failed to parse project plugin registry: ${resolvedProjectPath}`); } } } // Project entries shadow user entries for the same plugin ID. if (projectRoots.length > 0) { const projectIds = new Set(projectRoots.map(r => r.id)); const deduped = roots.filter(r => !projectIds.has(r.id)); roots.length = 0; roots.push(...projectRoots, ...deduped); } // Merge --plugin-dir roots (highest precedence) on every fresh load. Local roots use // an artificial marketplace ID, so identity must be matched by manifest plugin name; // comparing full IDs would leave the installed copy active beside the candidate. if (injectedPluginDirRoots.length > 0) { const merged = prioritizeInjectedPluginRoots(roots, injectedPluginDirRoots); roots.length = 0; roots.push(...merged); } const result = { roots, warnings }; pluginRootsCache.set(cacheKey, result); return result; } export function prioritizeInjectedPluginRoots( installed: XcshPluginRoot[], injected: XcshPluginRoot[], ): XcshPluginRoot[] { const seen = new Set(); const winners = injected.filter(root => { if (seen.has(root.plugin)) return false; seen.add(root.plugin); return true; }); return [...winners, ...installed.filter(root => !seen.has(root.plugin))]; } export interface XcshPluginSummary { /** Registry id (root.plugin) — the key the `xcsh://plugin/` resolver matches on. */ id: string; /** Display name from the plugin's own manifest (falls back to the registry id). */ name: string; description: string; } /** * Sanitize a manifest/package description for single-line rendering in the system prompt. * Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated). * Defense-in-depth: the value is rendered with `noEscape:true`, so a newline would break the * single-line bullet and an oversized description would bloat the prompt. */ function sanitizePluginDescription(description: unknown): string { if (typeof description !== "string") return ""; const collapsed = description.replace(/\s+/g, " ").trim(); return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed; } /** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */ export async function readPluginSummary(root: { plugin: string; path: string }): Promise { // Precedence mirrors MarketplaceManager#resolvePluginVersion. const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [ { rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) }, { rel: "package.json", pick: m => ({ name: m?.xcsh?.name ?? m?.pi?.name, description: m?.xcsh?.description ?? m?.pi?.description }), }, ]; for (const c of candidates) { try { const m = await Bun.file(path.join(root.path, c.rel)).json(); const { name, description } = c.pick(m); if (name || description) { return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) }; } } catch { // try next candidate } } return { id: root.plugin, name: root.plugin, description: "" }; } /** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */ export async function listXcshPluginSummaries(home: string, cwd?: string): Promise { try { const { roots } = await listXcshPluginRoots(home, cwd); const seen = new Set(); const out: XcshPluginSummary[] = []; for (const r of roots) { if (seen.has(r.plugin)) continue; seen.add(r.plugin); out.push(await readPluginSummary(r)); } return out.sort((a, b) => a.name.localeCompare(b.name)); } catch { return []; } } /** * Clear the plugin roots cache (useful for testing or when plugins change). */ export function clearXcshPluginRootsCache(): void { pluginRootsCache.clear(); preloadedPluginRoots = [...injectedPluginDirRoots]; // Re-warm preloaded roots asynchronously so sync LSP config reads stay valid if (lastPreloadHome) { void preloadPluginRoots(lastPreloadHome, getProjectDir()); } } // ── Preloaded plugin roots (for sync consumers like LSP config) ───────────── // Populated at startup by preloadPluginRoots(). Read synchronously by // getPreloadedPluginRoots(). Safe degradation: empty array if not warmed. let preloadedPluginRoots: XcshPluginRoot[] = []; let injectedPluginDirRoots: XcshPluginRoot[] = []; let lastPreloadHome: string | undefined; /** * Populate the module-level plugin roots cache for sync consumers. * Call during session initialization, after dir resolution completes * but before any LSP config is read. */ export async function preloadPluginRoots(home: string, cwd?: string): Promise { lastPreloadHome = home; const { roots } = await listXcshPluginRoots(home, cwd); preloadedPluginRoots = roots; } /** * Get pre-loaded plugin roots synchronously. * Returns empty array if preloadPluginRoots() hasn't been called. */ export function getPreloadedPluginRoots(): readonly XcshPluginRoot[] { return preloadedPluginRoots; } // ── --plugin-dir injection ────────────────────────────────────────────────── /** * Inject synthetic plugin roots from --plugin-dir paths. * These are prepended to the cache with highest precedence (before OMP/xcsh entries). * Must be called before any listXcshPluginRoots() access. */ export async function injectPluginDirRoots(home: string, dirs: string[], cwd?: string): Promise { const injected: XcshPluginRoot[] = []; for (const dir of dirs) { const resolved = path.resolve(dir); // Read plugin name from manifest let pluginName = path.basename(resolved); try { const manifestPath = path.join(resolved, ".xcsh-plugin", "plugin.json"); const content = await Bun.file(manifestPath).text(); const manifest = JSON.parse(content); if (typeof manifest.name === "string" && manifest.name) { pluginName = manifest.name; } } catch { // No manifest or invalid — use directory name } injected.push(buildPluginDirRoot(resolved, pluginName)); } // Set injected roots BEFORE populating cache so listXcshPluginRoots merges them. injectedPluginDirRoots = injected; lastPreloadHome = home; // ensure cache-clear re-warm fires even when injectPluginDirRoots was the startup path // Clear any stale cache entries (populated before injected roots were set). pluginRootsCache.clear(); // Rebuild — cache miss triggers fresh load that includes both user+project registries // and prepends injectedPluginDirRoots at highest precedence. const { roots } = await listXcshPluginRoots(home, cwd); preloadedPluginRoots = roots; }