config: refactor
This commit is contained in:
@@ -44,6 +44,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
import { Config } from "@/config"
|
import { Config } from "@/config"
|
||||||
|
import { ConfigMCP } from "@/config/mcp"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { LoadAPIKeyError } from "ai"
|
import { LoadAPIKeyError } from "ai"
|
||||||
@@ -1213,7 +1214,7 @@ export namespace ACP {
|
|||||||
description: "compact the session",
|
description: "compact the session",
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpServers: Record<string, Config.Mcp> = {}
|
const mcpServers: Record<string, ConfigMCP.Info> = {}
|
||||||
for (const server of params.mcpServers) {
|
for (const server of params.mcpServers) {
|
||||||
if ("type" in server) {
|
if ("type" in server) {
|
||||||
mcpServers[server.name] = {
|
mcpServers[server.name] = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MCP } from "../../mcp"
|
|||||||
import { McpAuth } from "../../mcp/auth"
|
import { McpAuth } from "../../mcp/auth"
|
||||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
import { ConfigMCP } from "../../config/mcp"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
@@ -43,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
|
|||||||
|
|
||||||
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
||||||
|
|
||||||
type McpConfigured = Config.Mcp
|
type McpConfigured = ConfigMCP.Info
|
||||||
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
||||||
return typeof config === "object" && config !== null && "type" in config
|
return typeof config === "object" && config !== null && "type" in config
|
||||||
}
|
}
|
||||||
@@ -426,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
|
|||||||
return candidates[0]
|
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 = "{}"
|
let text = "{}"
|
||||||
if (await Filesystem.exists(configPath)) {
|
if (await Filesystem.exists(configPath)) {
|
||||||
text = await Filesystem.readText(configPath)
|
text = await Filesystem.readText(configPath)
|
||||||
@@ -514,7 +515,7 @@ export const McpAddCommand = cmd({
|
|||||||
})
|
})
|
||||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||||
|
|
||||||
const mcpConfig: Config.Mcp = {
|
const mcpConfig: ConfigMCP.Info = {
|
||||||
type: "local",
|
type: "local",
|
||||||
command: command.split(" "),
|
command: command.split(" "),
|
||||||
}
|
}
|
||||||
@@ -544,7 +545,7 @@ export const McpAddCommand = cmd({
|
|||||||
})
|
})
|
||||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||||
|
|
||||||
let mcpConfig: Config.Mcp
|
let mcpConfig: ConfigMCP.Info
|
||||||
|
|
||||||
if (useOAuth) {
|
if (useOAuth) {
|
||||||
const hasClientId = await prompts.confirm({
|
const hasClientId = await prompts.confirm({
|
||||||
|
|||||||
@@ -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 { Log } from "../util"
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
|
import { ConfigModelID } from "./model-id"
|
||||||
import { InvalidError } from "./paths"
|
import { InvalidError } from "./paths"
|
||||||
|
|
||||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
function rel(item: string, patterns: string[]) {
|
export const Info = z.object({
|
||||||
const normalizedItem = item.replaceAll("\\", "/")
|
template: z.string(),
|
||||||
for (const pattern of patterns) {
|
description: z.string().optional(),
|
||||||
const index = normalizedItem.indexOf(pattern)
|
agent: z.string().optional(),
|
||||||
if (index === -1) continue
|
model: ConfigModelID.optional(),
|
||||||
return normalizedItem.slice(index + pattern.length)
|
subtask: z.boolean().optional(),
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
function trim(file: string) {
|
export type Info = z.infer<typeof Info>
|
||||||
const ext = path.extname(file)
|
|
||||||
return ext.length ? file.slice(0, -ext.length) : file
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace ConfigCommand {
|
export async function load(dir: string) {
|
||||||
export const Info = z.object({
|
const result: Record<string, Info> = {}
|
||||||
template: z.string(),
|
for (const item of await Glob.scan("{command,commands}/**/*.md", {
|
||||||
description: z.string().optional(),
|
cwd: dir,
|
||||||
agent: z.string().optional(),
|
absolute: true,
|
||||||
model: ModelId.optional(),
|
dot: true,
|
||||||
subtask: z.boolean().optional(),
|
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 config = {
|
||||||
const result: Record<string, Info> = {}
|
name,
|
||||||
for (const item of await Glob.scan("{command,commands}/**/*.md", {
|
...md.data,
|
||||||
cwd: dir,
|
template: md.content.trim(),
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
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 { Instance, type InstanceContext } from "../project/instance"
|
||||||
import * as LSPServer from "../lsp/server"
|
import * as LSPServer from "../lsp/server"
|
||||||
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import * as ConfigPaths from "./paths"
|
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 { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
import { Npm } from "@opencode-ai/shared/npm"
|
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 { ConfigPlugin } from "./plugin"
|
||||||
import { ConfigManaged } from "./managed"
|
import { ConfigManaged } from "./managed"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
|
import { ConfigPermission } from "./permission"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
@@ -64,231 +52,6 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
|||||||
return merged
|
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({
|
export const Skills = z.object({
|
||||||
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
||||||
urls: z
|
urls: z
|
||||||
@@ -298,95 +61,6 @@ export const Skills = z.object({
|
|||||||
})
|
})
|
||||||
export type Skills = z.infer<typeof Skills>
|
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
|
export const Keybinds = z
|
||||||
.object({
|
.object({
|
||||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||||
@@ -696,7 +370,7 @@ export const Info = z
|
|||||||
.describe(
|
.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.",
|
"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
|
share: z
|
||||||
.enum(["manual", "auto", "disabled"])
|
.enum(["manual", "auto", "disabled"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -718,8 +392,8 @@ export const Info = z
|
|||||||
.array(z.string())
|
.array(z.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
|
.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(),
|
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||||
small_model: ModelId.describe(
|
small_model: ConfigModelID.describe(
|
||||||
"Small model to use for tasks like title generation in the format of provider/model",
|
"Small model to use for tasks like title generation in the format of provider/model",
|
||||||
).optional(),
|
).optional(),
|
||||||
default_agent: z
|
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"),
|
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
|
||||||
mode: z
|
mode: z
|
||||||
.object({
|
.object({
|
||||||
build: Agent.optional(),
|
build: ConfigAgent.Info.optional(),
|
||||||
plan: Agent.optional(),
|
plan: ConfigAgent.Info.optional(),
|
||||||
})
|
})
|
||||||
.catchall(Agent)
|
.catchall(ConfigAgent.Info)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("@deprecated Use `agent` field instead."),
|
.describe("@deprecated Use `agent` field instead."),
|
||||||
agent: z
|
agent: z
|
||||||
.object({
|
.object({
|
||||||
// primary
|
// primary
|
||||||
plan: Agent.optional(),
|
plan: ConfigAgent.Info.optional(),
|
||||||
build: Agent.optional(),
|
build: ConfigAgent.Info.optional(),
|
||||||
// subagent
|
// subagent
|
||||||
general: Agent.optional(),
|
general: ConfigAgent.Info.optional(),
|
||||||
explore: Agent.optional(),
|
explore: ConfigAgent.Info.optional(),
|
||||||
// specialized
|
// specialized
|
||||||
title: Agent.optional(),
|
title: ConfigAgent.Info.optional(),
|
||||||
summary: Agent.optional(),
|
summary: ConfigAgent.Info.optional(),
|
||||||
compaction: Agent.optional(),
|
compaction: ConfigAgent.Info.optional(),
|
||||||
})
|
})
|
||||||
.catchall(Agent)
|
.catchall(ConfigAgent.Info)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
|
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
|
||||||
provider: z.record(z.string(), Provider).optional().describe("Custom provider configurations and model overrides"),
|
provider: z.record(z.string(), Provider).optional().describe("Custom provider configurations and model overrides"),
|
||||||
@@ -758,7 +432,7 @@ export const Info = z
|
|||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z.union([
|
z.union([
|
||||||
Mcp,
|
ConfigMCP.Info,
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean(),
|
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"),
|
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
||||||
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
||||||
permission: Permission.optional(),
|
permission: ConfigPermission.Info.optional(),
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
tools: z.record(z.string(), z.boolean()).optional(),
|
||||||
enterprise: z
|
enterprise: z
|
||||||
.object({
|
.object({
|
||||||
@@ -867,7 +541,7 @@ export const Info = z
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
plugin_origins?: PluginOrigin[]
|
plugin_origins?: ConfigPlugin.Origin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -1084,10 +758,17 @@ export const layer = Layer.effect(
|
|||||||
const gitignore = path.join(dir, ".gitignore")
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
const hasIgnore = yield* fs.existsSafe(gitignore)
|
const hasIgnore = yield* fs.existsSafe(gitignore)
|
||||||
if (!hasIgnore) {
|
if (!hasIgnore) {
|
||||||
yield* fs.writeFileString(
|
yield* fs
|
||||||
gitignore,
|
.writeFileString(
|
||||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
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"
|
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
|
if (!list?.length) return
|
||||||
const hit = kind ?? (yield* scope(source))
|
const hit = kind ?? (yield* scope(source))
|
||||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||||
@@ -1116,7 +801,7 @@ export const layer = Layer.effect(
|
|||||||
result.plugin_origins = plugins
|
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)
|
result = mergeConfigConcatArrays(result, next)
|
||||||
return track(source, next.plugin, kind)
|
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
|
const dep = yield* npmSvc
|
||||||
.install(dir, {
|
.install(dir, {
|
||||||
@@ -1204,8 +889,8 @@ export const layer = Layer.effect(
|
|||||||
deps.push(dep)
|
deps.push(dep)
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
|
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(() => ConfigAgent.load(dir)))
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
|
||||||
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
|
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
|
||||||
yield* track(dir, list)
|
yield* track(dir, list)
|
||||||
}
|
}
|
||||||
@@ -1284,9 +969,9 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.tools) {
|
if (result.tools) {
|
||||||
const perms: Record<string, PermissionAction> = {}
|
const perms: Record<string, ConfigPermission.Action> = {}
|
||||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
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") {
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
perms.edit = action
|
perms.edit = action
|
||||||
continue
|
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 Config from "./config"
|
||||||
|
export * as ConfigAgent from "./agent"
|
||||||
export * as ConfigCommand from "./command"
|
export * as ConfigCommand from "./command"
|
||||||
export { ConfigManaged } from "./managed"
|
export { ConfigManaged } from "./managed"
|
||||||
export * as ConfigMarkdown from "./markdown"
|
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"
|
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>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
|
import { ConfigMCP } from "../config/mcp"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import z from "zod/v4"
|
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 ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
|
||||||
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
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
|
return typeof entry === "object" && entry !== null && "type" in entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ export interface Interface {
|
|||||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { 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 connect: (name: string) => Effect.Effect<void>
|
||||||
readonly disconnect: (name: string) => Effect.Effect<void>
|
readonly disconnect: (name: string) => Effect.Effect<void>
|
||||||
readonly getPrompt: (
|
readonly getPrompt: (
|
||||||
@@ -276,7 +277,10 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
|
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 oauthDisabled = mcp.oauth === false
|
||||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||||
let authProvider: McpOAuthProvider | 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 [cmd, ...args] = mcp.command
|
||||||
const cwd = Instance.directory
|
const cwd = Instance.directory
|
||||||
const transport = new StdioClientTransport({
|
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) {
|
if (mcp.enabled === false) {
|
||||||
log.info("mcp server disabled", { key })
|
log.info("mcp server disabled", { key })
|
||||||
return DISABLED_RESULT
|
return DISABLED_RESULT
|
||||||
@@ -424,8 +431,8 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const { client: mcpClient, status } =
|
const { client: mcpClient, status } =
|
||||||
mcp.type === "remote"
|
mcp.type === "remote"
|
||||||
? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" })
|
? yield* connectRemote(key, mcp as ConfigMCP.Info & { type: "remote" })
|
||||||
: yield* connectLocal(key, mcp as Config.Mcp & { type: "local" })
|
: yield* connectLocal(key, mcp as ConfigMCP.Info & { type: "local" })
|
||||||
|
|
||||||
if (!mcpClient) {
|
if (!mcpClient) {
|
||||||
return { status } satisfies CreateResult
|
return { status } satisfies CreateResult
|
||||||
@@ -588,7 +595,7 @@ export const layer = Layer.effect(
|
|||||||
return s.clients
|
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 s = yield* InstanceState.get(state)
|
||||||
const result = yield* create(name, mcp)
|
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)
|
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)
|
yield* createAndStore(name, mcp)
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return { status: s.status }
|
return { status: s.status }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config"
|
import { ConfigPermission } from "@/config/permission"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
@@ -289,7 +289,7 @@ function expand(pattern: string): string {
|
|||||||
return pattern
|
return pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
export function fromConfig(permission: ConfigPermission.Info) {
|
||||||
const ruleset: Ruleset = []
|
const ruleset: Ruleset = []
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
import { ConfigMCP } from "../../config/mcp"
|
||||||
import { AppRuntime } from "../../effect/app-runtime"
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -53,7 +54,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
config: Config.Mcp,
|
config: ConfigMCP.Info,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -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.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||||
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1865,7 +1868,7 @@ describe("resolvePluginSpec", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("deduplicatePluginOrigins", () => {
|
describe("deduplicatePluginOrigins", () => {
|
||||||
const dedupe = (plugins: Config.PluginSpec[]) =>
|
const dedupe = (plugins: ConfigPlugin.Spec[]) =>
|
||||||
ConfigPlugin.deduplicatePluginOrigins(
|
ConfigPlugin.deduplicatePluginOrigins(
|
||||||
plugins.map((spec) => ({
|
plugins.map((spec) => ({
|
||||||
spec,
|
spec,
|
||||||
|
|||||||
Reference in New Issue
Block a user