/ src / components / agents / agentFileUtils.ts
agentFileUtils.ts
  1  import { mkdir, open, unlink } from 'fs/promises'
  2  import { join } from 'path'
  3  import type { SettingSource } from 'src/utils/settings/constants.js'
  4  import { getManagedFilePath } from 'src/utils/settings/managedPath.js'
  5  import type { AgentMemoryScope } from '../../tools/AgentTool/agentMemory.js'
  6  import {
  7    type AgentDefinition,
  8    isBuiltInAgent,
  9    isPluginAgent,
 10  } from '../../tools/AgentTool/loadAgentsDir.js'
 11  import { getCwd } from '../../utils/cwd.js'
 12  import type { EffortValue } from '../../utils/effort.js'
 13  import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
 14  import { getErrnoCode } from '../../utils/errors.js'
 15  import { AGENT_PATHS } from './types.js'
 16  
 17  /**
 18   * Formats agent data as markdown file content
 19   */
 20  export function formatAgentAsMarkdown(
 21    agentType: string,
 22    whenToUse: string,
 23    tools: string[] | undefined,
 24    systemPrompt: string,
 25    color?: string,
 26    model?: string,
 27    memory?: AgentMemoryScope,
 28    effort?: EffortValue,
 29  ): string {
 30    // For YAML double-quoted strings, we need to escape:
 31    // - Backslashes: \ -> \\
 32    // - Double quotes: " -> \"
 33    // - Newlines: \n -> \\n (so yaml reads it as literal backslash-n, not newline)
 34    const escapedWhenToUse = whenToUse
 35      .replace(/\\/g, '\\\\') // Escape backslashes first
 36      .replace(/"/g, '\\"') // Escape double quotes
 37      .replace(/\n/g, '\\\\n') // Escape newlines as \\n so yaml preserves them as \n
 38  
 39    // Omit tools field entirely when tools is undefined or ['*'] (all tools allowed)
 40    const isAllTools =
 41      tools === undefined || (tools.length === 1 && tools[0] === '*')
 42    const toolsLine = isAllTools ? '' : `\ntools: ${tools.join(', ')}`
 43    const modelLine = model ? `\nmodel: ${model}` : ''
 44    const effortLine = effort !== undefined ? `\neffort: ${effort}` : ''
 45    const colorLine = color ? `\ncolor: ${color}` : ''
 46    const memoryLine = memory ? `\nmemory: ${memory}` : ''
 47  
 48    return `---
 49  name: ${agentType}
 50  description: "${escapedWhenToUse}"${toolsLine}${modelLine}${effortLine}${colorLine}${memoryLine}
 51  ---
 52  
 53  ${systemPrompt}
 54  `
 55  }
 56  
 57  /**
 58   * Gets the directory path for an agent location
 59   */
 60  function getAgentDirectoryPath(location: SettingSource): string {
 61    switch (location) {
 62      case 'flagSettings':
 63        throw new Error(`Cannot get directory path for ${location} agents`)
 64      case 'userSettings':
 65        return join(getClaudeConfigHomeDir(), AGENT_PATHS.AGENTS_DIR)
 66      case 'projectSettings':
 67        return join(getCwd(), AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR)
 68      case 'policySettings':
 69        return join(
 70          getManagedFilePath(),
 71          AGENT_PATHS.FOLDER_NAME,
 72          AGENT_PATHS.AGENTS_DIR,
 73        )
 74      case 'localSettings':
 75        return join(getCwd(), AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR)
 76    }
 77  }
 78  
 79  function getRelativeAgentDirectoryPath(location: SettingSource): string {
 80    switch (location) {
 81      case 'projectSettings':
 82        return join('.', AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR)
 83      default:
 84        return getAgentDirectoryPath(location)
 85    }
 86  }
 87  
 88  /**
 89   * Gets the file path for a new agent based on its name
 90   * Used when creating new agent files
 91   */
 92  export function getNewAgentFilePath(agent: {
 93    source: SettingSource
 94    agentType: string
 95  }): string {
 96    const dirPath = getAgentDirectoryPath(agent.source)
 97    return join(dirPath, `${agent.agentType}.md`)
 98  }
 99  
100  /**
101   * Gets the actual file path for an agent (handles filename vs agentType mismatch)
102   * Always use this for existing agents to get their real file location
103   */
104  export function getActualAgentFilePath(agent: AgentDefinition): string {
105    if (agent.source === 'built-in') {
106      return 'Built-in'
107    }
108    if (agent.source === 'plugin') {
109      throw new Error('Cannot get file path for plugin agents')
110    }
111  
112    const dirPath = getAgentDirectoryPath(agent.source)
113    const filename = agent.filename || agent.agentType
114    return join(dirPath, `${filename}.md`)
115  }
116  
117  /**
118   * Gets the relative file path for a new agent based on its name
119   * Used for displaying where new agent files will be created
120   */
121  export function getNewRelativeAgentFilePath(agent: {
122    source: SettingSource | 'built-in'
123    agentType: string
124  }): string {
125    if (agent.source === 'built-in') {
126      return 'Built-in'
127    }
128    const dirPath = getRelativeAgentDirectoryPath(agent.source)
129    return join(dirPath, `${agent.agentType}.md`)
130  }
131  
132  /**
133   * Gets the actual relative file path for an agent (handles filename vs agentType mismatch)
134   */
135  export function getActualRelativeAgentFilePath(agent: AgentDefinition): string {
136    if (isBuiltInAgent(agent)) {
137      return 'Built-in'
138    }
139    if (isPluginAgent(agent)) {
140      return `Plugin: ${agent.plugin || 'Unknown'}`
141    }
142    if (agent.source === 'flagSettings') {
143      return 'CLI argument'
144    }
145  
146    const dirPath = getRelativeAgentDirectoryPath(agent.source)
147    const filename = agent.filename || agent.agentType
148    return join(dirPath, `${filename}.md`)
149  }
150  
151  /**
152   * Ensures the directory for an agent location exists
153   */
154  async function ensureAgentDirectoryExists(
155    source: SettingSource,
156  ): Promise<string> {
157    const dirPath = getAgentDirectoryPath(source)
158    await mkdir(dirPath, { recursive: true })
159    return dirPath
160  }
161  
162  /**
163   * Saves an agent to the filesystem
164   * @param checkExists - If true, throws error if file already exists
165   */
166  export async function saveAgentToFile(
167    source: SettingSource | 'built-in',
168    agentType: string,
169    whenToUse: string,
170    tools: string[] | undefined,
171    systemPrompt: string,
172    checkExists = true,
173    color?: string,
174    model?: string,
175    memory?: AgentMemoryScope,
176    effort?: EffortValue,
177  ): Promise<void> {
178    if (source === 'built-in') {
179      throw new Error('Cannot save built-in agents')
180    }
181  
182    await ensureAgentDirectoryExists(source)
183    const filePath = getNewAgentFilePath({ source, agentType })
184  
185    const content = formatAgentAsMarkdown(
186      agentType,
187      whenToUse,
188      tools,
189      systemPrompt,
190      color,
191      model,
192      memory,
193      effort,
194    )
195    try {
196      await writeFileAndFlush(filePath, content, checkExists ? 'wx' : 'w')
197    } catch (e: unknown) {
198      if (getErrnoCode(e) === 'EEXIST') {
199        throw new Error(`Agent file already exists: ${filePath}`)
200      }
201      throw e
202    }
203  }
204  
205  /**
206   * Updates an existing agent file
207   */
208  export async function updateAgentFile(
209    agent: AgentDefinition,
210    newWhenToUse: string,
211    newTools: string[] | undefined,
212    newSystemPrompt: string,
213    newColor?: string,
214    newModel?: string,
215    newMemory?: AgentMemoryScope,
216    newEffort?: EffortValue,
217  ): Promise<void> {
218    if (agent.source === 'built-in') {
219      throw new Error('Cannot update built-in agents')
220    }
221  
222    const filePath = getActualAgentFilePath(agent)
223  
224    const content = formatAgentAsMarkdown(
225      agent.agentType,
226      newWhenToUse,
227      newTools,
228      newSystemPrompt,
229      newColor,
230      newModel,
231      newMemory,
232      newEffort,
233    )
234  
235    await writeFileAndFlush(filePath, content)
236  }
237  
238  /**
239   * Deletes an agent file
240   */
241  export async function deleteAgentFromFile(
242    agent: AgentDefinition,
243  ): Promise<void> {
244    if (agent.source === 'built-in') {
245      throw new Error('Cannot delete built-in agents')
246    }
247  
248    const filePath = getActualAgentFilePath(agent)
249  
250    try {
251      await unlink(filePath)
252    } catch (e: unknown) {
253      const code = getErrnoCode(e)
254      if (code !== 'ENOENT') {
255        throw e
256      }
257    }
258  }
259  
260  async function writeFileAndFlush(
261    filePath: string,
262    content: string,
263    flag: 'w' | 'wx' = 'w',
264  ): Promise<void> {
265    const handle = await open(filePath, flag)
266    try {
267      await handle.writeFile(content, { encoding: 'utf-8' })
268      await handle.datasync()
269    } finally {
270      await handle.close()
271    }
272  }