feat(core): add location-scoped config loading (#29625)

This commit is contained in:
Dax 2026-05-30 00:06:08 -04:00 committed by GitHub
parent 5fb85a6aa3
commit 9583e08be4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 3507 additions and 525 deletions

View file

@ -256,6 +256,7 @@
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",

View file

@ -63,6 +63,7 @@
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",

View file

@ -1,147 +1,104 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { Array, Context, Effect, Layer, Schema } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
export class Info extends Schema.Class<Info>("AgentV2.Info")({
id: ID,
model: ModelV2.Ref.pipe(Schema.optional),
options: ProviderV2.Options,
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]),
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionV2.Ruleset,
}) {
static empty(id: ID) {
return new Info({
id,
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
mode: "all",
hidden: false,
permissions: [],
})
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
type Data = {
agents: Map<ID, Info>
}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export type Editor = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
remove: (id: ID) => void
}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
const state = State.create<Data, Editor>({
initial: () => ({ agents: new Map() }),
editor: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
},
remove: (id) => {
draft.agents.delete(id)
},
}),
})
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
return Service.of({
transform: state.transform,
update: state.update,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
all: Effect.fn("AgentV2.all")(function* () {
return Array.fromIterable(state.get().agents.values())
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer

View file

@ -1,18 +1,22 @@
export * as Catalog from "./catalog"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { produce, type Draft } from "immer"
import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
export type ProviderRecord = {
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
@ -25,6 +29,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
@ -34,24 +40,31 @@ export const Event = {
}),
}
export type Context = {
data: readonly ProviderRecord[]
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
type Data = {
providers: Map<ProviderV2.ID, ProviderRecord>
defaultModel?: DefaultModel
}
export type Editor = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
}
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
export interface Interface {
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
readonly transform: State.Interface<Data, Editor>["transform"]
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
@ -65,29 +78,25 @@ export interface Interface {
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly setDefault: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let loaders: { update: (ctx: Context) => void }[] = []
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
const provider = state.get().providers.get(model.providerID)!.provider
const endpoint =
model.endpoint.type === "unknown"
? provider.endpoint
@ -120,9 +129,9 @@ export const layer = Layer.effect(
}
function* getRecord(providerID: ProviderV2.ID) {
const match = HashMap.get(records, providerID)
if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID })
return match.value
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
@ -131,114 +140,73 @@ export const layer = Layer.effect(
delete item.options.aisdk.provider.baseURL
}
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
HashMap.fromIterable(
HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
)
const context = (draft: {
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
data: ProviderRecord[]
}): Context => {
const result: Context = {
data: draft.data,
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider,
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
const state = State.create<Data, Editor>({
initial: () => ({ providers: new Map() }),
editor: (draft) => {
const result: Editor = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
if (!current) {
current = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
draft.providers.set(providerID, current)
}
fn(current.provider)
normalizeEndpoint(current.provider)
},
remove: (providerID) => {
draft.providers.delete(providerID)
},
},
remove: (providerID) => {
draft.records = HashMap.remove(draft.records, providerID)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data.splice(index, 1)
model: {
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
update: (providerID, modelID, fn) => {
result.provider.update(providerID, () => {})
const record = draft.providers.get(providerID)!
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
model.providerID = providerID
normalizeEndpoint(model)
},
remove: (providerID, modelID) => {
draft.providers.get(providerID)?.models.delete(modelID)
},
default: {
get: () => draft.defaultModel,
set: (providerID, modelID) => {
draft.defaultModel = { providerID, modelID }
},
},
},
},
model: {
update: (providerID, modelID, fn) => {
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider: current.provider,
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID, modelID) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
if (!current) return
const next = {
provider: current.provider,
models: new Map(current.models),
}
next.models.delete(modelID)
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data[index] = next
},
},
}
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}),
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
yield* plugin.added().pipe(
Stream.runForEach((id) =>
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
records = draft.records
}),
yield* events.subscribe(PluginV2.Event.Added).pipe(
Stream.runForEach((event) =>
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
loader: Effect.fn("CatalogV2.loader")(function* () {
const loader = { update: (_ctx: Context) => {} }
loaders = [...loaders, loader]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
loaders = loaders.filter((item) => item !== loader)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update) {
loader.update = update
yield* rebuild()
})
}),
transform: state.transform,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
@ -247,11 +215,11 @@ export const layer = Layer.effect(
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return globalThis.Array.from(HashMap.values(records))
return Array.fromIterable(state.get().providers.values())
.map((record) => record.provider)
.filter((provider) => provider.enabled)
}),
@ -267,9 +235,8 @@ export const layer = Layer.effect(
all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe(
records,
HashMap.toValues,
Array.flatMap((record) => globalThis.Array.from(record.models.values())),
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => Array.fromIterable(record.models.values())),
Array.map(resolve),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
@ -277,12 +244,13 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.model.available")(function* () {
return (yield* result.model.all()).filter((model) => {
const record = Option.getOrUndefined(HashMap.get(records, model.providerID))
const record = state.get().providers.get(model.providerID)
return record?.provider.enabled !== false && model.enabled
})
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
@ -295,13 +263,8 @@ export const layer = Layer.effect(
)
}),
setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) {
yield* result.model.get(providerID, modelID)
defaultModel = { providerID, modelID }
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = Option.getOrUndefined(HashMap.get(records, providerID))
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
if (providerID === ProviderV2.ID.opencode) {
@ -310,7 +273,7 @@ export const layer = Layer.effect(
}
const candidates = pipe(
globalThis.Array.from(record.models.values()),
Array.fromIterable(record.models.values()),
Array.filter(
(model) =>
model.providerID === providerID &&

202
packages/core/src/config.ts Normal file
View file

@ -0,0 +1,202 @@
export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionV2 } from "./permission"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigWatcher } from "./config/watcher"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: Schema.String.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional).annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
description: "Attachment processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
}),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
export const FileSource = Schema.Struct({
type: Schema.Literal("file"),
path: Schema.String,
}).annotate({ identifier: "Config.FileSource" })
export type FileSource = typeof FileSource.Type
export const MemorySource = Schema.Struct({
type: Schema.Literal("memory"),
}).annotate({ identifier: "Config.MemorySource" })
export type MemorySource = typeof MemorySource.Type
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
source: Source,
info: Info,
}) {}
export interface Interface {
/** Returns supplemental config directories from lowest to highest priority. */
readonly directories: () => Effect.Effect<AbsolutePath[]>
/** Loads location config files from lowest to highest priority. */
readonly get: () => Effect.Effect<Loaded[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const names = ["config.json", "opencode.json", "opencode.jsonc"]
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
// Accept legacy fields while v2 is migrated incrementally; recognized
// fields still have to satisfy the v2 schema.
const info = Option.getOrUndefined(
Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
)
if (!info) return
return new Loaded({ source: { type: "file", path: filepath }, info })
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
)
})
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const directories = locationIsGlobal
? [globalDirectory]
: [
globalDirectory,
...(yield* fs
.up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
.pipe(Effect.orDie))
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = locationIsGlobal
? []
: (yield* fs
.up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
.pipe(Effect.orDie)).toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? []))
return Service.of({
directories: Effect.fn("Config.directories")(function* () {
return directories
}),
get: Effect.fn("Config.get")(function* () {
return configs
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
)

View file

@ -0,0 +1,25 @@
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { PermissionV2 } from "../permission"
import { ConfigProvider } from "./provider"
import { PositiveInt } from "../schema"
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
model: Schema.String.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
options: ConfigProvider.Options.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: PermissionV2.Ruleset.pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,15 @@
export * as ConfigAttachments from "./attachments"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("ConfigV2.Attachments.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Attachments")({
image: Image.pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,16 @@
export * as ConfigCompaction from "./compaction"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
turns: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
prune: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,18 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("ConfigV2.Experimental.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -0,0 +1,12 @@
export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("ConfigV2.Formatter.Entry")({
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])

View file

@ -0,0 +1,18 @@
export * as ConfigLSP from "./lsp"
import { Schema } from "effect"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
})
export class Server extends Schema.Class<Server>("ConfigV2.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])

View file

@ -0,0 +1,36 @@
export * as ConfigMCP from "./mcp"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
timeout: PositiveInt.pipe(Schema.optional),
}) {}
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
timeout: PositiveInt.pipe(Schema.optional),
}) {}
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
timeout: PositiveInt.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,13 @@
export * as ConfigPlugin from "./plugin"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("ConfigV2.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
export type Plugin = typeof Plugin.Type
export const Plugins = Plugin.pipe(Schema.Array)

View file

@ -0,0 +1,66 @@
export * as ConfigAgentPlugin from "./agent"
import { Effect } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { PluginV2 } from "../../plugin"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
const config = yield* Config.Service
const transform = yield* agent.transform()
const files = yield* config.get()
yield* transform((editor) => {
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
for (const file of files) {
for (const [id, item] of Object.entries(file.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
permissions.delete(agentID)
continue
}
editor.update(agentID, (agent) => {
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.options !== undefined) {
Object.assign(agent.options.headers, item.options.headers ?? {})
Object.assign(agent.options.body, item.options.body ?? {})
Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {})
Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
})
if (item.permissions !== undefined) {
permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions])
}
}
}
const global = files.flatMap((file) => file.info.permissions ?? [])
for (const current of editor.list()) {
editor.update(current.id, (agent) => {
agent.permissions.push(...global, ...(permissions.get(current.id) ?? []))
})
}
})
}),
})

View file

@ -0,0 +1,95 @@
export * as ConfigProviderPlugin from "./provider"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-provider"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const transform = yield* catalog.transform()
const files = yield* config.get()
yield* transform((catalog) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.env !== undefined) provider.env = [...item.env]
provider.enabled = { via: "custom", data: {} }
if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint }
if (item.options !== undefined) {
Object.assign(provider.options.headers, item.options.headers ?? {})
Object.assign(provider.options.body, item.options.body ?? {})
Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {})
Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {})
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
if (config.api_id !== undefined) model.apiID = config.api_id
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.options !== undefined) {
Object.assign(model.options.headers, config.options.headers ?? {})
Object.assign(model.options.body, config.options.body ?? {})
Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {})
Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {})
if (config.options.variant !== undefined) model.options.variant = config.options.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
}
model.variants.push(existing)
}
Object.assign(existing.headers, variant.headers ?? {})
Object.assign(existing.body, variant.body ?? {})
Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {})
Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
})
}),
})

View file

@ -0,0 +1,62 @@
export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
export class Options extends Schema.Class<Options>("ConfigV2.Provider.Options")({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
aisdk: Schema.Struct({
provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}).pipe(Schema.optional),
}) {}
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
read: Schema.Finite.pipe(Schema.optional),
write: Schema.Finite.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("ConfigV2.Model")({
api_id: ModelV2.ID.pipe(Schema.optional),
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
options: Schema.Struct({
...Options.fields,
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
variants: Schema.Struct({
id: ModelV2.VariantID,
...Options.fields,
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
options: Options.pipe(Schema.optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,17 @@
export * as ConfigReference from "./reference"
import { Schema } from "effect"
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
path: Schema.String,
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
export type Entry = typeof Entry.Type
export const Info = Schema.Record(Schema.String, Entry)

View file

@ -0,0 +1,9 @@
export * as ConfigToolOutput from "./tool-output"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Info extends Schema.Class<Info>("ConfigV2.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,7 @@
export * as ConfigWatcher from "./watcher"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Watcher")({
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -128,7 +128,7 @@ export const layer = Layer.effect(
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.version === undefined ? {} : { version: definition.version }),
...(location ? { location } : {}),
...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}),
data,
} as Payload<D>
return yield* publishEvent(event)

View file

@ -2,12 +2,17 @@ import { Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
import { Policy } from "./policy"
import { Config } from "./config"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) =>
Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
),
idleTimeToLive: "5 minutes",
lookup: (ref: Location.Ref) => {
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe(
Layer.provideMerge(Policy.defaultLayer),
Layer.provideMerge(Location.defaultLayer(ref)),
)
return result
},
idleTimeToLive: "60 minutes",
dependencies: [],
}) {}

View file

@ -1,11 +1,40 @@
import { Context, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
directory: AbsolutePath,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
export interface Interface {
readonly directory: AbsolutePath
readonly workspaceID?: string
readonly project: {
readonly id: Project.ID
readonly directory: AbsolutePath
}
readonly vcs?: Project.Vcs
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
})
}),
)
export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer))

View file

@ -36,7 +36,7 @@ export const Cost = Schema.Struct({
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderV2.ID,
variant: VariantID,
variant: VariantID.pipe(Schema.optional),
})
export type Ref = typeof Ref.Type

View file

@ -2,17 +2,26 @@ export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
import type { ModelV2 } from "./model"
import type { AgentV2 } from "./agent"
import type { Catalog } from "./catalog"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
export const Event = {
Added: EventV2.define({
type: "plugin.added",
schema: {
id: ID,
},
}),
}
type HookSpec = {
"catalog.transform": {
input: Catalog.Context
input: Catalog.Editor
output: {}
}
"account.switched": {
@ -43,27 +52,6 @@ type HookSpec = {
sdk?: any
}
}
"agent.update": {
input: {}
output: {
agent: AgentV2.Info
cancel: boolean
}
}
"agent.remove": {
input: {
agent: AgentV2.Info
}
output: {
cancel: boolean
}
}
"agent.default": {
input: {}
output: {
agent?: AgentV2.ID
}
}
}
export type Hooks = {
@ -93,7 +81,6 @@ export interface Interface {
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void>
readonly added: () => Stream.Stream<ID>
readonly triggerFor: <Name extends keyof Hooks>(
id: ID,
name: Name,
@ -117,9 +104,7 @@ export const layer = Layer.effect(
hooks: HookFunctions
scope: Scope.Closeable
}[] = []
const added = yield* PubSub.unbounded<ID>()
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
const events = yield* EventV2.Service
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
@ -135,9 +120,8 @@ export const layer = Layer.effect(
scope,
},
]
yield* PubSub.publish(added, input.id)
yield* events.publish(Event.Added, { id: input.id })
}),
added: () => Stream.fromPubSub(added),
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
return yield* svc.triggerFor(ID.make("*"), name, input, output)
}),
@ -185,7 +169,7 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
// opencode
// sdcok

View file

@ -19,7 +19,7 @@ export const AccountPlugin = PluginV2.define({
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -4,10 +4,13 @@ import { Context, Deferred, Effect, Layer } from "effect"
import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { EventV2 } from "../event"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
@ -15,7 +18,7 @@ import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: PluginV2.Effect<
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service
>
}
@ -28,10 +31,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const done = yield* Deferred.make<void>()
@ -41,8 +45,9 @@ export const layer = Layer.effect(
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AgentV2.Service, agent),
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
@ -57,6 +62,8 @@ export const layer = Layer.effect(
yield* add(item)
}
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@ -72,10 +79,11 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Catalog.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(AccountV2.defaultLayer),
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Npm.defaultLayer),
)

View file

@ -6,7 +6,7 @@ export const EnvPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
const key = item.provider.env.find((env) => process.env[env])
if (!key) continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -57,10 +57,10 @@ export const ModelsDevPlugin = PluginV2.define({
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
yield* load((catalog) => {
yield* transform((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {

View file

@ -51,7 +51,7 @@ export const AmazonBedrockPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -6,7 +6,7 @@ export const AnthropicPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -15,7 +15,7 @@ export const AzurePlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
const configured = item.provider.options.aisdk.provider.resourceName
@ -58,7 +58,7 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
"catalog.transform": Effect.fn(function* (evt) {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue

View file

@ -6,7 +6,7 @@ export const CerebrasPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (ctx) {
for (const item of ctx.data) {
for (const item of ctx.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
ctx.provider.update(item.provider.id, (provider) => {

View file

@ -11,7 +11,7 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === providerID)
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return

View file

@ -31,7 +31,7 @@ export const GithubCopilotPlugin = PluginV2.define({
: evt.sdk.chat(evt.model.apiID)
}),
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,

View file

@ -59,7 +59,7 @@ export const GoogleVertexPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (
item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
@ -110,7 +110,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =

View file

@ -6,7 +6,7 @@ export const KiloPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue

View file

@ -6,7 +6,7 @@ export const LLMGatewayPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.enabled === false) continue
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue

View file

@ -6,7 +6,7 @@ export const NvidiaPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue

View file

@ -17,7 +17,7 @@ export const OpenAIPlugin = PluginV2.define({
evt.language = evt.sdk.responses(evt.model.apiID)
}),
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue

View file

@ -8,7 +8,7 @@ export const OpencodePlugin = PluginV2.define({
let hasKey = false
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||

View file

@ -7,7 +7,7 @@ export const OpenRouterPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -6,7 +6,7 @@ export const VercelPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -6,7 +6,7 @@ export const ZenmuxPlugin = PluginV2.define({
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
for (const item of evt.provider.list()) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue

View file

@ -0,0 +1,44 @@
export * as Policy from "./policy"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type Effect = typeof Effect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
export const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: EffectRuntime.fn("Policy.load")(function* (input) {
statements = input
}),
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const defaultLayer = layer

View file

@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
vcs: Schema.optional(Vcs),
}) {}
export interface Interface {
@ -105,7 +104,7 @@ export const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))

View file

@ -0,0 +1,67 @@
export * as State from "./state"
import { Effect, Scope, Semaphore } from "effect"
import { createDraft, finishDraft, type Draft, type Objectish } from "immer"
export type Transform<Editor> = (editor: Editor) => void
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
export interface Options<State extends Objectish, Editor> {
readonly initial: () => State
readonly editor: MakeEditor<State, Editor>
/** Completes every committed edit; reason identifies exceptional update origins. */
readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect<void>
}
export interface Interface<State extends Objectish, Editor> {
readonly get: () => State
readonly transform: () => Effect.Effect<
(transform: Transform<Editor>) => Effect.Effect<void>,
never,
Scope.Scope
>
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
}
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
let state = options.initial()
let transforms: { update: Transform<Editor> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (draft: Draft<State>, reason?: string) {
const api = options.editor(draft)
if (options.finalize) yield* options.finalize(api, reason)
state = finishDraft(draft) as State
})
const rebuild = Effect.fn("State.rebuild")(function* () {
const draft = createDraft(options.initial())
const api = options.editor(draft)
for (const transform of transforms) transform.update(api)
yield* commit(draft)
}, semaphore.withPermit)
return {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update: Transform<Editor>) {
transform.update = update
yield* rebuild()
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
const draft = createDraft(state)
yield* update(options.editor(draft))
yield* commit(draft, reason)
}, semaphore.withPermit),
}
}

View file

@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer)
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Context {
): Catalog.Editor {
return {
data: records,
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
provider: {
list: () => records,
get: (providerID) => records.find((item) => item.provider.id === providerID),
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
@ -45,8 +44,13 @@ function context(
},
},
model: {
get: () => undefined,
update: () => {},
remove: () => {},
default: {
get: () => undefined,
set: () => {},
},
},
}
}
@ -192,7 +196,7 @@ describe("AccountV2", () => {
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
transform: () => Effect.die("unexpected catalog.transform"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
@ -203,7 +207,6 @@ describe("AccountV2", () => {
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})

View file

@ -0,0 +1,105 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { testEffect } from "./lib/effect"
const it = testEffect(AgentV2.defaultLayer)
describe("AgentV2", () => {
it.effect("starts without agents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
expect(yield* agent.all()).toEqual([])
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
}),
)
it.effect("materializes replayable agent transforms", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "Reviews code"
info.mode = "subagent"
}),
)
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "Old description"
info.hidden = true
}),
)
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "New description"
}),
)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
)
it.effect("removes a transform contribution when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
const scope = yield* Scope.make()
const transform = yield* agent.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("applies direct agent updates", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("build")
yield* agent.update((editor) =>
Effect.sync(() =>
editor.update(id, (info) => {
info.mode = "primary"
info.hidden = true
}),
),
)
expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true })
}),
)
it.effect("creates agents with runtime defaults and supports direct removal", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("custom")
yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {})))
expect(yield* agent.get(id)).toEqual(
AgentV2.Info.empty(id),
)
yield* agent.update((editor) => Effect.sync(() => editor.remove(id)))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
})

View file

@ -5,14 +5,21 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
@ -22,9 +29,9 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@ -48,9 +55,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@ -77,9 +84,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@ -104,14 +111,14 @@ describe("CatalogV2", () => {
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = []
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* plugin.add({
id: PluginV2.ID.make("test"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
Effect.sync(() => {
const item = evt.data.find((record) => record.provider.id === providerID)
const item = evt.provider.get(providerID)
if (!item) return
seen.push(item.provider.endpoint.type)
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
@ -119,7 +126,7 @@ describe("CatalogV2", () => {
}),
}),
})
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
@ -135,9 +142,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.name = "Before"
}),
@ -164,9 +171,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider"
@ -194,9 +201,9 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
@ -212,13 +219,44 @@ describe("CatalogV2", () => {
}),
)
it.effect("uses a transform-provided default model until that transform is replaced", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const old = ModelV2.ID.make("old")
const newest = ModelV2.ID.make("new")
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
catalog.model.update(providerID, newest, (model) => {
model.time.released = DateTime.makeUnsafe(2000)
})
}
yield* transform((catalog) => {
models(catalog)
catalog.model.default.set(providerID, old)
})
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
yield* transform(models)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
}),
)
it.effect("small model prefers small keyword candidates before cost scoring", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
@ -237,4 +275,23 @@ describe("CatalogV2", () => {
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
const transform = yield* catalog.transform()
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
}),
)
})

View file

@ -0,0 +1,186 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { testEffect } from "../lib/effect"
const it = testEffect(AgentV2.defaultLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const defaults = yield* agents.transform()
yield* defaults((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
}),
)
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
agents: {
build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
},
removed: { description: "Removed later" },
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: { variant: "high", hidden: true },
removed: { disabled: true },
},
}),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
description: "Review changes",
mode: "subagent",
hidden: true,
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "*", action: "deny" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
)
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: {
model: "anthropic/claude-sonnet",
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
steps: 12,
options: {
headers: { first: "one", shared: "first" },
body: { enabled: true },
aisdk: { provider: { profile: "review" }, request: { effort: "medium" } },
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: {
options: {
headers: { shared: "last", second: "two" },
body: { retries: 2 },
aisdk: { request: { effort: "high" } },
},
},
},
}),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
steps: 12,
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
})
expect(reviewer.options).toEqual({
headers: { first: "one", shared: "last", second: "two" },
body: { enabled: true, retries: 2 },
aisdk: { provider: { profile: "review" }, request: { effort: "high" } },
})
}),
)
it.effect("removes a built-in agent disabled by configuration", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const defaults = yield* agents.transform()
yield* defaults((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({ agents: { build: { disabled: true } } }),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
expect(yield* agents.get(build)).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,451 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
function testLayer(
directory: string,
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
) {
return Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provideMerge(Policy.defaultLayer),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
),
),
),
)
}
const provider = {
endpoint: { type: "unknown" },
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
models: {},
}
describe("Config", () => {
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(
path.join(tmp.path, "config.json"),
JSON.stringify({ $schema: "base", providers: { base: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Later global files override scalar fields while retaining providers.
"$schema": "last",
"providers": { "last": ${JSON.stringify(provider)} },
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(3)
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
expect(documents[0]).toBeInstanceOf(Config.Loaded)
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
path.join(tmp.path, "config.json"),
)
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
)
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({
shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider },
})
yield* Effect.promise(() => fs.writeFile(file, contents))
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
shell: "/bin/bash",
model: "anthropic/claude",
autoupdate: "notify",
share: "disabled",
enterprise: { url: "https://share.example.com" },
username: "test-user",
permissions: [
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
],
agents: {
reviewer: {
model: "openrouter/openai/gpt-5",
variant: "high",
options: {
headers: { "x-agent": "reviewer" },
aisdk: { request: { reasoningEffort: "high" } },
},
description: "Review changes for correctness",
system: "Find regressions.",
mode: "subagent",
hidden: false,
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
},
},
snapshots: false,
watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } },
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
attachments: { image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 } },
tool_output: { max_lines: 1000, max_bytes: 32768 },
mcp: {
timeout: 5000,
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: 10000,
},
remote: {
type: "remote",
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
},
},
},
compaction: {
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
},
skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
references: {
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
},
plugins: [
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
],
}),
),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(1)
expect(documents[0]?.info.shell).toBe("/bin/bash")
expect(documents[0]?.info.model).toBe("anthropic/claude")
expect(documents[0]?.info.autoupdate).toBe("notify")
expect(documents[0]?.info.share).toBe("disabled")
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5",
variant: "high",
options: {
headers: { "x-agent": "reviewer" },
aisdk: { request: { reasoningEffort: "high" } },
},
description: "Review changes for correctness",
system: "Find regressions.",
mode: "subagent",
hidden: false,
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
})
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
expect(documents[0]?.info.formatter).toEqual({
prettier: { disabled: true },
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
})
expect(documents[0]?.info.lsp).toEqual({
typescript: { disabled: true },
custom: { command: ["custom-lsp"], extensions: [".foo"] },
})
expect(documents[0]?.info.attachments).toEqual({
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
expect(documents[0]?.info.mcp).toEqual({
timeout: 5000,
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: 10000,
},
remote: {
type: "remote",
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
},
},
})
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.skills).toEqual([
"./skills",
"~/shared-skills",
"https://example.com/.well-known/skills/",
])
expect(documents[0]?.info.instructions).toEqual([
"CONTRIBUTING.md",
".cursor/rules/*.md",
"https://example.com/shared-rules.md",
])
expect(documents[0]?.info.references).toEqual({
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
})
expect(documents[0]?.info.plugins).toEqual([
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("ignores invalid files while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
const parent = path.join(root, "packages")
const directory = path.join(parent, "app")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await Promise.all([
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
fs.writeFile(
path.join(directory, ".opencode", "opencode.jsonc"),
JSON.stringify({ $schema: "directory-dot" }),
),
])
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const directories = yield* config.directories()
const documents = yield* config.get()
expect(directories).toEqual([
AbsolutePath.make(global),
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"root",
"parent",
"directory",
"root-dot",
"directory-dot",
])
}).pipe(
Effect.provide(
testLayer(directory, global, root, {
type: "git",
store: AbsolutePath.make(path.join(root, ".git")),
}),
),
)
})
}),
),
)
})

View file

@ -0,0 +1,131 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
function options(headers: Record<string, string>, variant?: string) {
return {
headers,
variant,
}
}
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
endpoint: { type: "unknown" },
options: options({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
options: options({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: {
endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
options: options({ last: "last", shared: "last" }),
models: {
chat: {
api_id: "api-chat",
name: "Last",
limit: { output: 75 },
options: options({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.options.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
)
})

View file

@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
@ -46,7 +48,7 @@ describe("EventV2", () => {
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
}),
)

View file

@ -0,0 +1,12 @@
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
} satisfies Location.Interface
}

View file

@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
}),
)
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
Effect.gen(function* () {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe("workspace")
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({
type: "git",
store: AbsolutePath.make("/repo/.git"),
})
}),
)
})

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(ModelV2.Ref)
describe("ModelV2.Ref", () => {
test("accepts a model selection without a variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
id: ModelV2.ID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
})
})
test("preserves an explicit model variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({
id: ModelV2.ID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
})
})
})

View file

@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AmazonBedrockPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const bedrock = provider("amazon-bedrock", {
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
options: {

View file

@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("anthropic", {
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
}),
)

View file

@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure-cognitive-services", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
})

View file

@ -5,9 +5,12 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
@ -16,7 +19,10 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
@ -28,8 +34,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
@ -45,8 +51,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
@ -94,8 +100,8 @@ describe("AzurePlugin", () => {
),
})
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
@ -113,8 +119,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
@ -135,8 +141,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },

View file

@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.options.headers.Existing = "1"
@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
}),
)

View file

@ -6,9 +6,12 @@ import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
@ -17,7 +20,10 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
@ -48,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
@ -80,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}),
@ -146,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => {
),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
@ -167,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
provider.options.aisdk.provider.accountId = "configured-acct"

View file

@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})

View file

@ -5,9 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
@ -27,12 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({
}))
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
Layer.mergeAll(
Catalog.defaultLayer,
PluginV2.defaultLayer,
AccountV2.defaultLayer,
EventV2.defaultLayer,
npmLayer,
).pipe(
Layer.provide(Policy.defaultLayer),
Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })),
),
)
@ -179,8 +184,8 @@ describe("GitLabPlugin", () => {
),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
@ -227,8 +232,8 @@ describe("GitLabPlugin", () => {
),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",

View file

@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
provider.options.aisdk.provider.project = "configured-project"

View file

@ -50,8 +50,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@ -89,8 +89,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@ -139,8 +139,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@ -168,8 +168,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@ -204,8 +204,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.options.aisdk.provider.project = "config-project"

View file

@ -7,11 +7,17 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
export const npmLayer = Layer.succeed(
Npm.Service,
@ -25,7 +31,7 @@ export const npmLayer = Layer.succeed(
export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
transform: () => Effect.die("unexpected catalog.transform"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
@ -36,7 +42,6 @@ export const catalogLayer = Layer.succeed(
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
@ -46,6 +51,7 @@ export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),

View file

@ -22,8 +22,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -48,8 +48,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
@ -74,8 +74,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})

View file

@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("llmgateway", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
})

View file

@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const nvidia = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: {

View file

@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("custom-openai")
catalog.provider.update(item.id, () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})

View file

@ -5,11 +5,17 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
@ -18,8 +24,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
@ -39,8 +45,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const free = model("opencode", "free", { cost: cost(0) })
@ -60,8 +66,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
@ -81,8 +87,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
@ -102,8 +108,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
@ -125,8 +131,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
options: {
headers: {},
@ -157,8 +163,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
@ -180,8 +186,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai")
catalog.provider.update(item.id, () => {})
const paid = model("openai", "paid", { cost: cost(1) })
@ -200,8 +206,8 @@ describe("OpencodePlugin", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
@ -220,6 +226,8 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
}).pipe(
Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))),
),
)
})

View file

@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
})
@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})

View file

@ -12,8 +12,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("vercel", {
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -36,8 +36,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
@ -69,8 +69,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
}),
)

View file

@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
})
@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: {
@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openrouter", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },

View file

@ -0,0 +1,83 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
Policy.defaultLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})

View file

@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),

View file

@ -3,6 +3,7 @@ import { Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
export const V2Command = effectCmd({
@ -37,7 +38,7 @@ export const V2Command = effectCmd({
Effect.withSpan("Cli.debug.v2"),
Effect.provide(
LocationServiceMap.get({
directory: process.cwd(),
directory: AbsolutePath.make(process.cwd()),
}),
),
Effect.provide(LocationServiceMap.layer),

View file

@ -43,6 +43,7 @@ import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ConfigExperimental } from "@opencode-ai/core/config/experimental"
const log = Log.create({ service: "config" })
@ -301,6 +302,9 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
}),
),
}).annotate({ identifier: "Config" })

View file

@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
return {
directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
}
}

View file

@ -166,7 +166,7 @@ export const layer = Layer.effect(
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined,
}
: undefined,
cost: row.cost,

View file

@ -3,9 +3,8 @@ import { createServer, type Server } from "node:http"
import { streamText } from "ai"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { Env } from "@/env"
import { Plugin } from "@/plugin"
@ -22,70 +21,66 @@ const it = testEffect(
Provider.defaultLayer,
Env.defaultLayer,
Plugin.defaultLayer,
TestLLMServer.layer,
CrossSpawnSpawner.defaultLayer,
),
)
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedBodyServer(250)),
(server) => Effect.sync(() => server.server.close()),
)
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
expect(yield* Effect.promise(() => result.text)).toBe("late")
}),
{
config: (url) => {
const config = testProviderConfig(url)
return {
...config,
provider: {
test: {
...config.provider.test,
options: { ...config.provider.test.options, headerTimeout: 50 },
},
},
}
},
},
),
expect(yield* Effect.promise(() => result.text)).toBe("late")
}),
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
)
}),
)
it.live("chunkTimeout raises a response stream error when SSE body stalls", () =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedBodyServer(250)),
(server) => Effect.sync(() => server.server.close()),
)
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
messages: [{ role: "user", content: "hello" }],
})
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
messages: [{ role: "user", content: "hello" }],
})
const error = yield* Effect.promise(async () => {
try {
for await (const part of result.fullStream) {
if (part.type === "error") return part.error
const error = yield* Effect.promise(async () => {
try {
for await (const part of result.fullStream) {
if (part.type === "error") return part.error
}
} catch (error) {
return error
}
} catch (error) {
return error
}
})
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
}),
{ config: (url) => providerConfig(url, { chunkTimeout: 50 }) },
),
})
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
}),
{ config: providerConfig(server.url, { chunkTimeout: 50 }) },
)
}),
)
it.live("headerTimeout aborts when response headers do not arrive", () =>
@ -205,6 +200,20 @@ async function delayedHeaderServer(delay: number): Promise<{ server: Server; url
return { server, url: `http://127.0.0.1:${address.port}` }
}
async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> {
const server = createServer((_, res) => {
res.writeHead(200, { "content-type": "text/event-stream" })
res.flushHeaders()
setTimeout(() => {
res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n')
}, delay)
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address()
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
return { server, url: `http://127.0.0.1:${address.port}` }
}
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
return Effect.acquireUseRelease(
Effect.sync(() => {

View file

@ -1,6 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
import { describe, expect, test } from "bun:test"
import { tool, type ModelMessage, type JSONValue } from "ai"
@ -276,6 +277,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedClient = LLMClient.layer.pipe(

View file

@ -259,6 +259,7 @@ export default defineConfig({
"commands",
"formatters",
"permissions",
"policies",
"lsp",
"mcp-servers",
"acp",

View file

@ -393,6 +393,29 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
---
### Policies
Use the `experimental.policies` option to allow or deny OpenCode actions on configured resources. Currently, policies can control which providers OpenCode may use.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai"
}
]
}
}
```
[Learn more about policies here](/docs/policies).
---
### Image attachments
OpenCode normalizes image attachments before sending them to the model. By default, images are resized when they exceed `2000x2000` pixels or `5242880` base64 bytes.

View file

@ -0,0 +1,137 @@
---
title: Policies
description: Control which configured resources OpenCode may use.
---
Policies control whether OpenCode may perform an action on a named resource. This feature is experimental and is configured with the `experimental.policies` array in `opencode.json`.
Policies are separate from [permissions](/docs/permissions). Permissions control what tools can do during a session, while policies control whether OpenCode may use a resource such as an LLM provider.
---
## Configuration
Each policy statement has three fields:
- `effect` - Either `"allow"` or `"deny"`.
- `action` - The operation being controlled.
- `resource` - The resource ID or wildcard pattern the statement applies to.
For example, deny use of the `openai` provider:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai"
}
]
}
}
```
A provider denied by policy is not available for model selection or model use, even if it has credentials or is otherwise configured correctly.
---
## Available Policies
OpenCode currently supports one policy action:
| Action | Resource | Description |
| -------------- | ------------------------------ | ------------------------------------------ |
| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. |
More policy actions may be added in the future.
---
## Matching
The `resource` field supports wildcard matching. Use `*` to match zero or more characters and `?` to match one character.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "company-*"
}
]
}
}
```
This denies providers such as `company-us` and `company-eu`.
---
## Rule Order
When multiple statements match, the last matching statement wins. Put broad rules first, then more specific exceptions after them.
For example, allow only Anthropic:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*"
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic"
}
]
}
}
```
If no policy matches a provider, provider use is allowed by default.
Policies may be set in both your global config and project config. If policies from both locations match the same provider, your global policy takes priority over the project policy. This prevents a repository from re-enabling a provider that you deny globally.
---
## Provider Lists
Use policies instead of the older `disabled_providers` and `enabled_providers` settings when controlling provider access.
To replace `disabled_providers`:
```json title="opencode.json"
{
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "openai" },
{ "effect": "deny", "action": "provider.use", "resource": "google" }
]
}
}
```
To replace `enabled_providers`, deny all providers first and allow the selected providers after it:
```json title="opencode.json"
{
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "anthropic" },
{ "effect": "allow", "action": "provider.use", "resource": "openai" }
]
}
}
```

View file

@ -0,0 +1,326 @@
# Catalog / Config / Plugin Lifecycle Options
We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option.
## Scenarios
- Initial load: a location opens, built-in/configured plugins activate, and the first visible catalog is constructed.
- Config: authored provider/model definitions and overrides.
- models.dev: remote provider/model data refreshed on a timer.
- Auth: active credentials enable/configure providers and can later disappear.
- Plugin activation: a plugin starts contributing while the location is open.
- Plugin disablement: a plugin stops contributing and its influence must disappear.
- Config edit: authored configuration changes while the location is open.
- Policy: allowed/denied provider selection changes after providers exist.
## A. Config Transforms, Service Reload
`Config` merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving `Draft<Config.Info>` and may mutate any config field.
```ts
type ConfigTransform = (config: Draft<Config.Info>) => void
const transform = yield * Config.transform()
yield *
transform((config) => {
config.providers ??= {}
config.providers.acme = {
/* ... */
}
config.model = "acme/code"
config.permissions = [
/* ... */
]
})
```
Because a transform can mutate any part of config, a transform change cannot safely trigger only `Catalog.reload()` or any other granular subset. Every service derived from config must reload in place from the newly transformed config.
```ts
const transform = yield* Config.transform()
yield* transform((draft) => mutateAnyConfigField(draft))
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Agent.reload()
→ MCP.reload()
→ other config-consuming services reload
```
### Initial Load
Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests.
```ts
LocationServiceMap.get(ref)
→ build location layer
→ Config.layer reads authored documents
→ merge authored documents
→ run currently active Config transforms
→ Policy.layer reads transformed Config
→ Catalog.layer reads transformed Config
→ materialize baseline provider/model catalog
→ PluginBoot baseline ready
→ Frontend.fetchCatalog()
PluginBoot background fiber
→ install/update plugin packages concurrently
→ activate completed plugins
→ Config.transform()
→ transform(updateConfig)
→ ReloadScheduler.request()
→ debounce short burst of completed activations
→ Reload.all()
→ Config.get()
→ run newly active Config transforms
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
The initial layer build is not a reload. `Reload.all()` only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field.
### Config
```ts
config file loaded
→ config source/watch trigger records new documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### models.dev
```ts
timer fires
→ ModelsDevPlugin.refresh()
→ ModelsDev.get()
→ transform(applyModelsDevToConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
`Catalog` does not know about `ModelsDev`; the plugin transforms config before catalog reads it.
### Auth
```ts
Account.switched(providerID)
→ AuthPlugin.refresh(providerID)
→ Account.active(providerID)
→ transform(applyAuthToConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### Plugin Activation
```ts
Plugin.activate("acme-models")
→ Config.transform()
→ transform(applyAcmeConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### Plugin Disablement
```ts
Plugin.disable("company-naming")
→ close plugin scope
→ Config internally unregisters transform in finalizer
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ sonnet.name = "Sonnet"
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### Config Edit
```ts
file watcher sees edit
→ config source/watch trigger records updated documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### Policy
```ts
policy config changes
→ config source/watch trigger records updated documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ apply updated policy
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
### Tradeoffs
- A plugin receives `Draft<Config.Info>`, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform.
- Plugin disablement removes its config transform and lets services rematerialize without manual undo.
- models.dev and auth become config transforms rather than catalog dependencies.
- `Config` owns merge/order semantics for fields visible to transforms.
- Granular service reload is not safe because a config transform can mutate anything; every config-consuming service reloads after any transform change.
- `Catalog` depends on provider/model config semantics and is part of that full service reload.
- One reload produces at most one `Catalog.Event.Updated` notification.
- Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup.
## B. Catalog Transforms
Plugins register replayable catalog transforms. Each transform receives a `Catalog.Editor` whose helper methods mutate a private catalog draft; `Catalog` rematerializes visible records from its active transforms.
```ts
interface Catalog {
transform(): Effect.Effect<
(update: (catalog: Catalog.Editor) => void) => Effect.Effect<void>,
never,
Scope.Scope
>
}
```
```ts
const transform = yield* Catalog.transform()
yield* transform(update)
→ replace this transform callback
→ apply active transforms in registration order
→ apply policy
→ commit diff
→ Event.publish(Catalog.Event.Updated)
→ Frontend.refetchCatalog()
```
### Initial Load
Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests.
```ts
LocationServiceMap.get(ref)
→ build location layer
→ Catalog.layer creates empty catalog state
→ PluginBoot.layer activates immediately available plugins
→ ConfigProviderPlugin installs Catalog.transform()
→ ModelsDevPlugin installs Catalog.transform()
→ AuthPlugin installs Catalog.transform()
→ Catalog.layer applies active transforms during boot
→ apply policy
→ materialize baseline provider/model catalog
→ PluginBoot baseline ready
→ Frontend.fetchCatalog()
PluginBoot background fiber
→ install/update plugin packages concurrently
→ activate completed plugins
→ Catalog.transform()
→ transform(updateCatalog)
→ Catalog internally rebuilds
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
```
Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself.
### Config
```ts
config file loaded
→ ConfigProviderAdapter.load()
→ transform(applyConfigToCatalog)
→ Catalog internally rebuilds
```
### models.dev
```ts
timer fires
→ ModelsDevPlugin.refresh()
→ ModelsDev.get()
→ transform(applyModelsDevToCatalog)
→ Catalog internally rebuilds
→ commit diff
```
### Auth
```ts
Account.switched(providerID)
→ AuthPlugin.refresh()
→ transform(applyAuthToCatalog)
→ Catalog internally rebuilds
→ replay active transforms including current auth
→ apply policy
→ commit diff
```
### Plugin Activation
```ts
Plugin.activate("acme-models")
→ Catalog.transform()
→ transform(applyAcmeToCatalog)
→ Catalog internally rebuilds
→ commit diff
```
### Plugin Disablement
```ts
Plugin.disable("company-naming")
→ close plugin scope
→ Catalog internally unregisters transform in finalizer
→ Catalog internally rebuilds
→ sonnet.name = "Sonnet"
→ commit diff
```
### Config Edit
```ts
file watcher sees edit
→ ConfigProviderAdapter.load()
→ transform(applyUpdatedConfigToCatalog)
→ Catalog internally rebuilds
```
### Policy
```ts
policy changes
→ Catalog rebuild trigger
→ replay all active transforms
→ apply updated policy last
→ commit diff
```
### Tradeoffs
- Disablement, source refresh, and policy re-evaluation are transform replay operations.
- Auth does not need to be represented as config.
- Config remains one catalog source rather than a catalog dependency.
- The API shape matches A, but the mutable draft is catalog state instead of configuration state.
- Catalog needs transform ordering and internal rebuild behavior in addition to reads.
- Recompute ordering, serialization, and diff events must be specified.
- One internal rebuild produces at most one `Catalog.Event.Updated` notification.
- Deferred plugin activation avoids blocking readiness and only rebuilds catalog for catalog transform changes.
- Debouncing those rebuilds needs an additional batching interface or an activation coordinator that installs multiple transforms before exposing updates.

394
specs/v2/config.md Normal file
View file

@ -0,0 +1,394 @@
# V2 Config Review
This document breaks the legacy configuration schema into small review groups. Work through one group at a time and decide whether each field should be ported as-is, removed, or redesigned for v2.
## Status Labels
- `pending`: not discussed yet
- `keep`: port with substantially the existing meaning
- `remove`: do not carry forward
- `redesign`: keep the capability with a different shape, scope, or owning module
## Schema Scope
Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review.
## Group 1: File Metadata
Small fields describing the config file itself rather than application behavior.
| Field | Current Purpose | Status | Notes |
| --------- | ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- |
| `$schema` | JSON schema reference for editor validation and completion | keep | Keep as read-only metadata; loading config must not insert it or create files for it. |
## Group 2: Process And Server Settings
Settings that affect process startup, shell execution, or network serving. Review global-only versus location-specific scope carefully.
| Field | Current Purpose | Status | Notes |
| ------------ | --------------------------------------------------- | ------ | ------------------------------------------------------------------------------ |
| `shell` | Default shell for terminal and shell tool execution | keep | Port as effective config; shared shell choice is used throughout opencode. |
| `logLevel` | Intended logging level configuration | remove | Do not port: no config consumer exists and logging initializes from CLI input. |
| `server` | Hostname, port, mDNS, and CORS settings | remove | Do not port: location config is loaded after the server is already running. |
| `autoupdate` | Automatic update or notification behavior | keep | Global-only user preference; keep `true`, `false`, and `"notify"`. |
## Group 3: Commands And Project Resources
Configuration that introduces location-scoped project resources or discoverable content.
| Field | Current Purpose | Status | Notes |
| -------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- |
| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. |
| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. |
| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. |
| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. |
V2 does not expose separate user-authored command configuration. Skills should cover named reusable prompt workflows, whether invoked directly by the user or loaded by an agent. Internal command routing and built-in commands may remain runtime concerns without creating a `command` or `commands` config field.
This intentionally does not port legacy command-only behavior such as per-command `model`, `agent`, `subtask`, prompt shell expansion, or positional/template substitution. If a related capability is needed in v2, it should be designed in the owning domain rather than preserved through a second workflow definition system.
Keep `skills` as discovery-source configuration rather than inline workflow definitions. Skill content remains owned by `SKILL.md`; each `skills` entry is either a local search root or a remote discovery URL. Direct invocation behavior can be designed separately without expanding the config shape.
```jsonc
{
"skills": ["./team-skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
}
```
Keep ambient instructions separate from skills. Instructions are automatically included as model context, while skills are loaded or invoked intentionally. Each source is unambiguously either a local path/glob or a URL, so v2 keeps the simple array shape:
```jsonc
{
"instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
}
```
Keep named external context references as a v2 configuration capability, renamed to plural `references` because it is a collection keyed by alias. References declare local directories or Git repositories that can later be addressed as `@alias` or `@alias/path` when the v2 runtime implements this behavior.
```jsonc
{
"references": {
"design-system": { "path": "../ui-library" },
"sdk": { "repository": "github.com/example/sdk", "branch": "main" },
},
}
```
Retain the compact string entry form as well: values starting with `.`, `/`, or `~` represent local paths, and other strings represent Git repositories.
## Group 4: Plugins
Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources.
| Field | Current Purpose | Status | Notes |
| -------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. |
Plugin order remains part of the v2 configuration contract because hook registration and execution can depend on load order. Replace legacy option tuples with readable object entries:
```jsonc
{
"plugins": [
"opencode-helicone-session",
{
"package": "@my-org/audit-plugin",
"options": {
"endpoint": "https://audit.example.com",
},
},
],
}
```
The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.opencode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field.
## Group 5: Filesystem And Tool Runtime
Settings controlling local file observation, snapshots, language tooling, and tool output behavior.
| Field | Current Purpose | Status | Notes |
| ------------- | --------------------------------------- | ------- | ----- |
| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. |
| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. |
| `formatter` | Configure formatters | keep | Keep singular `boolean \| Record<string, entry>` shape; it configures built-in enablement and named formatter overrides. |
| `lsp` | Configure language servers | keep | Keep singular `boolean \| Record<string, entry>` shape; custom servers need commands and file extensions. |
| `attachment` | Configure attachment/image processing | redesign | Rename to plural `attachments`; retain `{ image?: { auto_resize?, max_width?, max_height?, max_base64_bytes? } }` for input normalization limits. |
| `tool_output` | Configure tool output truncation limits | keep | Keep `{ max_lines?, max_bytes? }`; both positive thresholds apply to saved-preview truncation behavior. |
`formatter` and `lsp` configure one project tooling subsystem each, so their singular names remain appropriate. `true` enables the built-in registrations, `false` disables them, and a keyed object enables built-ins while applying named overrides or custom registrations. Custom language servers must declare `extensions` so runtime file attachment is deterministic; validation of known built-in server IDs belongs with the eventual v2 LSP integration rather than the aggregate core config schema.
Rename legacy `attachment` to `attachments` in v2. This setting controls processing for the attachment domain and may expand beyond image handling, while singular `attachment` is already used as a model capability flag indicating whether one model accepts attachments.
```jsonc
{
"formatter": {
"prettier": { "disabled": true },
"project": { "command": ["./scripts/format", "$FILE"], "extensions": [".foo"] },
},
"lsp": {
"typescript": { "disabled": true },
"project": { "command": ["project-language-server", "--stdio"], "extensions": [".foo"] },
},
"attachments": {
"image": { "auto_resize": true, "max_width": 2000, "max_height": 2000 },
},
"tool_output": { "max_lines": 2000, "max_bytes": 51200 },
}
```
## Group 6: Sharing And Identity
Settings affecting sharing behavior or user/account identity rather than model execution.
| Field | Current Purpose | Status | Notes |
| ------------ | ----------------------------------------------- | ------- | ------------------------------- |
| `share` | Session sharing behavior | keep | Keep `"manual" \| "auto" \| "disabled"`; it controls manual sharing permission and automatic sharing of new sessions. |
| `autoshare` | Legacy automatic sharing flag | remove | Do not port deprecated alias; use `share: "auto"`. |
| `enterprise` | Enterprise URL configuration | keep | Keep `{ url?: string }`; currently selects the legacy sharing service endpoint when no organization account is active. |
| `username` | Display username in conversations and telemetry | keep | Keep string identity override; runtime may otherwise resolve an operating-system username. |
Retain `share` as the single session-sharing setting. `"manual"` permits explicit sharing, `"auto"` shares newly created top-level sessions, and `"disabled"` prevents sharing. Legacy `autoshare: true` is only an alias for `share: "auto"`, so v2 does not expose it.
Retain `enterprise.url` for legacy enterprise share hosting selection and `username` as a user-facing identity override. These remain separate from server authentication credentials; `username` identifies the user in conversation and telemetry behavior rather than HTTP basic-auth configuration.
```jsonc
{
"share": "disabled",
"enterprise": { "url": "https://share.example.com" },
"username": "developer",
}
```
## Group 7: Providers And Model Selection
Provider catalog customization and model-choice configuration. The new core work has started here.
| Field | Current Purpose | Status | Notes |
| -------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider` | Custom provider configuration and model overrides | redesign | Rename to plural `providers` in v2; do not preserve the legacy singular key. Review nested provider/model fields separately. |
| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. |
| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. |
| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. |
| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. |
Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape:
```jsonc
{
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*",
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic",
},
],
},
}
```
See [provider-policy.md](./provider-policy.md) for the provider policy semantics and precedence rules.
Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed.
Provider configuration uses the plural `providers` key in v2. This intentionally differs from the legacy singular `provider` key; v2 does not add a compatibility alias while its configuration surface is still being defined.
Keep `model` as the default model fallback. It is application-wide behavior used when an active session or agent has no explicit model selection, so it does not belong inside any individual provider configuration.
Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting.
Provider, model, variant, and provisional agent `options` are authored as partial patches rather than fully materialized runtime option records. Users should be able to set only the override they need, such as a header or an AI SDK request option; catalog state supplies empty defaults and merges patches in configuration order.
Keep provider `env` as an authored list of recognized credential environment variable names. Built-in catalog providers already carry this metadata for automatic environment-backed availability, and configured providers may need to declare the same source. For a configured provider this is additive metadata, not a requirement that one of the variables exists: the provider may instead be usable through configured options, a stored account, or an endpoint that needs no credential.
Within configured models, rename legacy upstream model identifier `id` to `api_id` rather than exposing camelCase runtime `apiID`. Model `limit` is an authored patch, so an override may change only `context`, `input`, or `output`. Model `cost` accepts one simple pricing object or an array of tiered pricing entries; omitted cache prices default to zero.
Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` flags as first-class config fields; provider/request behavior belongs in structured `options` or model variants. Do not port `release_date`, `status`, `experimental`, `whitelist`, or `blacklist` in this v2 surface.
```jsonc
{
"providers": {
"internal": {
"env": ["INTERNAL_LLM_API_KEY"],
"options": { "headers": { "Authorization": "Bearer {env:API_KEY}" } },
"models": {
"chat": {
"api_id": "upstream-chat-model",
"limit": { "output": 32768 },
"cost": { "input": 1.25, "output": 10 },
"variants": [
{ "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } },
],
},
},
},
},
}
```
## Group 8: Agents And Permissions
Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.
| Field | Current Purpose | Status | Notes |
| --------------- | --------------------------------------------------- | ------- | ------------------------------------------- |
| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. |
| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. |
| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. |
| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. |
| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. |
Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together.
Do not port `mode`. The legacy loader already merges this deprecated alias into `agent`, and v2 should expose only one authoring surface for agent definitions.
Rename legacy `agent` to `agents` because the setting is a collection keyed by agent name. It should continue to support overriding built-in agents such as `build`, `plan`, and `title`, as well as declaring named custom agents. The nested entry schema remains open until agent-local `permission` and deprecated `tools` behavior are decided.
Keep nested `agents.<name>.mode` with values `"primary"`, `"subagent"`, or `"all"`. This identifies an agent's runtime role and is separate from the removed top-level legacy `mode` alias, which was an alternate container for agent definitions.
For named configurable entries across v2, use `disabled?: boolean` consistently when an entry should remain configured but inactive. Agent definitions should therefore redesign legacy `disable` as `disabled`; this matches formatters, language servers, future MCP server definitions, and configured model overrides. Runtime catalog state may still track active availability as `enabled`; that is not user-authored config.
Keep separate `model` and `variant` fields on agent definitions. A model reference uses `provider/model-id`, but model IDs may themselves contain slash-delimited segments, such as `openrouter/openai/gpt-5`; appending a variant to that string would be ambiguous.
Keep `color` on agent definitions. Agents are user-visible selectable entities, so a user-authored display color is appropriate metadata for the agent rather than an unrelated application presentation setting. Retain hex colors and named theme colors supported by the existing configuration.
Keep agent-local `options` provisionally using the same structured provider options shape available on configured providers and models: headers, body, and AI SDK provider/request overrides. Its long-term ownership remains open for team review because reusable provider-specific presets can instead be modeled as variants. Do not retain dedicated agent `temperature` or `top_p` fields.
Retain `description`, `hidden`, and `steps`; they define an agent's discoverability, visibility, and iteration budget rather than model request parameters. Rename legacy agent `prompt` to `system`, making clear that it supplies persistent system-level agent content without colliding with top-level ambient `instructions`. Remove deprecated `maxSteps` in favor of `steps`.
```jsonc
{
"agents": {
"reviewer": {
"model": "openrouter/openai/gpt-5",
"variant": "high",
"options": {
"headers": { "x-agent": "reviewer" },
"body": {},
"aisdk": { "provider": {}, "request": { "reasoningEffort": "high" } },
},
"description": "Review changes for correctness",
"system": "Find regressions and missing tests.",
"mode": "subagent",
"color": "warning",
"steps": 12,
"disabled": false,
"permissions": [
{ "permission": "edit", "pattern": "*", "action": "deny" },
],
},
},
}
```
Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward.
Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries.
```jsonc
{
"permissions": [
{ "permission": "bash", "pattern": "*", "action": "ask" },
{ "permission": "bash", "pattern": "git status", "action": "allow" },
],
}
```
## Group 9: Integrations
External protocol and server integration configuration.
| Field | Current Purpose | Status | Notes |
| ----- | ------------------------------------- | ------- | ----- |
| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. |
Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem.
```jsonc
{
"mcp": {
"timeout": 5000,
"servers": {
"github": {
"type": "local",
"command": ["npx", "-y", "@github/github-mcp-server"],
"environment": { "GITHUB_TOKEN": "{env:GITHUB_TOKEN}" },
"disabled": false,
"timeout": 10000,
},
"docs": {
"type": "remote",
"url": "https://docs.example.com/mcp",
"headers": { "Authorization": "Bearer {env:DOCS_TOKEN}" },
"oauth": {
"client_id": "{env:MCP_CLIENT_ID}",
"client_secret": "{env:MCP_CLIENT_SECRET}",
"scope": "read write",
"callback_port": 19876,
"redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback",
},
"disabled": false,
},
},
},
}
```
## Group 10: Conversation Lifecycle
Behavior affecting long-running conversations and context management.
| Field | Current Purpose | Status | Notes |
| ------------ | ----------------------------------------------------------- | ------- | ----- |
| `compaction` | Automatic compaction, pruning, and context reserve settings | redesign | Group retained verbatim history under `keep` and rename context headroom to `buffer`. |
Retain the compaction capability but redesign the less clear limits. `keep.turns` is the maximum number of recent user turns to preserve verbatim after compaction, and `keep.tokens` is the token budget for those retained turns. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted.
```jsonc
{
"compaction": {
"auto": true,
"prune": true,
"keep": {
"turns": 2,
"tokens": 2000,
},
"buffer": 10000,
},
}
```
## Group 11: Deprecated And Experimental Settings
Fields that should not be ported by inertia; each needs an explicit justification.
| Field | Current Purpose | Status | Notes |
| ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- |
| `layout` | Legacy layout selection | remove | Do not port deprecated option; stretch layout is always used. |
| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | remove | Do not port; pasted-input presentation behavior belongs to the client/UI surface. |
| `experimental.batch_tool` | Enable batch tool | remove | Do not port; batch tool is no longer a supported feature. |
| `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. |
| `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. |
| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. |
| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers.<name>.timeout` for per-server overrides. |
## Review Order
Work through the groups in this order unless a dependency between decisions becomes clear:
1. File Metadata
2. Process And Server Settings
3. Providers And Model Selection
4. Commands And Project Resources
5. Plugins
6. Filesystem And Tool Runtime
7. Sharing And Identity
8. Agents And Permissions
9. Integrations
10. Conversation Lifecycle
11. Deprecated And Experimental Settings

291
specs/v2/provider-policy.md Normal file
View file

@ -0,0 +1,291 @@
# Policy
## Purpose
Policies control whether an operation on a named resource is allowed. They may be authored in configuration files, but policy evaluation is its own runtime concern.
The first policy consumer is provider availability:
```text
action: provider.use
resource: provider ID, such as openai or company-ai
```
Provider configuration and provider policy remain separate:
- `providers` describes endpoints, options, and model overrides.
- `experimental.policies` determines whether an operation using a provider is allowed.
A provider can be correctly configured and have valid credentials while policy still denies its use.
## Goals
- Replace legacy `enabled_providers` and `disabled_providers`.
- Keep the default experience unchanged when users specify no policy.
- Support wildcard matching for actions and resources.
- Provide one small policy vocabulary that can later cover operations such as `plugin.load` or `mcp.connect`.
- Let user policy override repository policy, and later allow organization-managed policy to override both.
- Keep evaluation simple: matching statements are applied in order and the last match wins.
## Non-Goals
- Policies do not configure endpoints, credentials, models, or provider options.
- Policies do not make unusable resources usable.
- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values.
- This spec does not define how organization-managed policies are delivered.
## Statement Shape
```jsonc
{
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai",
},
],
},
}
```
```ts
interface PolicyInfo {
effect: "allow" | "deny"
action: string
resource: string
}
```
The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `experimental.policies` union because config files are one place statements can be authored while the capability is experimental.
## Matching
Both `action` and `resource` use opencode's existing wildcard matching behavior.
Examples:
| Action | Resource | Matches |
| -------------- | ----------- | ---------------------------------------------------------------------------- |
| `provider.use` | `openai` | Only use of provider ID `openai` |
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later |
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
## Evaluation
To evaluate an operation and resource:
1. Start with `allow`.
2. Consider every statement whose `action` and `resource` match the requested action and resource.
3. Each matching statement replaces the current decision with its `effect`.
4. The last matching statement determines the result.
Conceptually:
```ts
function evaluate(action: string, resource: string, fallback: Policy.Effect, statements: Policy.Info[]) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}
```
Each caller supplies the default effect appropriate for its operation. Catalog provider use supplies `"allow"`, so no provider policy statements means normal behavior continues: otherwise usable providers are allowed.
## Ordering Within One Config Document
Statements remain in the order written by the user.
To deny all providers except Anthropic:
```jsonc
{
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*",
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic",
},
],
},
}
```
Result:
```text
provider.use / anthropic -> allow
provider.use / openai -> deny
```
To allow internal providers except experimental ones:
```jsonc
{
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "company-*" },
{ "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" },
],
},
}
```
Result:
```text
company-stable: allowed
company-experimental-fast: denied
openai: denied
```
## Ordering Across Authored Config Documents
Ordinary settings and policies have different precedence needs:
- Ordinary settings are read forward, so location-specific settings override user-global settings.
- Policies are read by reversing authored config documents, so user-global policy can override repository policy.
- Statements inside each document keep their written order.
At minimum, this means a repository cannot silently re-enable something the user denied globally.
Project config:
```jsonc
{
"experimental": {
"policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }],
},
}
```
User-global config:
```jsonc
{
"experimental": {
"policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }],
},
}
```
Result:
```text
provider.use / openai -> deny
```
The relative policy precedence of direct project files and `.opencode` files is intentionally deferred until `.opencode` configuration is reviewed.
## Organization-Managed Policy
Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority.
```text
repository policy -> user-global policy -> organization-managed policy
```
Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths.
Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement.
## Interaction With Provider Configuration
```jsonc
{
"providers": {
"company-ai": {
"endpoint": {
"type": "openai/responses",
"url": "https://ai.company.example/v1/responses",
},
},
},
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "company-ai" },
],
},
}
```
The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use.
Provider policy applies regardless of how a provider becomes known or usable, including:
- models.dev catalog data
- environment credentials
- saved accounts
- built-in provider plugins
- explicit provider configuration
## Applying Provider Policy
Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
Intended flow:
1. Build provider/model catalog entries.
2. Apply configured provider and model overrides.
3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID.
4. Prevent denied providers from being selectable or used.
Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision.
## Legacy Migration
Legacy deny list:
```jsonc
{
"disabled_providers": ["openai", "google"],
}
```
Equivalent v2 policy:
```jsonc
{
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "openai" },
{ "effect": "deny", "action": "provider.use", "resource": "google" },
],
},
}
```
Legacy allowlist:
```jsonc
{
"enabled_providers": ["anthropic", "openai"],
}
```
Equivalent v2 policy:
```jsonc
{
"experimental": {
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "anthropic" },
{ "effect": "allow", "action": "provider.use", "resource": "openai" },
],
},
}
```