diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts new file mode 100644 index 000000000..3ce8d8521 --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdtempSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +import { loadAgentDefinitions, parseMarkdownAgentFile } from "./agent-definitions-loader" + +describe("agent-definitions-loader", () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "agent-definitions-test-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("#parseMarkdownAgentFile", () => { + test("parses valid markdown agent file", () => { + const filePath = join(tempDir, "test-agent.md") + const content = `--- +name: test-agent +description: A test agent +model: claude-opus-4 +mode: subagent +tools: bash,read +--- + +You are a test agent.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("test-agent") + expect(result?.config.description).toBe("(definition-file) A test agent") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.prompt).toBe("You are a test agent.") + expect(result?.config.tools).toEqual({ bash: true, read: true }) + }) + + test("uses filename as agent name if name not specified in frontmatter", () => { + const filePath = join(tempDir, "custom-name.md") + const content = `--- +description: No name specified +--- + +Prompt content.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("custom-name") + }) + + test("returns null for missing file", () => { + const filePath = join(tempDir, "missing.md") + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).toBeNull() + }) + + test("handles malformed frontmatter gracefully with defaults", () => { + const filePath = join(tempDir, "malformed.md") + const content = `--- +invalid: yaml: content: here +--- + +Prompt.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("malformed") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.prompt).toBe("Prompt.") + }) + + test("defaults mode to subagent when not specified", () => { + const filePath = join(tempDir, "no-mode.md") + const content = `--- +name: no-mode-agent +--- + +Prompt.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.config.mode).toBe("subagent") + }) + }) + + describe("#loadAgentDefinitions", () => { + test("loads mixed format files (markdown and JSON)", () => { + const mdPath = join(tempDir, "agent1.md") + const jsonPath = join(tempDir, "agent2.json") + + writeFileSync( + mdPath, + `--- +name: md-agent +--- + +Markdown agent prompt.`, + "utf-8" + ) + + writeFileSync( + jsonPath, + JSON.stringify({ + name: "json-agent", + prompt: "JSON agent prompt.", + }), + "utf-8" + ) + + const result = loadAgentDefinitions([mdPath, jsonPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(2) + expect(result["md-agent"]).toBeDefined() + expect(result["json-agent"]).toBeDefined() + expect(result["md-agent"].prompt).toBe("Markdown agent prompt.") + expect(result["json-agent"].prompt).toBe("JSON agent prompt.") + }) + + test("silently skips missing files with warning log", () => { + const validPath = join(tempDir, "valid.md") + const missingPath = join(tempDir, "missing.md") + + writeFileSync( + validPath, + `--- +name: valid-agent +--- + +Valid prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([validPath, missingPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("silently skips malformed files with warning log", () => { + const validPath = join(tempDir, "valid.jsonc") + const malformedPath = join(tempDir, "malformed.json") + + writeFileSync( + validPath, + JSON.stringify({ + name: "valid-agent", + prompt: "Valid prompt.", + }), + "utf-8" + ) + + writeFileSync(malformedPath, "{ invalid json", "utf-8") + + const result = loadAgentDefinitions([validPath, malformedPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("last-write-wins for duplicate agent names", () => { + const path1 = join(tempDir, "agent-v1.md") + const path2 = join(tempDir, "agent-v2.md") + + writeFileSync( + path1, + `--- +name: duplicate-agent +description: First version +--- + +First prompt.`, + "utf-8" + ) + + writeFileSync( + path2, + `--- +name: duplicate-agent +description: Second version +--- + +Second prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([path1, path2], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["duplicate-agent"].description).toBe("(definition-file) Second version") + expect(result["duplicate-agent"].prompt).toBe("Second prompt.") + }) + + test("returns empty object for empty paths array", () => { + const result = loadAgentDefinitions([], "definition-file") + + expect(result).toEqual({}) + }) + + test("handles absolute paths correctly", () => { + const absolutePath = join(tempDir, "absolute.md") + + writeFileSync( + absolutePath, + `--- +name: absolute-agent +--- + +Absolute path prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([absolutePath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["absolute-agent"]).toBeDefined() + }) + + test("skips unsupported file extensions with warning", () => { + const validPath = join(tempDir, "valid.md") + const unsupportedPath = join(tempDir, "unsupported.txt") + + writeFileSync( + validPath, + `--- +name: valid-agent +--- + +Valid prompt.`, + "utf-8" + ) + + writeFileSync(unsupportedPath, "Some text file content.", "utf-8") + + const result = loadAgentDefinitions([validPath, unsupportedPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("supports JSONC format with comments", () => { + const jsoncPath = join(tempDir, "agent.jsonc") + + writeFileSync( + jsoncPath, + `{ + // This is a comment + "name": "jsonc-agent", + "description": "JSONC agent", // inline comment + "prompt": "JSONC prompt." +}`, + "utf-8" + ) + + const result = loadAgentDefinitions([jsoncPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["jsonc-agent"]).toBeDefined() + expect(result["jsonc-agent"].prompt).toBe("JSONC prompt.") + }) + }) +}) diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.ts new file mode 100644 index 000000000..b541e5f88 --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync } from "fs" +import { basename, extname } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { log } from "../../shared/logger" +import { parseJsonAgentFile } from "./json-agent-loader" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" + +function parseToolsConfig(toolsStr?: string): Record | undefined { + if (!toolsStr) return undefined + + const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null { + try { + if (!existsSync(filePath)) { + return null + } + + const content = readFileSync(filePath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const agentName = basename(filePath, ".md") + const name = data.name || agentName + const originalDescription = data.description || "" + + const formattedDescription = `(${scope}) ${originalDescription}` + + const mappedModelOverride = mapClaudeModelToOpenCode(data.model) + const modelString = mappedModelOverride + ? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` + : undefined + + const config: ClaudeCodeAgentConfig = { + description: formattedDescription, + mode: data.mode || "subagent", + prompt: body.trim(), + ...(modelString ? { model: modelString } : {}), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + return { + name, + path: filePath, + config, + scope, + } + } catch { + return null + } +} + +export function loadAgentDefinitions( + paths: string[], + scope: AgentScope +): Record { + const result: Record = {} + + for (const filePath of paths) { + if (!existsSync(filePath)) { + log(`[agent-definitions-loader] File not found, skipping: ${filePath}`) + continue + } + + const ext = extname(filePath).toLowerCase() + let agent: LoadedAgent | null = null + + if (ext === ".md") { + agent = parseMarkdownAgentFile(filePath, scope) + } else if (ext === ".json" || ext === ".jsonc") { + agent = parseJsonAgentFile(filePath, scope) + } else { + log(`[agent-definitions-loader] Unsupported file extension: ${ext} for ${filePath}`) + continue + } + + if (!agent) { + log(`[agent-definitions-loader] Failed to parse agent file: ${filePath}`) + continue + } + + result[agent.name] = agent.config + } + + return result +} diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 98b7c8566..5a1bbf8fd 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -1,24 +1,10 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { join, basename } from "path" -import { parseFrontmatter } from "../../shared/frontmatter" +import { existsSync, readdirSync } from "fs" +import { join } from "path" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" -import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" -import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { AgentScope, ClaudeCodeAgentConfig, LoadedAgent } from "./types" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" - -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} +import { parseMarkdownAgentFile } from "./agent-definitions-loader" function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { if (!existsSync(agentsDir)) { @@ -32,42 +18,10 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] if (!isMarkdownFile(entry)) continue const agentPath = join(agentsDir, entry.name) - const agentName = basename(entry.name, ".md") + const agent = parseMarkdownAgentFile(agentPath, scope) - try { - const content = readFileSync(agentPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const name = data.name || agentName - const originalDescription = data.description || "" - - const formattedDescription = `(${scope}) ${originalDescription}` - - const mappedModelOverride = mapClaudeModelToOpenCode(data.model) - const modelString = mappedModelOverride - ? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` - : undefined - - const config: ClaudeCodeAgentConfig = { - description: formattedDescription, - mode: data.mode || "subagent", - prompt: body.trim(), - ...(modelString ? { model: modelString } : {}), - } - - const toolsConfig = parseToolsConfig(data.tools) - if (toolsConfig) { - config.tools = toolsConfig - } - - agents.push({ - name, - path: agentPath, - config, - scope, - }) - } catch { - continue + if (agent) { + agents.push(agent) } } diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts new file mode 100644 index 000000000..c814c8fd9 --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { readOpencodeConfigAgents } from "./opencode-config-agents-reader" + +describe("readOpencodeConfigAgents", () => { + it("returns empty record when no opencode.json exists", () => { + const nonexistentDir = "/nonexistent/directory/path" + const result = readOpencodeConfigAgents(nonexistentDir) + expect(result).toEqual({}) + }) + + it("reads inline agents from opencode.json", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "my-agent": { + description: "Custom agent", + model: "claude-opus-4-6", + mode: "subagent", + prompt: "You are a helpful assistant", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result).toHaveProperty("my-agent") + expect(result["my-agent"].description).toBe("(opencode-config) Custom agent") + expect(result["my-agent"].mode).toBe("subagent") + expect(result["my-agent"].prompt).toBe("You are a helpful assistant") + expect(result["my-agent"].model).toBeDefined() + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("reads agents from opencode.jsonc with comments", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.jsonc") + fs.writeFileSync( + configPath, + `{ + // Define agents + "agents": { + "test-agent": { + "description": "Test agent", + "prompt": "Test prompt" + } + } +} +` + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(Object.keys(result).length).toBeGreaterThan(0) + expect(result).toHaveProperty("test-agent") + expect(result["test-agent"].description).toBe("(opencode-config) Test agent") + expect(result["test-agent"].prompt).toBe("Test prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("handles malformed opencode.json gracefully", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync(configPath, "{ invalid json ") + + const result = readOpencodeConfigAgents(tempDir) + expect(result).toEqual({}) + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("maps Claude model names correctly", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "sonnet-agent": { + description: "Sonnet", + model: "sonnet", + prompt: "test", + }, + "opus-agent": { + description: "Opus", + model: "opus", + prompt: "test", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["sonnet-agent"].model).toBeDefined() + expect(result["opus-agent"].model).toBeDefined() + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("handles agent_definitions file paths", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDefFile = path.join(tempDir, "agents.json") + fs.writeFileSync( + agentDefFile, + JSON.stringify({ + name: "definition-agent", + description: "From definition file", + prompt: "File-based agent prompt", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent_definitions: ["./agents.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + if (Object.keys(result).length > 0) { + expect(result).toHaveProperty("definition-agent") + expect(result["definition-agent"].description).toContain("definition-file") + } + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("merges inline and definition agents, with inline taking precedence", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDefFile = path.join(tempDir, "agents.json") + fs.writeFileSync( + agentDefFile, + JSON.stringify({ + name: "shared-agent", + description: "From definition file", + prompt: "Definition prompt", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "shared-agent": { + description: "From inline", + prompt: "Inline prompt", + }, + }, + agent_definitions: ["./agents.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["shared-agent"].description).toBe("(opencode-config) From inline") + expect(result["shared-agent"].prompt).toBe("Inline prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("parses tools as both string and array formats", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "string-tools": { + description: "Tools as string", + tools: "tool1, tool2, tool3", + prompt: "test", + }, + "array-tools": { + description: "Tools as array", + tools: ["bash", "read"], + prompt: "test", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["string-tools"].tools).toEqual({ + tool1: true, + tool2: true, + tool3: true, + }) + + expect(result["array-tools"].tools).toEqual({ + bash: true, + read: true, + }) + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("prioritizes project-level opencode.json over user-level", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-project-")) + const projectOpencodeDir = path.join(projectDir, ".opencode") + fs.mkdirSync(projectOpencodeDir, { recursive: true }) + + const projectConfigPath = path.join(projectOpencodeDir, "opencode.json") + fs.writeFileSync( + projectConfigPath, + JSON.stringify({ + agents: { + "project-agent": { + description: "From project", + prompt: "Project prompt", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(projectDir) + + expect(result).toHaveProperty("project-agent") + expect(result["project-agent"].description).toBe("(opencode-config) From project") + + fs.rmSync(projectDir, { recursive: true }) + }) + + it("handles agent_definitions as array of paths", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDef1 = path.join(tempDir, "agents1.json") + fs.writeFileSync( + agentDef1, + JSON.stringify({ + name: "agent-one", + description: "First agent", + prompt: "Prompt 1", + }) + ) + + const agentDef2 = path.join(tempDir, "agents2.json") + fs.writeFileSync( + agentDef2, + JSON.stringify({ + name: "agent-two", + description: "Second agent", + prompt: "Prompt 2", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent_definitions: ["./agents1.json", "./agents2.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + if (Object.keys(result).length >= 2) { + expect(result).toHaveProperty("agent-one") + expect(result).toHaveProperty("agent-two") + } + + fs.rmSync(tempDir, { recursive: true }) + }) +}) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts new file mode 100644 index 000000000..354ab9edf --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { loadAgentDefinitions } from "./agent-definitions-loader" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { ClaudeCodeAgentConfig } from "./types" + +interface OpencodeConfigWithAgents { + agents?: Record + agent_definitions?: string | string[] +} + +function getWindowsAppdataDir(): string | null { + return process.env.APPDATA || null +} + +function getConfigPaths(directory: string): string[] { + const crossPlatformDir = path.join(os.homedir(), ".config") + const paths = [ + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + path.join(crossPlatformDir, "opencode", "opencode.json"), + path.join(crossPlatformDir, "opencode", "opencode.jsonc"), + ] + + if (process.platform === "win32") { + const appdataDir = getWindowsAppdataDir() + if (appdataDir) { + paths.push(path.join(appdataDir, "opencode", "opencode.json")) + paths.push(path.join(appdataDir, "opencode", "opencode.jsonc")) + } + } + + return paths +} + +function parseToolsConfig(toolsValue: unknown): Record | undefined { + if (!toolsValue) return undefined + + let toolsStr: string + if (typeof toolsValue === "string") { + toolsStr = toolsValue + } else if (Array.isArray(toolsValue)) { + toolsStr = toolsValue.filter((t) => typeof t === "string").join(",") + } else { + return undefined + } + + const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null { + if (!agentData || typeof agentData !== "object") { + return null + } + + const agent = agentData as Record + + const description = agent.description ? `(opencode-config) ${String(agent.description)}` : "(opencode-config) " + + const mappedModel = mapClaudeModelToOpenCode( + agent.model ? String(agent.model) : undefined + ) + const modelString = mappedModel + ? `${mappedModel.providerID}/${mappedModel.modelID}` + : undefined + + const config: ClaudeCodeAgentConfig = { + description, + mode: (agent.mode as "subagent" | "primary" | "all") || "subagent", + prompt: agent.prompt ? String(agent.prompt) : "", + ...(modelString ? { model: modelString } : {}), + } + + const toolsConfig = parseToolsConfig(agent.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + return config +} + +export function readOpencodeConfigAgents(directory: string): Record { + const result: Record = {} + + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + + const content = fs.readFileSync(configPath, "utf-8") + const parseResult = parseJsoncSafe(content) + + if (!parseResult.data) continue + + const configDir = path.dirname(configPath) + + if (parseResult.data.agents && typeof parseResult.data.agents === "object") { + for (const [agentName, agentData] of Object.entries(parseResult.data.agents)) { + const converted = convertInlineAgent(agentData) + if (converted) { + result[agentName] = converted + } + } + } + + if (parseResult.data.agent_definitions) { + const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions) + const resolvedPaths = definitionPaths.map((p) => + path.isAbsolute(p) ? p : path.resolve(configDir, p) + ) + + const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config") + + for (const [name, config] of Object.entries(definitionAgents)) { + if (!(name in result)) { + result[name] = config + } + } + } + } catch { + continue + } + } + + return result +} + +function extractDefinitionPaths(definitionPaths: unknown): string[] { + if (typeof definitionPaths === "string") { + return [definitionPaths] + } + + if (Array.isArray(definitionPaths)) { + return definitionPaths + .filter((p) => typeof p === "string") + .map((p) => p as string) + } + + return [] +}