diff --git a/apps/server/src/lib/agent-discovery.ts b/apps/server/src/lib/agent-discovery.ts index d94c87d3..b831bdec 100644 --- a/apps/server/src/lib/agent-discovery.ts +++ b/apps/server/src/lib/agent-discovery.ts @@ -24,7 +24,7 @@ export interface FilesystemAgent { } /** - * Parse agent .md file frontmatter and content + * Parse agent content string into AgentDefinition * Format: * --- * name: agent-name # Optional @@ -34,61 +34,128 @@ export interface FilesystemAgent { * --- * System prompt content here... */ -async function parseAgentFile( - filePath: string, - isSystemPath: boolean -): Promise { - try { - const content = isSystemPath - ? ((await systemPaths.systemPathReadFile(filePath, 'utf-8')) as string) - : ((await secureFs.readFile(filePath, 'utf-8')) as string); +function parseAgentContent(content: string, filePath: string): AgentDefinition | null { + // Extract frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!frontmatterMatch) { + logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`); + return null; + } - // Extract frontmatter - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); - if (!frontmatterMatch) { - logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`); - return null; - } + const [, frontmatter, prompt] = frontmatterMatch; - const [, frontmatter, prompt] = frontmatterMatch; + // Parse description (required) + const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim(); + if (!description) { + logger.warn(`Missing description in agent file: ${filePath}`); + return null; + } - // Parse description (required) - const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim(); - if (!description) { - logger.warn(`Missing description in agent file: ${filePath}`); - return null; - } + // Parse tools (optional) - supports both comma-separated and space-separated + const toolsMatch = frontmatter.match(/tools:\s*(.+)/); + const tools = toolsMatch + ? toolsMatch[1] + .split(/[,\s]+/) // Split by comma or whitespace + .map((t) => t.trim()) + .filter((t) => t && t !== '') + : undefined; - // Parse tools (optional) - supports both comma-separated and space-separated - const toolsMatch = frontmatter.match(/tools:\s*(.+)/); - const tools = toolsMatch - ? toolsMatch[1] - .split(/[,\s]+/) // Split by comma or whitespace - .map((t) => t.trim()) - .filter((t) => t && t !== '') + // Parse model (optional) - validate against allowed values + const modelMatch = frontmatter.match(/model:\s*(\w+)/); + const modelValue = modelMatch?.[1]?.trim(); + const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const; + const model = + modelValue && validModels.includes(modelValue as (typeof validModels)[number]) + ? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit') : undefined; - // Parse model (optional) - validate against allowed values - const modelMatch = frontmatter.match(/model:\s*(\w+)/); - const modelValue = modelMatch?.[1]?.trim(); - const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const; - const model = - modelValue && validModels.includes(modelValue as (typeof validModels)[number]) - ? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit') - : undefined; + if (modelValue && !model) { + logger.warn( + `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}` + ); + } - if (modelValue && !model) { - logger.warn( - `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}` - ); - } + return { + description, + prompt: prompt.trim(), + tools, + model, + }; +} - return { - description, - prompt: prompt.trim(), - tools, - model, - }; +/** + * Directory entry with type information + */ +interface DirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; +} + +/** + * Filesystem adapter interface for abstracting systemPaths vs secureFs + */ +interface FsAdapter { + exists: (filePath: string) => Promise; + readdir: (dirPath: string) => Promise; + readFile: (filePath: string) => Promise; +} + +/** + * Create a filesystem adapter for system paths (user directory) + */ +function createSystemPathAdapter(): FsAdapter { + return { + exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)), + readdir: async (dirPath) => { + const entryNames = await systemPaths.systemPathReaddir(dirPath); + const entries: DirEntry[] = []; + for (const name of entryNames) { + const stat = await systemPaths.systemPathStat(path.join(dirPath, name)); + entries.push({ + name, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + }); + } + return entries; + }, + readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Create a filesystem adapter for project paths (secureFs) + */ +function createSecureFsAdapter(): FsAdapter { + return { + exists: (filePath) => + secureFs + .access(filePath) + .then(() => true) + .catch(() => false), + readdir: async (dirPath) => { + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + })); + }, + readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Parse agent file using the provided filesystem adapter + */ +async function parseAgentFileWithAdapter( + filePath: string, + fsAdapter: FsAdapter +): Promise { + try { + const content = await fsAdapter.readFile(filePath); + return parseAgentContent(content, filePath); } catch (error) { logger.error(`Failed to parse agent file: ${filePath}`, error); return null; @@ -106,103 +173,50 @@ async function scanAgentsDirectory( source: 'user' | 'project' ): Promise { const agents: FilesystemAgent[] = []; - const isSystemPath = source === 'user'; // User directories use systemPaths + const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter(); try { // Check if directory exists - const exists = isSystemPath - ? await systemPaths.systemPathExists(baseDir) - : await secureFs - .access(baseDir) - .then(() => true) - .catch(() => false); - + const exists = await fsAdapter.exists(baseDir); if (!exists) { logger.debug(`Directory does not exist: ${baseDir}`); return agents; } // Read all entries in the directory - if (isSystemPath) { - // For system paths (user directory) - const entryNames = await systemPaths.systemPathReaddir(baseDir); - for (const entryName of entryNames) { - const entryPath = path.join(baseDir, entryName); - const stat = await systemPaths.systemPathStat(entryPath); + const entries = await fsAdapter.readdir(baseDir); - // Check for flat .md file format (agent-name.md) - if (stat.isFile() && entryName.endsWith('.md')) { - const agentName = entryName.slice(0, -3); // Remove .md extension - const definition = await parseAgentFile(entryPath, true); - if (definition) { - agents.push({ - name: agentName, - definition, - source, - filePath: entryPath, - }); - logger.debug(`Discovered ${source} agent (flat): ${agentName}`); - } - } - // Check for subdirectory format (agent-name/AGENT.md) - else if (stat.isDirectory()) { - const agentFilePath = path.join(entryPath, 'AGENT.md'); - const agentFileExists = await systemPaths.systemPathExists(agentFilePath); - - if (agentFileExists) { - const definition = await parseAgentFile(agentFilePath, true); - if (definition) { - agents.push({ - name: entryName, - definition, - source, - filePath: agentFilePath, - }); - logger.debug(`Discovered ${source} agent (subdirectory): ${entryName}`); - } - } + for (const entry of entries) { + // Check for flat .md file format (agent-name.md) + if (entry.isFile && entry.name.endsWith('.md')) { + const agentName = entry.name.slice(0, -3); // Remove .md extension + const agentFilePath = path.join(baseDir, entry.name); + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); + if (definition) { + agents.push({ + name: agentName, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (flat): ${agentName}`); } } - } else { - // For project paths (use secureFs) - const entries = await secureFs.readdir(baseDir, { withFileTypes: true }); - for (const entry of entries) { - // Check for flat .md file format (agent-name.md) - if (entry.isFile() && entry.name.endsWith('.md')) { - const agentName = entry.name.slice(0, -3); // Remove .md extension - const agentFilePath = path.join(baseDir, entry.name); - const definition = await parseAgentFile(agentFilePath, false); + // Check for subdirectory format (agent-name/AGENT.md) + else if (entry.isDirectory) { + const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md'); + const agentFileExists = await fsAdapter.exists(agentFilePath); + + if (agentFileExists) { + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); if (definition) { agents.push({ - name: agentName, + name: entry.name, definition, source, filePath: agentFilePath, }); - logger.debug(`Discovered ${source} agent (flat): ${agentName}`); - } - } - // Check for subdirectory format (agent-name/AGENT.md) - else if (entry.isDirectory()) { - const agentDir = path.join(baseDir, entry.name); - const agentFilePath = path.join(agentDir, 'AGENT.md'); - - const agentFileExists = await secureFs - .access(agentFilePath) - .then(() => true) - .catch(() => false); - - if (agentFileExists) { - const definition = await parseAgentFile(agentFilePath, false); - if (definition) { - agents.push({ - name: entry.name, - definition, - source, - filePath: agentFilePath, - }); - logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); - } + logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); } } }