File size: 8,040 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
/**
 * Agent Discovery - Scans filesystem for AGENT.md files
 *
 * Discovers agents from:
 * - ~/.claude/agents/ (user-level, global)
 * - .claude/agents/ (project-level)
 *
 * Similar to Skills, but for custom subagents defined in AGENT.md files.
 */

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; // Directory name (e.g., 'code-reviewer')
  definition: AgentDefinition;
  source: 'user' | 'project';
  filePath: string; // Full path to AGENT.md
}

/**
 * Parse agent content string into AgentDefinition
 * Format:
 * ---
 * name: agent-name  # Optional
 * description: When to use this agent
 * tools: tool1, tool2, tool3  # Optional (comma or space separated list)
 * model: sonnet  # Optional: sonnet, opus, haiku
 * ---
 * System prompt content here...
 */
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;
  }

  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 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 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(', ')}`
    );
  }

  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<boolean>;
  readdir: (dirPath: string) => Promise<DirEntry[]>;
  readFile: (filePath: string) => Promise<string>;
}

/**
 * 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<string>,
  };
}

/**
 * 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<string>,
  };
}

/**
 * Parse agent file using the provided filesystem adapter
 */
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;
  }
}

/**
 * Scan a directory for agent .md files
 * Agents can be in two formats:
 * 1. Flat: agent-name.md (file directly in agents/)
 * 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills)
 */
async function scanAgentsDirectory(
  baseDir: string,
  source: 'user' | 'project'
): Promise<FilesystemAgent[]> {
  const agents: FilesystemAgent[] = [];
  const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter();

  try {
    // Check if directory exists
    const exists = await fsAdapter.exists(baseDir);
    if (!exists) {
      logger.debug(`Directory does not exist: ${baseDir}`);
      return agents;
    }

    // Read all entries in the directory
    const entries = await fsAdapter.readdir(baseDir);

    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}`);
        }
      }
      // 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: 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;
}

/**
 * Discover all filesystem-based agents from user and project sources
 */
export async function discoverFilesystemAgents(
  projectPath?: string,
  sources: Array<'user' | 'project'> = ['user', 'project']
): Promise<FilesystemAgent[]> {
  const agents: FilesystemAgent[] = [];

  // Discover user-level agents from ~/.claude/agents/
  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}`);
  }

  // Discover project-level agents from .claude/agents/
  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;
}