import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import chalk from "chalk"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js"; import { createEventBus, type EventBus } from "./event-bus.js"; import { createExtensionRuntime, discoverAndLoadExtensions, 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"; import { loadPromptTemplates } from "./prompt-templates.js"; import { SettingsManager } from "./settings-manager.js"; import type { Skill, SkillWarning } from "./skills.js"; import { loadSkills } from "./skills.js"; export interface ResourceCollision { resourceType: "extension" | "skill" | "prompt" | "theme"; name: string; // skill name, command/tool/flag name, prompt name, theme name winnerPath: string; loserPath: string; winnerSource?: string; // e.g., "npm:foo", "git:...", "local" loserSource?: string; } export interface ResourceDiagnostic { type: "warning" | "error" | "collision"; message: string; path?: string; collision?: ResourceCollision; } export interface ResourceLoader { getExtensions(): LoadExtensionsResult; getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; getPathMetadata(): Map; reload(): Promise; } function resolvePromptInput(input: string | undefined, description: string): string | undefined { if (!input) { return undefined; } if (existsSync(input)) { try { return readFileSync(input, "utf-8"); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; } } return input; } function loadContextFileFromDir(dir: string): { path: string; content: string } | null { const candidates = ["AGENTS.md", "CLAUDE.md"]; for (const filename of candidates) { const filePath = join(dir, filename); if (existsSync(filePath)) { try { return { path: filePath, content: readFileSync(filePath, "utf-8"), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); } } } return null; } function loadProjectContextFiles( options: { cwd?: string; agentDir?: string } = {}, ): Array<{ path: string; content: string }> { const resolvedCwd = options.cwd ?? process.cwd(); const resolvedAgentDir = options.agentDir ?? getAgentDir(); const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); const globalContext = loadContextFileFromDir(resolvedAgentDir); if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path); } const ancestorContextFiles: Array<{ path: string; content: string }> = []; let currentDir = resolvedCwd; const root = resolve("/"); while (true) { const contextFile = loadContextFileFromDir(currentDir); if (contextFile && !seenPaths.has(contextFile.path)) { ancestorContextFiles.unshift(contextFile); seenPaths.add(contextFile.path); } if (currentDir === root) break; const parentDir = resolve(currentDir, ".."); if (parentDir === currentDir) break; currentDir = parentDir; } contextFiles.push(...ancestorContextFiles); return contextFiles; } export interface DefaultResourceLoaderOptions { cwd?: string; agentDir?: string; settingsManager?: SettingsManager; eventBus?: EventBus; additionalExtensionPaths?: string[]; additionalSkillPaths?: string[]; additionalPromptTemplatePaths?: string[]; additionalThemePaths?: string[]; extensionFactories?: ExtensionFactory[]; noExtensions?: boolean; noSkills?: boolean; noPromptTemplates?: boolean; noThemes?: boolean; systemPrompt?: string; appendSystemPrompt?: string; extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skills: Skill[]; diagnostics: ResourceDiagnostic[]; }; promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[]; }; themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { themes: Theme[]; diagnostics: ResourceDiagnostic[]; }; agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; systemPromptOverride?: (base: string | undefined) => string | undefined; appendSystemPromptOverride?: (base: string[]) => string[]; } export class DefaultResourceLoader implements ResourceLoader { private cwd: string; private agentDir: string; private settingsManager: SettingsManager; private eventBus: EventBus; private packageManager: DefaultPackageManager; private additionalExtensionPaths: string[]; private additionalSkillPaths: string[]; private additionalPromptTemplatePaths: string[]; private additionalThemePaths: string[]; private extensionFactories: ExtensionFactory[]; private noExtensions: boolean; private noSkills: boolean; private noPromptTemplates: boolean; private noThemes: boolean; private systemPromptSource?: string; private appendSystemPromptSource?: string; private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skills: Skill[]; diagnostics: ResourceDiagnostic[]; }; private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[]; }; private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { themes: Theme[]; diagnostics: ResourceDiagnostic[]; }; private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; private systemPromptOverride?: (base: string | undefined) => string | undefined; private appendSystemPromptOverride?: (base: string[]) => string[]; private extensionsResult: LoadExtensionsResult; private skills: Skill[]; private skillDiagnostics: ResourceDiagnostic[]; private prompts: PromptTemplate[]; private promptDiagnostics: ResourceDiagnostic[]; private themes: Theme[]; private themeDiagnostics: ResourceDiagnostic[]; private agentsFiles: Array<{ path: string; content: string }>; private systemPrompt?: string; private appendSystemPrompt: string[]; private pathMetadata: Map; constructor(options: DefaultResourceLoaderOptions) { this.cwd = options.cwd ?? process.cwd(); this.agentDir = options.agentDir ?? getAgentDir(); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.eventBus = options.eventBus ?? createEventBus(); this.packageManager = new DefaultPackageManager({ cwd: this.cwd, agentDir: this.agentDir, settingsManager: this.settingsManager, }); this.additionalExtensionPaths = options.additionalExtensionPaths ?? []; this.additionalSkillPaths = options.additionalSkillPaths ?? []; this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? []; this.additionalThemePaths = options.additionalThemePaths ?? []; this.extensionFactories = options.extensionFactories ?? []; this.noExtensions = options.noExtensions ?? false; this.noSkills = options.noSkills ?? false; this.noPromptTemplates = options.noPromptTemplates ?? false; this.noThemes = options.noThemes ?? false; this.systemPromptSource = options.systemPrompt; this.appendSystemPromptSource = options.appendSystemPrompt; this.extensionsOverride = options.extensionsOverride; this.skillsOverride = options.skillsOverride; this.promptsOverride = options.promptsOverride; this.themesOverride = options.themesOverride; this.agentsFilesOverride = options.agentsFilesOverride; this.systemPromptOverride = options.systemPromptOverride; this.appendSystemPromptOverride = options.appendSystemPromptOverride; this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; this.skills = []; this.skillDiagnostics = []; this.prompts = []; this.promptDiagnostics = []; this.themes = []; this.themeDiagnostics = []; this.agentsFiles = []; this.appendSystemPrompt = []; this.pathMetadata = new Map(); } getExtensions(): LoadExtensionsResult { return this.extensionsResult; } getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { return { skills: this.skills, diagnostics: this.skillDiagnostics }; } getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { return { prompts: this.prompts, diagnostics: this.promptDiagnostics }; } getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { return { themes: this.themes, diagnostics: this.themeDiagnostics }; } getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } { return { agentsFiles: this.agentsFiles }; } getSystemPrompt(): string | undefined { return this.systemPrompt; } getAppendSystemPrompt(): string[] { return this.appendSystemPrompt; } getPathMetadata(): Map { return this.pathMetadata; } async reload(): Promise { const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); // Store metadata from resolved paths this.pathMetadata = new Map(resolvedPaths.metadata); // Add CLI paths metadata for (const p of cliExtensionPaths.extensions) { if (!this.pathMetadata.has(p)) { this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" }); } } for (const p of cliExtensionPaths.skills) { if (!this.pathMetadata.has(p)) { this.pathMetadata.set(p, { source: "cli", scope: "temporary", origin: "top-level" }); } } const extensionPaths = this.noExtensions ? cliExtensionPaths.extensions : this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions); 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 inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); // Detect extension conflicts (tools, commands, flags with same names from different extensions) const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); if (conflicts.length > 0) { const conflictingPaths = new Set(conflicts.map((c) => c.path)); extensionsResult.extensions = extensionsResult.extensions.filter((ext) => !conflictingPaths.has(ext.path)); for (const conflict of conflicts) { extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); } } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; const skillPaths = this.noSkills ? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths) : this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths); let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; if (this.noSkills && skillPaths.length === 0) { skillsResult = { skills: [], diagnostics: [] }; } else { const result = loadSkills({ cwd: this.cwd, agentDir: this.agentDir, skillPaths, }); skillsResult = { skills: result.skills, diagnostics: this.skillWarningsToDiagnostics(result.warnings) }; } const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; this.skills = resolvedSkills.skills; this.skillDiagnostics = resolvedSkills.diagnostics; const promptPaths = this.noPromptTemplates ? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths) : this.mergePaths( [...resolvedPaths.prompts, ...cliExtensionPaths.prompts], this.additionalPromptTemplatePaths, ); let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; if (this.noPromptTemplates && promptPaths.length === 0) { promptsResult = { prompts: [], diagnostics: [] }; } else { const allPrompts = loadPromptTemplates({ cwd: this.cwd, agentDir: this.agentDir, promptPaths, }); promptsResult = this.dedupePrompts(allPrompts); } const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; this.prompts = resolvedPrompts.prompts; this.promptDiagnostics = resolvedPrompts.diagnostics; const themePaths = this.noThemes ? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths) : this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths); let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; if (this.noThemes && themePaths.length === 0) { themesResult = { themes: [], diagnostics: [] }; } else { const loaded = this.loadThemes(themePaths); const deduped = this.dedupeThemes(loaded.themes); themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; } const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; this.themes = resolvedThemes.themes; this.themeDiagnostics = resolvedThemes.diagnostics; const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; const baseSystemPrompt = resolvePromptInput( this.systemPromptSource ?? this.discoverSystemPromptFile(), "system prompt", ); this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt; const appendSource = this.appendSystemPromptSource ?? this.discoverAppendSystemPromptFile(); const resolvedAppend = resolvePromptInput(appendSource, "append system prompt"); const baseAppend = resolvedAppend ? [resolvedAppend] : []; this.appendSystemPrompt = this.appendSystemPromptOverride ? this.appendSystemPromptOverride(baseAppend) : baseAppend; } private mergePaths(primary: string[], additional: string[]): string[] { const merged: string[] = []; const seen = new Set(); for (const p of [...primary, ...additional]) { const resolved = this.resolveResourcePath(p); if (seen.has(resolved)) continue; seen.add(resolved); merged.push(resolved); } return merged; } private resolveResourcePath(p: string): string { const trimmed = p.trim(); let expanded = trimmed; if (trimmed === "~") { expanded = homedir(); } else if (trimmed.startsWith("~/")) { expanded = join(homedir(), trimmed.slice(2)); } else if (trimmed.startsWith("~")) { expanded = join(homedir(), trimmed.slice(1)); } return resolve(this.cwd, expanded); } private loadThemes(paths: string[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { const themes: Theme[] = []; const diagnostics: ResourceDiagnostic[] = []; 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 p of paths) { const resolved = resolve(this.cwd, p); if (!existsSync(resolved)) { diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); continue; } try { const stats = statSync(resolved); if (stats.isDirectory()) { this.loadThemesFromDir(resolved, themes, diagnostics); } else if (stats.isFile() && resolved.endsWith(".json")) { this.loadThemeFromFile(resolved, themes, diagnostics); } else { diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved }); } } catch (error) { const message = error instanceof Error ? error.message : "failed to read theme path"; diagnostics.push({ type: "warning", message, path: resolved }); } } return { themes, diagnostics }; } private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void { if (!existsSync(dir)) { return; } try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(join(dir, entry.name)).isFile(); } catch { continue; } } if (!isFile) { continue; } if (!entry.name.endsWith(".json")) { continue; } this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics); } } catch (error) { const message = error instanceof Error ? error.message : "failed to read theme directory"; diagnostics.push({ type: "warning", message, path: dir }); } } private loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void { try { themes.push(loadThemeFromPath(filePath)); } catch (error) { const message = error instanceof Error ? error.message : "failed to load theme"; diagnostics.push({ type: "warning", message, path: filePath }); } } private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{ extensions: Extension[]; errors: Array<{ path: string; error: string }>; }> { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; for (const [index, factory] of this.extensionFactories.entries()) { const extensionPath = ``; try { const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath); extensions.push(extension); } catch (error) { const message = error instanceof Error ? error.message : "failed to load extension"; errors.push({ path: extensionPath, error: message }); } } return { extensions, errors }; } private skillWarningsToDiagnostics(warnings: SkillWarning[]): ResourceDiagnostic[] { return warnings.map((w) => { // If it's a name collision, create proper collision structure if (w.collisionName && w.collisionWinner) { return { type: "collision" as const, message: w.message, path: w.skillPath, collision: { resourceType: "skill" as const, name: w.collisionName, winnerPath: w.collisionWinner, loserPath: w.skillPath, }, }; } return { type: "warning" as const, message: w.message, path: w.skillPath, }; }); } private dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { const seen = new Map(); const diagnostics: ResourceDiagnostic[] = []; for (const prompt of prompts) { const existing = seen.get(prompt.name); if (existing) { diagnostics.push({ type: "collision", message: `name "/${prompt.name}" collision`, path: prompt.filePath, collision: { resourceType: "prompt", name: prompt.name, winnerPath: existing.filePath, loserPath: prompt.filePath, }, }); } else { seen.set(prompt.name, prompt); } } return { prompts: Array.from(seen.values()), diagnostics }; } private dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { const seen = new Map(); const diagnostics: ResourceDiagnostic[] = []; for (const t of themes) { const name = t.name ?? "unnamed"; const existing = seen.get(name); if (existing) { diagnostics.push({ type: "collision", message: `name "${name}" collision`, path: t.sourcePath, collision: { resourceType: "theme", name, winnerPath: existing.sourcePath ?? "", loserPath: t.sourcePath ?? "", }, }); } else { seen.set(name, t); } } return { themes: Array.from(seen.values()), diagnostics }; } private discoverSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); if (existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined; } private discoverAppendSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); if (existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined; } private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> { const conflicts: Array<{ path: string; message: string }> = []; // Track which extension registered each tool, command, and flag const toolOwners = new Map(); const commandOwners = new Map(); const flagOwners = new Map(); for (const ext of extensions) { // Check tools for (const toolName of ext.tools.keys()) { const existingOwner = toolOwners.get(toolName); if (existingOwner && existingOwner !== ext.path) { conflicts.push({ path: ext.path, message: `Tool "${toolName}" conflicts with ${existingOwner}`, }); } else { toolOwners.set(toolName, ext.path); } } // Check commands for (const commandName of ext.commands.keys()) { const existingOwner = commandOwners.get(commandName); if (existingOwner && existingOwner !== ext.path) { conflicts.push({ path: ext.path, message: `Command "/${commandName}" conflicts with ${existingOwner}`, }); } else { commandOwners.set(commandName, ext.path); } } // Check flags for (const flagName of ext.flags.keys()) { const existingOwner = flagOwners.get(flagName); if (existingOwner && existingOwner !== ext.path) { conflicts.push({ path: ext.path, message: `Flag "--${flagName}" conflicts with ${existingOwner}`, }); } else { flagOwners.set(flagName, ext.path); } } } return conflicts; } }