diff options
| author | Dax Raad <[email protected]> | 2026-04-16 12:40:16 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2026-04-16 12:40:24 -0400 |
| commit | 33bb847a1dfb5e79b4815813739671a40afa0e51 (patch) | |
| tree | b2beddf3c36d894bff28393c2f038bdc096bf5ef | |
| parent | bfffc3c2c6349d9199dd1a73260612b5ec2da88d (diff) | |
| download | opencode-33bb847a1dfb5e79b4815813739671a40afa0e51.tar.gz opencode-33bb847a1dfb5e79b4815813739671a40afa0e51.zip | |
config: refactor
| -rw-r--r-- | packages/opencode/src/acp/agent.ts | 3 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/mcp.ts | 9 | ||||
| -rw-r--r-- | packages/opencode/src/config/agent.ts | 171 | ||||
| -rw-r--r-- | packages/opencode/src/config/command.ts | 110 | ||||
| -rw-r--r-- | packages/opencode/src/config/config.ts | 401 | ||||
| -rw-r--r-- | packages/opencode/src/config/entry-name.ts | 16 | ||||
| -rw-r--r-- | packages/opencode/src/config/index.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/src/config/mcp.ts | 70 | ||||
| -rw-r--r-- | packages/opencode/src/config/model-id.ts | 3 | ||||
| -rw-r--r-- | packages/opencode/src/config/permission.ts | 68 | ||||
| -rw-r--r-- | packages/opencode/src/mcp/mcp.ts | 25 | ||||
| -rw-r--r-- | packages/opencode/src/permission/permission.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/mcp.ts | 3 | ||||
| -rw-r--r-- | packages/opencode/test/config/config.test.ts | 5 |
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, |
