Ver Fonte

config: refactor

Dax Raad há 1 semana atrás
pai
commit
33bb847a1d

+ 2 - 1
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] = {

+ 5 - 4
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({

+ 171 - 0
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
+}

+ 47 - 63
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
 }

+ 43 - 358
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

+ 16 - 0
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
+}

+ 4 - 0
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"

+ 70 - 0
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>
+}

+ 3 - 0
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" })

+ 68 - 0
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>

+ 16 - 9
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 }

+ 2 - 2
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") {

+ 2 - 1
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) => {

+ 4 - 1
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,