config: refactor
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
export const Info = z.object({
|
||||
template: z.string(),
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: ConfigModelID.optional(),
|
||||
subtask: z.boolean().optional(),
|
||||
})
|
||||
|
||||
function trim(file: string) {
|
||||
const ext = path.extname(file)
|
||||
return ext.length ? file.slice(0, -ext.length) : file
|
||||
}
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
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 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
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
||||
const name = configEntryNameFromPath(item, patterns)
|
||||
|
||||
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 })
|
||||
const config = {
|
||||
name,
|
||||
...md.data,
|
||||
template: md.content.trim(),
|
||||
}
|
||||
return result
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import z from "zod"
|
||||
|
||||
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user