mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-26 15:55:45 +00:00
Extract error handling, parsing logic, and variable substitution into dedicated modules. This reduces duplication between tui.json and opencode.json parsing and makes the config system easier to extend for future config formats.
172 lines
5.3 KiB
TypeScript
172 lines
5.3 KiB
TypeScript
export * as ConfigAgent from "./agent"
|
|
|
|
import { Log } from "../util"
|
|
import z from "zod"
|
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
|
import { Bus } from "@/bus"
|
|
import { configEntryNameFromPath } from "./entry-name"
|
|
import { InvalidError } from "./error"
|
|
import * as ConfigMarkdown from "./markdown"
|
|
import { ConfigModelID } from "./model-id"
|
|
import { ConfigPermission } from "./permission"
|
|
|
|
const log = Log.create({ service: "config" })
|
|
|
|
export const Info = z
|
|
.object({
|
|
model: ConfigModelID.optional(),
|
|
variant: z
|
|
.string()
|
|
.optional()
|
|
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
|
|
temperature: z.number().optional(),
|
|
top_p: z.number().optional(),
|
|
prompt: z.string().optional(),
|
|
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
|
|
disable: z.boolean().optional(),
|
|
description: z.string().optional().describe("Description of when to use the agent"),
|
|
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
|
hidden: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
|
|
options: z.record(z.string(), z.any()).optional(),
|
|
color: z
|
|
.union([
|
|
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
|
|
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
])
|
|
.optional()
|
|
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
|
|
steps: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.optional()
|
|
.describe("Maximum number of agentic iterations before forcing text-only response"),
|
|
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
|
|
permission: ConfigPermission.Info.optional(),
|
|
})
|
|
.catchall(z.any())
|
|
.transform((agent, _ctx) => {
|
|
const knownKeys = new Set([
|
|
"name",
|
|
"model",
|
|
"variant",
|
|
"prompt",
|
|
"description",
|
|
"temperature",
|
|
"top_p",
|
|
"mode",
|
|
"hidden",
|
|
"color",
|
|
"steps",
|
|
"maxSteps",
|
|
"options",
|
|
"permission",
|
|
"disable",
|
|
"tools",
|
|
])
|
|
|
|
const options: Record<string, unknown> = { ...agent.options }
|
|
for (const [key, value] of Object.entries(agent)) {
|
|
if (!knownKeys.has(key)) options[key] = value
|
|
}
|
|
|
|
const permission: ConfigPermission.Info = {}
|
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
|
const action = enabled ? "allow" : "deny"
|
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
|
permission.edit = action
|
|
continue
|
|
}
|
|
permission[tool] = action
|
|
}
|
|
Object.assign(permission, agent.permission)
|
|
|
|
const steps = agent.steps ?? agent.maxSteps
|
|
|
|
return { ...agent, options, permission, steps } as typeof agent & {
|
|
options?: Record<string, unknown>
|
|
permission?: ConfigPermission.Info
|
|
steps?: number
|
|
}
|
|
})
|
|
.meta({
|
|
ref: "AgentConfig",
|
|
})
|
|
export type Info = z.infer<typeof Info>
|
|
|
|
export async function load(dir: string) {
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse agent ${item}`
|
|
const { Session } = await import("@/session")
|
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
log.error("failed to load agent", { agent: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
|
const name = configEntryNameFromPath(item, patterns)
|
|
|
|
const config = {
|
|
name,
|
|
...md.data,
|
|
prompt: md.content.trim(),
|
|
}
|
|
const parsed = Info.safeParse(config)
|
|
if (parsed.success) {
|
|
result[config.name] = parsed.data
|
|
continue
|
|
}
|
|
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function loadMode(dir: string) {
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse mode ${item}`
|
|
const { Session } = await import("@/session")
|
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
log.error("failed to load mode", { mode: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const config = {
|
|
name: configEntryNameFromPath(item, []),
|
|
...md.data,
|
|
prompt: md.content.trim(),
|
|
}
|
|
const parsed = Info.safeParse(config)
|
|
if (parsed.success) {
|
|
result[config.name] = {
|
|
...parsed.data,
|
|
mode: "primary" as const,
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|