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.zod.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
|
|
}
|