| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import path from 'path'; |
| import os from 'os'; |
| import { createLogger } from '@automaker/utils'; |
| import { secureFs, systemPaths } from '@automaker/platform'; |
| import type { AgentDefinition } from '@automaker/types'; |
|
|
| const logger = createLogger('AgentDiscovery'); |
|
|
| export interface FilesystemAgent { |
| name: string; |
| definition: AgentDefinition; |
| source: 'user' | 'project'; |
| filePath: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function parseAgentContent(content: string, filePath: string): AgentDefinition | null { |
| |
| 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 description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim(); |
| if (!description) { |
| logger.warn(`Missing description in agent file: ${filePath}`); |
| return null; |
| } |
|
|
| |
| const toolsMatch = frontmatter.match(/tools:\s*(.+)/); |
| const tools = toolsMatch |
| ? toolsMatch[1] |
| .split(/[,\s]+/) |
| .map((t) => t.trim()) |
| .filter((t) => t && t !== '') |
| : undefined; |
|
|
| |
| 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(', ')}` |
| ); |
| } |
|
|
| return { |
| description, |
| prompt: prompt.trim(), |
| tools, |
| model, |
| }; |
| } |
|
|
| |
| |
| |
| interface DirEntry { |
| name: string; |
| isFile: boolean; |
| isDirectory: boolean; |
| } |
|
|
| |
| |
| |
| interface FsAdapter { |
| exists: (filePath: string) => Promise<boolean>; |
| readdir: (dirPath: string) => Promise<DirEntry[]>; |
| readFile: (filePath: string) => Promise<string>; |
| } |
|
|
| |
| |
| |
| 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<string>, |
| }; |
| } |
|
|
| |
| |
| |
| 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<string>, |
| }; |
| } |
|
|
| |
| |
| |
| async function parseAgentFileWithAdapter( |
| filePath: string, |
| fsAdapter: FsAdapter |
| ): Promise<AgentDefinition | null> { |
| try { |
| const content = await fsAdapter.readFile(filePath); |
| return parseAgentContent(content, filePath); |
| } catch (error) { |
| logger.error(`Failed to parse agent file: ${filePath}`, error); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function scanAgentsDirectory( |
| baseDir: string, |
| source: 'user' | 'project' |
| ): Promise<FilesystemAgent[]> { |
| const agents: FilesystemAgent[] = []; |
| const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter(); |
|
|
| try { |
| |
| const exists = await fsAdapter.exists(baseDir); |
| if (!exists) { |
| logger.debug(`Directory does not exist: ${baseDir}`); |
| return agents; |
| } |
|
|
| |
| const entries = await fsAdapter.readdir(baseDir); |
|
|
| for (const entry of entries) { |
| |
| if (entry.isFile && entry.name.endsWith('.md')) { |
| const agentName = entry.name.slice(0, -3); |
| 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 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: entry.name, |
| definition, |
| source, |
| filePath: agentFilePath, |
| }); |
| logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); |
| } |
| } |
| } |
| } |
| } catch (error) { |
| logger.error(`Failed to scan agents directory: ${baseDir}`, error); |
| } |
|
|
| return agents; |
| } |
|
|
| |
| |
| |
| export async function discoverFilesystemAgents( |
| projectPath?: string, |
| sources: Array<'user' | 'project'> = ['user', 'project'] |
| ): Promise<FilesystemAgent[]> { |
| const agents: FilesystemAgent[] = []; |
|
|
| |
| if (sources.includes('user')) { |
| const userAgentsDir = path.join(os.homedir(), '.claude', 'agents'); |
| const userAgents = await scanAgentsDirectory(userAgentsDir, 'user'); |
| agents.push(...userAgents); |
| logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`); |
| } |
|
|
| |
| if (sources.includes('project') && projectPath) { |
| const projectAgentsDir = path.join(projectPath, '.claude', 'agents'); |
| const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project'); |
| agents.push(...projectAgents); |
| logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`); |
| } |
|
|
| return agents; |
| } |
|
|