|
| 1 | +import * as fs from "fs/promises" |
| 2 | +import * as path from "path" |
| 3 | +import * as os from "os" |
| 4 | +import matter from "gray-matter" |
| 5 | + |
| 6 | +// Valid model values for agents |
| 7 | +export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const |
| 8 | +export type AgentModel = (typeof VALID_AGENT_MODELS)[number] |
| 9 | + |
| 10 | +// Agent definition parsed from markdown file |
| 11 | +export interface ParsedAgent { |
| 12 | + name: string |
| 13 | + description: string |
| 14 | + prompt: string |
| 15 | + tools?: string[] |
| 16 | + disallowedTools?: string[] |
| 17 | + model?: AgentModel |
| 18 | +} |
| 19 | + |
| 20 | +// Agent with source/path metadata |
| 21 | +export interface FileAgent extends ParsedAgent { |
| 22 | + source: "user" | "project" |
| 23 | + path: string |
| 24 | +} |
| 25 | + |
| 26 | +/** |
| 27 | + * Parse agent markdown file with YAML frontmatter |
| 28 | + * Format: |
| 29 | + * --- |
| 30 | + * name: code-reviewer |
| 31 | + * description: Reviews code for quality |
| 32 | + * tools: Read, Glob, Grep |
| 33 | + * model: sonnet |
| 34 | + * --- |
| 35 | + * |
| 36 | + * You are a code reviewer. When invoked... |
| 37 | + */ |
| 38 | +export function parseAgentMd( |
| 39 | + content: string, |
| 40 | + filename: string |
| 41 | +): Partial<ParsedAgent> { |
| 42 | + try { |
| 43 | + const { data, content: body } = matter(content) |
| 44 | + |
| 45 | + // Parse tools - can be comma-separated string or array |
| 46 | + let tools: string[] | undefined |
| 47 | + if (typeof data.tools === "string") { |
| 48 | + tools = data.tools |
| 49 | + .split(",") |
| 50 | + .map((t: string) => t.trim()) |
| 51 | + .filter(Boolean) |
| 52 | + } else if (Array.isArray(data.tools)) { |
| 53 | + tools = data.tools |
| 54 | + } |
| 55 | + |
| 56 | + // Parse disallowedTools |
| 57 | + let disallowedTools: string[] | undefined |
| 58 | + if (typeof data.disallowedTools === "string") { |
| 59 | + disallowedTools = data.disallowedTools |
| 60 | + .split(",") |
| 61 | + .map((t: string) => t.trim()) |
| 62 | + .filter(Boolean) |
| 63 | + } else if (Array.isArray(data.disallowedTools)) { |
| 64 | + disallowedTools = data.disallowedTools |
| 65 | + } |
| 66 | + |
| 67 | + // Validate model |
| 68 | + const model = |
| 69 | + data.model && VALID_AGENT_MODELS.includes(data.model) |
| 70 | + ? (data.model as AgentModel) |
| 71 | + : undefined |
| 72 | + |
| 73 | + return { |
| 74 | + name: |
| 75 | + typeof data.name === "string" ? data.name : filename.replace(".md", ""), |
| 76 | + description: typeof data.description === "string" ? data.description : "", |
| 77 | + prompt: body.trim(), |
| 78 | + tools, |
| 79 | + disallowedTools, |
| 80 | + model, |
| 81 | + } |
| 82 | + } catch (err) { |
| 83 | + console.error("[agents] Failed to parse markdown:", err) |
| 84 | + return {} |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Generate markdown content for agent file |
| 90 | + */ |
| 91 | +export function generateAgentMd(agent: { |
| 92 | + name: string |
| 93 | + description: string |
| 94 | + prompt: string |
| 95 | + tools?: string[] |
| 96 | + disallowedTools?: string[] |
| 97 | + model?: AgentModel |
| 98 | +}): string { |
| 99 | + const frontmatter: string[] = [] |
| 100 | + frontmatter.push(`name: ${agent.name}`) |
| 101 | + frontmatter.push(`description: ${agent.description}`) |
| 102 | + if (agent.tools && agent.tools.length > 0) { |
| 103 | + frontmatter.push(`tools: ${agent.tools.join(", ")}`) |
| 104 | + } |
| 105 | + if (agent.disallowedTools && agent.disallowedTools.length > 0) { |
| 106 | + frontmatter.push(`disallowedTools: ${agent.disallowedTools.join(", ")}`) |
| 107 | + } |
| 108 | + if (agent.model && agent.model !== "inherit") { |
| 109 | + frontmatter.push(`model: ${agent.model}`) |
| 110 | + } |
| 111 | + |
| 112 | + return `---\n${frontmatter.join("\n")}\n---\n\n${agent.prompt}` |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Load agent definition from filesystem by name |
| 117 | + * Searches in user (~/.claude/agents/) and project (.claude/agents/) directories |
| 118 | + */ |
| 119 | +export async function loadAgent( |
| 120 | + name: string, |
| 121 | + cwd?: string |
| 122 | +): Promise<ParsedAgent | null> { |
| 123 | + const locations = [ |
| 124 | + path.join(os.homedir(), ".claude", "agents"), |
| 125 | + ...(cwd ? [path.join(cwd, ".claude", "agents")] : []), |
| 126 | + ] |
| 127 | + |
| 128 | + for (const dir of locations) { |
| 129 | + const agentPath = path.join(dir, `${name}.md`) |
| 130 | + try { |
| 131 | + const content = await fs.readFile(agentPath, "utf-8") |
| 132 | + const parsed = parseAgentMd(content, `${name}.md`) |
| 133 | + |
| 134 | + if (parsed.description && parsed.prompt) { |
| 135 | + return { |
| 136 | + name: parsed.name || name, |
| 137 | + description: parsed.description, |
| 138 | + prompt: parsed.prompt, |
| 139 | + tools: parsed.tools, |
| 140 | + disallowedTools: parsed.disallowedTools, |
| 141 | + model: parsed.model, |
| 142 | + } |
| 143 | + } |
| 144 | + } catch { |
| 145 | + continue |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + return null |
| 150 | +} |
| 151 | + |
| 152 | +/** |
| 153 | + * Scan directory for agent .md files |
| 154 | + * Format: .claude/agents/agent-name.md |
| 155 | + */ |
| 156 | +export async function scanAgentsDirectory( |
| 157 | + dir: string, |
| 158 | + source: "user" | "project" |
| 159 | +): Promise<FileAgent[]> { |
| 160 | + const agents: FileAgent[] = [] |
| 161 | + |
| 162 | + try { |
| 163 | + await fs.access(dir) |
| 164 | + const entries = await fs.readdir(dir, { withFileTypes: true }) |
| 165 | + |
| 166 | + for (const entry of entries) { |
| 167 | + // Validate entry name for security (prevent path traversal) |
| 168 | + if ( |
| 169 | + entry.name.includes("..") || |
| 170 | + entry.name.includes("/") || |
| 171 | + entry.name.includes("\\") |
| 172 | + ) { |
| 173 | + console.warn(`[agents] Skipping invalid filename: ${entry.name}`) |
| 174 | + continue |
| 175 | + } |
| 176 | + |
| 177 | + // Accept .md files (Claude Code native format) |
| 178 | + if (entry.isFile() && entry.name.endsWith(".md")) { |
| 179 | + const agentPath = path.join(dir, entry.name) |
| 180 | + try { |
| 181 | + const content = await fs.readFile(agentPath, "utf-8") |
| 182 | + const parsed = parseAgentMd(content, entry.name) |
| 183 | + |
| 184 | + if (parsed.description && parsed.prompt) { |
| 185 | + agents.push({ |
| 186 | + name: parsed.name || entry.name.replace(".md", ""), |
| 187 | + description: parsed.description, |
| 188 | + prompt: parsed.prompt, |
| 189 | + tools: parsed.tools, |
| 190 | + disallowedTools: parsed.disallowedTools, |
| 191 | + model: parsed.model, |
| 192 | + source, |
| 193 | + path: agentPath, |
| 194 | + }) |
| 195 | + } |
| 196 | + } catch (err) { |
| 197 | + console.error(`[agents] Failed to read agent ${entry.name}:`, err) |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + } catch (err) { |
| 202 | + // Directory doesn't exist or not accessible |
| 203 | + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { |
| 204 | + console.warn(`[agents] Could not scan directory ${dir}:`, err) |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + return agents |
| 209 | +} |
| 210 | + |
| 211 | +/** |
| 212 | + * Build agents Record for SDK Options |
| 213 | + * This properly registers agents with the SDK so Claude can invoke them via Task tool |
| 214 | + */ |
| 215 | +export async function buildAgentsOption( |
| 216 | + agentNames: string[], |
| 217 | + cwd?: string |
| 218 | +): Promise< |
| 219 | + Record< |
| 220 | + string, |
| 221 | + { description: string; prompt: string; tools?: string[]; model?: AgentModel } |
| 222 | + > |
| 223 | +> { |
| 224 | + if (agentNames.length === 0) return {} |
| 225 | + |
| 226 | + const agents: Record< |
| 227 | + string, |
| 228 | + { description: string; prompt: string; tools?: string[]; model?: AgentModel } |
| 229 | + > = {} |
| 230 | + |
| 231 | + for (const name of agentNames) { |
| 232 | + const agent = await loadAgent(name, cwd) |
| 233 | + if (agent) { |
| 234 | + agents[name] = { |
| 235 | + description: agent.description, |
| 236 | + prompt: agent.prompt, |
| 237 | + ...(agent.tools && { tools: agent.tools }), |
| 238 | + ...(agent.model && { model: agent.model }), |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + return agents |
| 244 | +} |
0 commit comments