summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-04-16 12:40:16 -0400
committerDax Raad <[email protected]>2026-04-16 12:40:24 -0400
commit33bb847a1dfb5e79b4815813739671a40afa0e51 (patch)
treeb2beddf3c36d894bff28393c2f038bdc096bf5ef
parentbfffc3c2c6349d9199dd1a73260612b5ec2da88d (diff)
downloadopencode-33bb847a1dfb5e79b4815813739671a40afa0e51.tar.gz
opencode-33bb847a1dfb5e79b4815813739671a40afa0e51.zip
config: refactor
-rw-r--r--packages/opencode/src/acp/agent.ts3
-rw-r--r--packages/opencode/src/cli/cmd/mcp.ts9
-rw-r--r--packages/opencode/src/config/agent.ts171
-rw-r--r--packages/opencode/src/config/command.ts110
-rw-r--r--packages/opencode/src/config/config.ts401
-rw-r--r--packages/opencode/src/config/entry-name.ts16
-rw-r--r--packages/opencode/src/config/index.ts4
-rw-r--r--packages/opencode/src/config/mcp.ts70
-rw-r--r--packages/opencode/src/config/model-id.ts3
-rw-r--r--packages/opencode/src/config/permission.ts68
-rw-r--r--packages/opencode/src/mcp/mcp.ts25
-rw-r--r--packages/opencode/src/permission/permission.ts4
-rw-r--r--packages/opencode/src/server/instance/mcp.ts3
-rw-r--r--packages/opencode/test/config/config.test.ts5
14 files changed, 453 insertions, 439 deletions
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 53bc7ed5f..9388c87f1 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -44,6 +44,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config"
+import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
@@ -1213,7 +1214,7 @@ export namespace ACP {
description: "compact the session",
})
- const mcpServers: Record<string, Config.Mcp> = {}
+ const mcpServers: Record<string, ConfigMCP.Info> = {}
for (const server of params.mcpServers) {
if ("type" in server) {
mcpServers[server.name] = {
diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts
index dc6d5e889..a5751ce83 100644
--- a/packages/opencode/src/cli/cmd/mcp.ts
+++ b/packages/opencode/src/cli/cmd/mcp.ts
@@ -8,6 +8,7 @@ import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config"
+import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version"
@@ -43,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
-type McpConfigured = Config.Mcp
+type McpConfigured = ConfigMCP.Info
function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config
}
@@ -426,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
return candidates[0]
}
-async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
+async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
let text = "{}"
if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath)
@@ -514,7 +515,7 @@ export const McpAddCommand = cmd({
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
- const mcpConfig: Config.Mcp = {
+ const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
@@ -544,7 +545,7 @@ export const McpAddCommand = cmd({
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
- let mcpConfig: Config.Mcp
+ let mcpConfig: ConfigMCP.Info
if (useOAuth) {
const hasClientId = await prompts.confirm({
diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts
new file mode 100644
index 000000000..3819368e8
--- /dev/null
+++ b/packages/opencode/src/config/agent.ts
@@ -0,0 +1,171 @@
+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 * as ConfigMarkdown from "./markdown"
+import { ConfigModelID } from "./model-id"
+import { InvalidError } from "./paths"
+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
+}
diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts
index 4b2d58f3f..5606bdd4c 100644
--- a/packages/opencode/src/config/command.ts
+++ b/packages/opencode/src/config/command.ts
@@ -1,76 +1,60 @@
+export * as ConfigCommand from "./command"
+
import { Log } from "../util"
-import path from "path"
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 * as ConfigMarkdown from "./markdown"
+import { ConfigModelID } from "./model-id"
import { InvalidError } from "./paths"
-const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
-
const log = Log.create({ service: "config" })
-function rel(item: string, patterns: string[]) {
- const normalizedItem = item.replaceAll("\\", "/")
- for (const pattern of patterns) {
- const index = normalizedItem.indexOf(pattern)
- if (index === -1) continue
- return normalizedItem.slice(index + pattern.length)
- }
-}
-
-function trim(file: string) {
- const ext = path.extname(file)
- return ext.length ? file.slice(0, -ext.length) : file
-}
-
-export namespace ConfigCommand {
- export const Info = z.object({
- template: z.string(),
- description: z.string().optional(),
- agent: z.string().optional(),
- model: ModelId.optional(),
- subtask: z.boolean().optional(),
- })
-
- 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("{command,commands}/**/*.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 command ${item}`
- const { Session } = await import("@/session")
- void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
- log.error("failed to load command", { command: item, err })
- return undefined
- })
- if (!md) continue
-
- const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
- const file = rel(item, patterns) ?? path.basename(item)
- const name = trim(file)
-
- const config = {
- name,
- ...md.data,
- template: 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 })
+export const Info = z.object({
+ template: z.string(),
+ description: z.string().optional(),
+ agent: z.string().optional(),
+ model: ConfigModelID.optional(),
+ subtask: z.boolean().optional(),
+})
+
+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("{command,commands}/**/*.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 command ${item}`
+ const { Session } = await import("@/session")
+ void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+ log.error("failed to load command", { command: item, err })
+ return undefined
+ })
+ if (!md) continue
+
+ const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
+ const name = configEntryNameFromPath(item, patterns)
+
+ const config = {
+ name,
+ ...md.data,
+ template: md.content.trim(),
+ }
+ const parsed = Info.safeParse(config)
+ if (parsed.success) {
+ result[config.name] = parsed.data
+ continue
}
- return result
+ throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
+ return result
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 3922357f2..92d66cf2b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -20,12 +20,9 @@ import {
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
-import * as ConfigMarkdown from "./markdown"
import { existsSync } from "fs"
-import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
-import { Glob } from "@opencode-ai/shared/util/glob"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import * as ConfigPaths from "./paths"
@@ -36,22 +33,13 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm"
+import { ConfigAgent } from "./agent"
+import { ConfigMCP } from "./mcp"
+import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
-
-const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
-const PluginOptions = z.record(z.string(), z.unknown())
-export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
-
-export type PluginOptions = z.infer<typeof PluginOptions>
-export type PluginSpec = z.infer<typeof PluginSpec>
-export type PluginScope = "global" | "local"
-export type PluginOrigin = {
- spec: PluginSpec
- source: string
- scope: PluginScope
-}
+import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
@@ -64,231 +52,6 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
return merged
}
-export type InstallInput = {
- waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
-}
-
-function rel(item: string, patterns: string[]) {
- const normalizedItem = item.replaceAll("\\", "/")
- for (const pattern of patterns) {
- const index = normalizedItem.indexOf(pattern)
- if (index === -1) continue
- return normalizedItem.slice(index + pattern.length)
- }
-}
-
-function trim(file: string) {
- const ext = path.extname(file)
- return ext.length ? file.slice(0, -ext.length) : file
-}
-
-async function loadAgent(dir: string) {
- const result: Record<string, Agent> = {}
-
- 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 file = rel(item, patterns) ?? path.basename(item)
- const agentName = trim(file)
-
- const config = {
- name: agentName,
- ...md.data,
- prompt: md.content.trim(),
- }
- const parsed = Agent.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
-}
-
-async function loadMode(dir: string) {
- const result: Record<string, Agent> = {}
- 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: path.basename(item, ".md"),
- ...md.data,
- prompt: md.content.trim(),
- }
- const parsed = Agent.safeParse(config)
- if (parsed.success) {
- result[config.name] = {
- ...parsed.data,
- mode: "primary" as const,
- }
- continue
- }
- }
- return result
-}
-
-export const McpLocal = z
- .object({
- type: z.literal("local").describe("Type of MCP server connection"),
- command: z.string().array().describe("Command and arguments to run the MCP server"),
- environment: z
- .record(z.string(), z.string())
- .optional()
- .describe("Environment variables to set when running the MCP server"),
- enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
- timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
- })
- .strict()
- .meta({
- ref: "McpLocalConfig",
- })
-
-export const McpOAuth = z
- .object({
- clientId: z
- .string()
- .optional()
- .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
- clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
- scope: z.string().optional().describe("OAuth scopes to request during authorization"),
- redirectUri: z
- .string()
- .optional()
- .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
- })
- .strict()
- .meta({
- ref: "McpOAuthConfig",
- })
-export type McpOAuth = z.infer<typeof McpOAuth>
-
-export const McpRemote = z
- .object({
- type: z.literal("remote").describe("Type of MCP server connection"),
- url: z.string().describe("URL of the remote MCP server"),
- enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
- headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
- oauth: z
- .union([McpOAuth, z.literal(false)])
- .optional()
- .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
- timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
- })
- .strict()
- .meta({
- ref: "McpRemoteConfig",
- })
-
-export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
-export type Mcp = z.infer<typeof Mcp>
-
-export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
- ref: "PermissionActionConfig",
-})
-export type PermissionAction = z.infer<typeof PermissionAction>
-
-export const PermissionObject = z.record(z.string(), PermissionAction).meta({
- ref: "PermissionObjectConfig",
-})
-export type PermissionObject = z.infer<typeof PermissionObject>
-
-export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
- ref: "PermissionRuleConfig",
-})
-export type PermissionRule = z.infer<typeof PermissionRule>
-
-// Capture original key order before zod reorders, then rebuild in original order
-const permissionPreprocess = (val: unknown) => {
- if (typeof val === "object" && val !== null && !Array.isArray(val)) {
- return { __originalKeys: Object.keys(val), ...val }
- }
- return val
-}
-
-const permissionTransform = (x: unknown): Record<string, PermissionRule> => {
- if (typeof x === "string") return { "*": x as PermissionAction }
- const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
- const { __originalKeys, ...rest } = obj
- if (!__originalKeys) return rest as Record<string, PermissionRule>
- const result: Record<string, PermissionRule> = {}
- for (const key of __originalKeys) {
- if (key in rest) result[key] = rest[key] as PermissionRule
- }
- return result
-}
-
-export const Permission = z
- .preprocess(
- permissionPreprocess,
- z
- .object({
- __originalKeys: z.string().array().optional(),
- read: PermissionRule.optional(),
- edit: PermissionRule.optional(),
- glob: PermissionRule.optional(),
- grep: PermissionRule.optional(),
- list: PermissionRule.optional(),
- bash: PermissionRule.optional(),
- task: PermissionRule.optional(),
- external_directory: PermissionRule.optional(),
- todowrite: PermissionAction.optional(),
- question: PermissionAction.optional(),
- webfetch: PermissionAction.optional(),
- websearch: PermissionAction.optional(),
- codesearch: PermissionAction.optional(),
- lsp: PermissionRule.optional(),
- doom_loop: PermissionAction.optional(),
- skill: PermissionRule.optional(),
- })
- .catchall(PermissionRule)
- .or(PermissionAction),
- )
- .transform(permissionTransform)
- .meta({
- ref: "PermissionConfig",
- })
-export type Permission = z.infer<typeof Permission>
-
export const Skills = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
@@ -298,95 +61,6 @@ export const Skills = z.object({
})
export type Skills = z.infer<typeof Skills>
-export const Agent = z
- .object({
- model: ModelId.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: Permission.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",
- ])
-
- // Extract unknown properties into options
- const options: Record<string, unknown> = { ...agent.options }
- for (const [key, value] of Object.entries(agent)) {
- if (!knownKeys.has(key)) options[key] = value
- }
-
- // Convert legacy tools config to permissions
- const permission: Permission = {}
- for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
- const action = enabled ? "allow" : "deny"
- // write, edit, patch, multiedit all map to edit permission
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- permission.edit = action
- } else {
- permission[tool] = action
- }
- }
- Object.assign(permission, agent.permission)
-
- // Convert legacy maxSteps to steps
- const steps = agent.steps ?? agent.maxSteps
-
- return { ...agent, options, permission, steps } as typeof agent & {
- options?: Record<string, unknown>
- permission?: Permission
- steps?: number
- }
- })
- .meta({
- ref: "AgentConfig",
- })
-export type Agent = z.infer<typeof Agent>
-
export const Keybinds = z
.object({
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
@@ -696,7 +370,7 @@ export const Info = z
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
- plugin: PluginSpec.array().optional(),
+ plugin: ConfigPlugin.Spec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -718,8 +392,8 @@ export const Info = z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
- model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
- small_model: ModelId.describe(
+ model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
+ small_model: ConfigModelID.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
@@ -731,26 +405,26 @@ export const Info = z
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
mode: z
.object({
- build: Agent.optional(),
- plan: Agent.optional(),
+ build: ConfigAgent.Info.optional(),
+ plan: ConfigAgent.Info.optional(),
})
- .catchall(Agent)
+ .catchall(ConfigAgent.Info)
.optional()
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
// primary
- plan: Agent.optional(),
- build: Agent.optional(),
+ plan: ConfigAgent.Info.optional(),
+ build: ConfigAgent.Info.optional(),
// subagent
- general: Agent.optional(),
- explore: Agent.optional(),
+ general: ConfigAgent.Info.optional(),
+ explore: ConfigAgent.Info.optional(),
// specialized
- title: Agent.optional(),
- summary: Agent.optional(),
- compaction: Agent.optional(),
+ title: ConfigAgent.Info.optional(),
+ summary: ConfigAgent.Info.optional(),
+ compaction: ConfigAgent.Info.optional(),
})
- .catchall(Agent)
+ .catchall(ConfigAgent.Info)
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z.record(z.string(), Provider).optional().describe("Custom provider configurations and model overrides"),
@@ -758,7 +432,7 @@ export const Info = z
.record(
z.string(),
z.union([
- Mcp,
+ ConfigMCP.Info,
z
.object({
enabled: z.boolean(),
@@ -820,7 +494,7 @@ export const Info = z
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
- permission: Permission.optional(),
+ permission: ConfigPermission.Info.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
@@ -867,7 +541,7 @@ export const Info = z
})
export type Info = z.output<typeof Info> & {
- plugin_origins?: PluginOrigin[]
+ plugin_origins?: ConfigPlugin.Origin[]
}
type State = {
@@ -1084,10 +758,17 @@ export const layer = Layer.effect(
const gitignore = path.join(dir, ".gitignore")
const hasIgnore = yield* fs.existsSafe(gitignore)
if (!hasIgnore) {
- yield* fs.writeFileString(
- gitignore,
- ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
- )
+ yield* fs
+ .writeFileString(
+ gitignore,
+ ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
+ )
+ .pipe(
+ Effect.catchIf(
+ (e) => e.reason._tag === "PermissionDenied",
+ () => Effect.void,
+ ),
+ )
}
})
@@ -1105,7 +786,11 @@ export const layer = Layer.effect(
return "global"
})
- const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
+ const track = Effect.fnUntraced(function* (
+ source: string,
+ list: ConfigPlugin.Spec[] | undefined,
+ kind?: ConfigPlugin.Scope,
+ ) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = ConfigPlugin.deduplicatePluginOrigins([
@@ -1116,7 +801,7 @@ export const layer = Layer.effect(
result.plugin_origins = plugins
})
- const merge = (source: string, next: Info, kind?: PluginScope) => {
+ const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
@@ -1183,7 +868,7 @@ export const layer = Layer.effect(
}
}
- yield* ensureGitignore(dir).pipe(Effect.forkScoped)
+ yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
@@ -1204,8 +889,8 @@ export const layer = Layer.effect(
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
- result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
- result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
+ result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
+ result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* track(dir, list)
}
@@ -1284,9 +969,9 @@ export const layer = Layer.effect(
}
if (result.tools) {
- const perms: Record<string, PermissionAction> = {}
+ const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
- const action: PermissionAction = enabled ? "allow" : "deny"
+ const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
diff --git a/packages/opencode/src/config/entry-name.ts b/packages/opencode/src/config/entry-name.ts
new file mode 100644
index 000000000..a553152c9
--- /dev/null
+++ b/packages/opencode/src/config/entry-name.ts
@@ -0,0 +1,16 @@
+import path from "path"
+
+function sliceAfterMatch(filePath: string, searchRoots: string[]) {
+ const normalizedPath = filePath.replaceAll("\\", "/")
+ for (const searchRoot of searchRoots) {
+ const index = normalizedPath.indexOf(searchRoot)
+ if (index === -1) continue
+ return normalizedPath.slice(index + searchRoot.length)
+ }
+}
+
+export function configEntryNameFromPath(filePath: string, searchRoots: string[]) {
+ const candidate = sliceAfterMatch(filePath, searchRoots) ?? path.basename(filePath)
+ const ext = path.extname(candidate)
+ return ext.length ? candidate.slice(0, -ext.length) : candidate
+}
diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts
index 8380d370d..f1af71867 100644
--- a/packages/opencode/src/config/index.ts
+++ b/packages/opencode/src/config/index.ts
@@ -1,5 +1,9 @@
export * as Config from "./config"
+export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown"
+export * as ConfigMCP from "./mcp"
+export { ConfigModelID } from "./model-id"
+export * as ConfigPermission from "./permission"
export * as ConfigPaths from "./paths"
diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts
new file mode 100644
index 000000000..fb8f8caa4
--- /dev/null
+++ b/packages/opencode/src/config/mcp.ts
@@ -0,0 +1,70 @@
+import z from "zod"
+
+export namespace ConfigMCP {
+ export const Local = z
+ .object({
+ type: z.literal("local").describe("Type of MCP server connection"),
+ command: z.string().array().describe("Command and arguments to run the MCP server"),
+ environment: z
+ .record(z.string(), z.string())
+ .optional()
+ .describe("Environment variables to set when running the MCP server"),
+ enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
+ timeout: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
+ })
+ .strict()
+ .meta({
+ ref: "McpLocalConfig",
+ })
+
+ export const OAuth = z
+ .object({
+ clientId: z
+ .string()
+ .optional()
+ .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
+ clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
+ scope: z.string().optional().describe("OAuth scopes to request during authorization"),
+ redirectUri: z
+ .string()
+ .optional()
+ .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
+ })
+ .strict()
+ .meta({
+ ref: "McpOAuthConfig",
+ })
+ export type OAuth = z.infer<typeof OAuth>
+
+ export const Remote = z
+ .object({
+ type: z.literal("remote").describe("Type of MCP server connection"),
+ url: z.string().describe("URL of the remote MCP server"),
+ enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
+ headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
+ oauth: z
+ .union([OAuth, z.literal(false)])
+ .optional()
+ .describe(
+ "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
+ ),
+ timeout: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
+ })
+ .strict()
+ .meta({
+ ref: "McpRemoteConfig",
+ })
+
+ export const Info = z.discriminatedUnion("type", [Local, Remote])
+ export type Info = z.infer<typeof Info>
+}
diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts
new file mode 100644
index 000000000..909e9aa92
--- /dev/null
+++ b/packages/opencode/src/config/model-id.ts
@@ -0,0 +1,3 @@
+import z from "zod"
+
+export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts
new file mode 100644
index 000000000..af01f6f2a
--- /dev/null
+++ b/packages/opencode/src/config/permission.ts
@@ -0,0 +1,68 @@
+export * as ConfigPermission from "./permission"
+import z from "zod"
+
+const permissionPreprocess = (val: unknown) => {
+ if (typeof val === "object" && val !== null && !Array.isArray(val)) {
+ return { __originalKeys: globalThis.Object.keys(val), ...val }
+ }
+ return val
+}
+
+export const Action = z.enum(["ask", "allow", "deny"]).meta({
+ ref: "PermissionActionConfig",
+})
+export type Action = z.infer<typeof Action>
+
+export const Object = z.record(z.string(), Action).meta({
+ ref: "PermissionObjectConfig",
+})
+export type Object = z.infer<typeof Object>
+
+export const Rule = z.union([Action, Object]).meta({
+ ref: "PermissionRuleConfig",
+})
+export type Rule = z.infer<typeof Rule>
+
+const transform = (x: unknown): Record<string, Rule> => {
+ if (typeof x === "string") return { "*": x as Action }
+ const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
+ const { __originalKeys, ...rest } = obj
+ if (!__originalKeys) return rest as Record<string, Rule>
+ const result: Record<string, Rule> = {}
+ for (const key of __originalKeys) {
+ if (key in rest) result[key] = rest[key] as Rule
+ }
+ return result
+}
+
+export const Info = z
+ .preprocess(
+ permissionPreprocess,
+ z
+ .object({
+ __originalKeys: z.string().array().optional(),
+ read: Rule.optional(),
+ edit: Rule.optional(),
+ glob: Rule.optional(),
+ grep: Rule.optional(),
+ list: Rule.optional(),
+ bash: Rule.optional(),
+ task: Rule.optional(),
+ external_directory: Rule.optional(),
+ todowrite: Action.optional(),
+ question: Action.optional(),
+ webfetch: Action.optional(),
+ websearch: Action.optional(),
+ codesearch: Action.optional(),
+ lsp: Rule.optional(),
+ doom_loop: Action.optional(),
+ skill: Rule.optional(),
+ })
+ .catchall(Rule)
+ .or(Action),
+ )
+ .transform(transform)
+ .meta({
+ ref: "PermissionConfig",
+ })
+export type Info = z.infer<typeof Info>
diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts
index 1f1022538..6666e0854 100644
--- a/packages/opencode/src/mcp/mcp.ts
+++ b/packages/opencode/src/mcp/mcp.ts
@@ -10,6 +10,7 @@ import {
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config"
+import { ConfigMCP } from "../config/mcp"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"
@@ -123,7 +124,7 @@ type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][numbe
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
-function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
+function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
return typeof entry === "object" && entry !== null && "type" in entry
}
@@ -224,7 +225,7 @@ export interface Interface {
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
- readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record<string, Status> | Status }>
+ readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void>
readonly disconnect: (name: string) => Effect.Effect<void>
readonly getPrompt: (
@@ -276,7 +277,10 @@ export const layer = Layer.effect(
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
- const connectRemote = Effect.fn("MCP.connectRemote")(function* (key: string, mcp: Config.Mcp & { type: "remote" }) {
+ const connectRemote = Effect.fn("MCP.connectRemote")(function* (
+ key: string,
+ mcp: ConfigMCP.Info & { type: "remote" },
+ ) {
const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined
@@ -382,7 +386,10 @@ export const layer = Layer.effect(
}
})
- const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) {
+ const connectLocal = Effect.fn("MCP.connectLocal")(function* (
+ key: string,
+ mcp: ConfigMCP.Info & { type: "local" },
+ ) {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const transport = new StdioClientTransport({
@@ -414,7 +421,7 @@ export const layer = Layer.effect(
)
})
- const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) {
+ const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCP.Info) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
return DISABLED_RESULT
@@ -424,8 +431,8 @@ export const layer = Layer.effect(
const { client: mcpClient, status } =
mcp.type === "remote"
- ? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" })
- : yield* connectLocal(key, mcp as Config.Mcp & { type: "local" })
+ ? yield* connectRemote(key, mcp as ConfigMCP.Info & { type: "remote" })
+ : yield* connectLocal(key, mcp as ConfigMCP.Info & { type: "local" })
if (!mcpClient) {
return { status } satisfies CreateResult
@@ -588,7 +595,7 @@ export const layer = Layer.effect(
return s.clients
})
- const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
+ const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp)
@@ -602,7 +609,7 @@ export const layer = Layer.effect(
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
})
- const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
+ const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
yield* createAndStore(name, mcp)
const s = yield* InstanceState.get(state)
return { status: s.status }
diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts
index fe7fb8545..44dac3b1d 100644
--- a/packages/opencode/src/permission/permission.ts
+++ b/packages/opencode/src/permission/permission.ts
@@ -1,6 +1,6 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
-import { Config } from "@/config"
+import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
@@ -289,7 +289,7 @@ function expand(pattern: string): string {
return pattern
}
-export function fromConfig(permission: Config.Permission) {
+export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/instance/mcp.ts
index 695008fc4..f6e6f1edd 100644
--- a/packages/opencode/src/server/instance/mcp.ts
+++ b/packages/opencode/src/server/instance/mcp.ts
@@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config"
+import { ConfigMCP } from "../../config/mcp"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -53,7 +54,7 @@ export const McpRoutes = lazy(() =>
"json",
z.object({
name: z.string(),
- config: Config.Mcp,
+ config: ConfigMCP.Info,
}),
),
async (c) => {
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 303fa8ba0..21d6e3e93 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -845,6 +845,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
},
})
+ // TODO: this is a hack to wait for backgruounded gitignore
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
@@ -1865,7 +1868,7 @@ describe("resolvePluginSpec", () => {
})
describe("deduplicatePluginOrigins", () => {
- const dedupe = (plugins: Config.PluginSpec[]) =>
+ const dedupe = (plugins: ConfigPlugin.Spec[]) =>
ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({
spec,