feat(core): add command registry (#30624)

This commit is contained in:
Dax 2026-06-04 02:57:43 -04:00 committed by GitHub
parent 70bb710715
commit 1ff19103a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
150 changed files with 4753 additions and 2657 deletions

View File

@ -90,6 +90,7 @@ jobs:
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@ -107,6 +108,12 @@ jobs:
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/lildax-*
outputs:
version: ${{ needs.version.outputs.version }}
@ -446,6 +453,11 @@ jobs:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:

View File

@ -87,14 +87,18 @@
"name": "@opencode-ai/cli",
"version": "1.15.13",
"bin": {
"opencode": "./src/index.ts",
"lildax": "./bin/lildax.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@ -517,6 +521,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
@ -654,6 +659,20 @@
"typescript": "catalog:",
},
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.15.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.13",
@ -1715,6 +1734,8 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],

116
packages/cli/bin/lildax.cjs Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env node
const childProcess = require("child_process")
const fs = require("fs")
const path = require("path")
const os = require("os")
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
function run(target) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
child.on("error", (error) => {
console.error(error.message)
process.exit(1)
})
const forwarders = {}
for (const signal of forwardedSignals) {
forwarders[signal] = () => {
try {
child.kill(signal)
} catch {}
}
process.on(signal, forwarders[signal])
}
child.on("exit", (code, signal) => {
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
if (signal) return process.kill(process.pid, signal)
process.exit(typeof code === "number" ? code : 0)
})
}
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".lildax")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/lildax-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax"
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8", timeout: 1500 })
return result.status === 0 && (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const command =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
const names = (() => {
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
const musl = (() => {
try {
if (fs.existsSync("/etc/alpine-release")) return true
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl")
} catch {
return false
}
})()
if (musl) return arch === "x64" ? (baseline ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]) : [`${base}-musl`, base]
return arch === "x64" ? (baseline ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]) : [base, `${base}-musl`]
}
return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base]
})()
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) for (const name of names) {
const candidate = path.join(modules, name, "bin", binary)
if (fs.existsSync(candidate)) return candidate
}
const parent = path.dirname(current)
if (parent === current) return
current = parent
}
}
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error("It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + names.map((name) => `"${name}"`).join(" or ") + " package")
process.exit(1)
}
run(resolved)

View File

@ -2,12 +2,14 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.15.13",
"private": true,
"type": "module",
"license": "MIT",
"bin": {
"opencode": "./src/index.ts"
"lildax": "./bin/lildax.cjs"
},
"files": [
"bin"
],
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
@ -16,9 +18,13 @@
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@parcel/watcher": "2.5.1",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"

View File

@ -0,0 +1,103 @@
#!/usr/bin/env bun
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const allTargets: {
os: string
arch: "arm64" | "x64"
abi?: "musl"
avx2?: false
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
const targets = singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
for (const item of targets) {
const name = [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: `./dist/${name}/bin/${binary}`,
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
},
})
if (!result.success) {
for (const log of result.logs) console.error(log)
process.exit(1)
}
await Bun.write(
`./dist/${name}/package.json`,
JSON.stringify(
{
name: `@opencode-ai/${name}`,
version: Script.version,
license: "MIT",
os: [item.os],
cpu: [item.arch],
},
null,
2,
),
)
}

View File

@ -0,0 +1,7 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bun
import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) return console.log(`already published ${name}@${version}`)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const item = await Bun.file(`./dist/${filepath}`).json()
binaries[item.name] = item.version
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name: pkg.name,
bin: { lildax: "./bin/lildax" },
version,
license: pkg.license,
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(Object.entries(binaries).map(([name, version]) => publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version)))
await publish(`./dist/${pkg.name}`, pkg.name, version)

View File

@ -1,12 +0,0 @@
import { CliApi } from "./cli-api"
export const Api = CliApi.make("opencode", {
description: "OpenCode command line interface",
commands: [
CliApi.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [CliApi.make("agents", { description: "List all agents" })],
}),
CliApi.make("migrate", { description: "Migrate v1 data to v2" }),
],
})

View File

@ -0,0 +1,36 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
commands: [
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("service", {
description: "Manage the background server",
commands: [
Spec.make("start", { description: "Start the background server" }),
Spec.make("restart", { description: "Restart the background server" }),
Spec.make("status", { description: "Show background server status" }),
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("password", {
description: "Get or set the server password",
params: { value: Argument.string("value").pipe(Argument.optional) },
}),
],
}),
Spec.make("serve", {
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
},
}),
],
})

View File

@ -0,0 +1,21 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const daemon = yield* Daemon.Service
const client = yield* daemon.client()
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
}),
)

View File

@ -0,0 +1,5 @@
import * as Effect from "effect/Effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))

View File

@ -0,0 +1,39 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}),
)
}),
)
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
}
function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}

View File

@ -0,0 +1,16 @@
import { EOL } from "os"
import { Option } from "effect"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.password,
Effect.fn("cli.service.password")(function* (input) {
const daemon = yield* Daemon.Service
const value = Option.getOrUndefined(input.value)
if (value !== undefined) yield* daemon.stop()
process.stdout.write((yield* daemon.password(value)) + EOL)
}),
)

View File

@ -0,0 +1,14 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const daemon = yield* Daemon.Service
yield* daemon.stop()
process.stdout.write((yield* daemon.start()) + EOL)
}),
)

View File

@ -0,0 +1,12 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.start,
Effect.fn("cli.service.start")(function* () {
process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
}),
)

View File

@ -0,0 +1,13 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
}),
)

View File

@ -0,0 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* (yield* Daemon.Service).stop()
}),
)

View File

@ -1,19 +1,20 @@
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { CliApi } from "./cli-api"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
export type Input<Value> =
Value extends CliApi.Node<infer _Name, infer Spec, infer _Commands>
? Input<Spec>
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
? Input<Command>
: Value extends Command.Command<infer _Name, infer Input, infer _Context, infer _Error, infer _Requirements>
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown>
type Loader<Node extends CliApi.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, never>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type Loader<Node extends Spec.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
export type Handlers<Node extends CliApi.Any> = keyof Node["commands"] extends never
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
: { readonly $?: Loader<Node> } & { readonly [Key in keyof Node["commands"]]: Handlers<Node["commands"][Key]> }
@ -29,17 +30,17 @@ type RuntimeHandlers =
readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined
}
export function handler<const Node extends CliApi.Any, Error>(
export function handler<const Node extends Spec.Any, Error, Requirements>(
_node: Node,
run: (input: Input<Node>) => Effect.Effect<void, Error>,
run: (input: Input<Node>) => Effect.Effect<void, Error, Requirements>,
) {
return run
}
export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Handlers<Root>) {
export function handlers<const Root extends Spec.Any>(root: Root, handlers: Handlers<Root>) {
const result: LazyHandler[] = []
function add(node: CliApi.Any, value: RuntimeHandlers) {
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
@ -52,11 +53,11 @@ export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Ha
return result
}
export function run(api: CliApi.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(api, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
}
function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
@ -65,8 +66,12 @@ function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): Provid
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
return spec.pipe(
Command.withHandler((input) => Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))),
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
) as ProvidedCommand
}
export * as CliBuilder from "./cli-builder"
export * as Runtime from "./runtime"

View File

@ -39,4 +39,4 @@ type ChildrenOf<Commands extends ReadonlyArray<Any>> = {
readonly [Node in Commands[number] as Node["name"]]: Node
}
export * as CliApi from "./cli-api"
export * as Spec from "./spec"

View File

@ -1,30 +0,0 @@
import { EOL } from "os"
import { AgentV2 } from "@opencode-ai/core/agent"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import * as Effect from "effect/Effect"
import { Api } from "../../api"
import { CliBuilder } from "../../cli-builder"
export default CliBuilder.handler(
Api.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(
function* () {
const svc = {
plugin: yield* PluginBoot.Service,
agent: yield* AgentV2.Service,
}
yield* svc.plugin.wait()
process.stdout.write(
JSON.stringify(
(yield* svc.agent.all()).sort((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
},
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.layer),
),
)

View File

@ -1,5 +0,0 @@
import * as Effect from "effect/Effect"
import { Api } from "../api"
import { CliBuilder } from "../cli-builder"
export default CliBuilder.handler(Api.commands.migrate, (_input) => Effect.log("No migrations to run."))

View File

@ -3,17 +3,27 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Effect from "effect/Effect"
import { Api } from "./api"
import { CliBuilder } from "./cli-builder"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
const Handlers = CliBuilder.handlers(Api, {
const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./handlers/debug/agents"),
agents: () => import("./commands/handlers/debug/agents"),
},
migrate: () => import("./handlers/migrate"),
migrate: () => import("./commands/handlers/migrate"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
status: () => import("./commands/handlers/service/status"),
stop: () => import("./commands/handlers/service/stop"),
password: () => import("./commands/handlers/service/password"),
},
serve: () => import("./commands/handlers/serve"),
})
CliBuilder.run(Api, Handlers, { version: "local" }).pipe(
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
NodeRuntime.runMain,

View File

@ -0,0 +1,145 @@
import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes } from "crypto"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
readonly password: (value?: string) => Effect.Effect<string, unknown>
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
)
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
})
const registration = Effect.fnUntraced(function* () {
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
})
const createClient = Effect.fnUntraced(function* (url: string) {
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
})
const healthy = Effect.fnUntraced(function* () {
const info = yield* registration()
const client = yield* createClient(info.url)
const response = yield* Effect.tryPromise(() => client.v2.health.get())
if (response.data?.healthy === true) return info
return yield* Effect.fail(new Error("Registered server is not healthy"))
})
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
})
return yield* healthy().pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.map((info) => info.url),
Effect.mapError(() => new Error("Failed to start server")),
)
})
const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start())
})
const status = Effect.fn("cli.daemon.status")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* fs.remove(file).pipe(Effect.ignore)
return undefined
})
const signal = (pid: number, signal: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
)
if (!running) return true
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
const stop = Effect.fn("cli.daemon.stop")(function* () {
const existing = yield* healthy().pipe(Effect.option)
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
const pid = existing.value.pid
yield* signal(pid, "SIGTERM")
const stopped = yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.option,
)
if (Option.isNone(stopped)) {
yield* signal(pid, "SIGKILL")
yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
)
}
yield* fs.remove(file).pipe(Effect.ignore)
})
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
const temp = file + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(
temp,
JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }),
{ mode: 0o600 },
)
yield* fs.rename(temp, file)
// The metadata file represents this live listener, not persistent config.
// Scope shutdown removes it when the server exits normally.
yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
})
return Service.of({ client, start, status, stop, password, register })
}),
)
export const defaultLayer = layer
export * as Daemon from "./daemon"

View File

@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@ -0,0 +1,68 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model"
import { State } from "./state"
export class Info extends Schema.Class<Info>("CommandV2.Info")({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
export type Data = {
commands: Map<string, Info>
}
export type Editor = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Editor>({
initial: () => ({ commands: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
},
remove: (name) => {
draft.commands.delete(name)
},
}),
})
return Service.of({
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
}),
list: Effect.fn("CommandV2.list")(function* () {
return Array.from(state.get().commands.values())
}),
})
}),
)
export const locationLayer = layer

View File

@ -12,6 +12,7 @@ import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
@ -85,6 +86,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),

View File

@ -0,0 +1,12 @@
export * as ConfigCommand from "./command"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}

View File

@ -0,0 +1,82 @@
export * as ConfigCommandPlugin from "./command"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const transform = yield* command.transform()
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]),
)
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
})
}),
})
function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
return yield* Effect.forEach(files.toSorted(), (filepath) =>
fs.readFileStringSafe(filepath).pipe(
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((commands) =>
commands.filter((command): command is { name: string; info: ConfigCommand.Info } => command !== undefined),
),
)
})
}
function decode(directory: string, filepath: string, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
if (!info) return
return {
name: path
.relative(directory, filepath)
.replaceAll("\\", "/")
.replace(/^(command|commands)\//, "")
.replace(/\.md$/, ""),
info,
}
}

View File

@ -18,13 +18,14 @@ export const Plugin = PluginV2.define({
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const items = entries.flatMap((entry) =>
entry.type === "document"
? (entry.info.skills ?? [])
: [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
)
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) {
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))

View File

@ -4,6 +4,7 @@ import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { Project } from "./project"
@ -51,6 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
ProjectReference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
FileSystem.locationLayer,

View File

@ -11,16 +11,23 @@ export const Ref = Schema.Struct({
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export interface Interface {
readonly directory: AbsolutePath
readonly workspaceID?: WorkspaceV2.ID
readonly project: {
readonly id: Project.ID
readonly directory: AbsolutePath
}
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
}),
}) {}
export interface Interface extends Info {
readonly vcs?: Project.Vcs
}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>

View File

@ -52,18 +52,6 @@ export const Api = Schema.Union([
]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const PublicApi = Schema.Union([
Schema.Struct({
id: ID,
...ProviderV2.PublicAISDK.fields,
}),
Schema.Struct({
id: ID,
...ProviderV2.PublicNative.fields,
}),
]).pipe(Schema.toTaggedUnion("type"))
export type PublicApi = typeof PublicApi.Type
export class Info extends Schema.Class<Info>("ModelV2.Info")({
id: ID,
providerID: ProviderV2.ID,
@ -125,64 +113,6 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
}
}
export class PublicInfo extends Schema.Class<PublicInfo>("ModelV2.PublicInfo")({
id: ID,
providerID: ProviderV2.ID,
family: Family.pipe(Schema.optional),
name: Schema.String,
api: PublicApi,
capabilities: Capabilities,
variants: Schema.Struct({
id: VariantID,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {}
export function toPublic(info: Info): PublicInfo {
const api =
info.api.type === "aisdk"
? {
id: info.api.id,
type: info.api.type,
package: info.api.package,
url: ProviderV2.sanitizePublicUrl(info.api.url),
}
: { id: info.api.id, type: info.api.type, url: ProviderV2.sanitizePublicUrl(info.api.url) }
return new PublicInfo({
id: info.id,
providerID: info.providerID,
family: info.family,
name: info.name,
api,
capabilities: {
tools: info.capabilities.tools,
input: [...info.capabilities.input],
output: [...info.capabilities.output],
},
variants: info.variants.map((variant) => ({ id: variant.id })),
time: { released: info.time.released },
cost: info.cost.map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: { ...cost.cache },
})),
status: info.status,
enabled: info.enabled,
limit: { ...info.limit },
})
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {

View File

@ -4,8 +4,10 @@ import { Context, Deferred, Effect, Layer } from "effect"
import { Auth } from "../auth"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
@ -16,6 +18,8 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
@ -26,6 +30,7 @@ type Plugin = {
id: PluginV2.ID
effect: PluginV2.Effect<
| Catalog.Service
| CommandV2.Service
| Auth.Service
| AgentV2.Service
| Npm.Service
@ -50,6 +55,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const agents = yield* AgentV2.Service
@ -68,6 +74,7 @@ export const layer = Layer.effect(
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Auth.Service, accounts),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
@ -87,12 +94,15 @@ export const layer = Layer.effect(
yield* add(EnvPlugin)
yield* add(AccountPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
@ -110,6 +120,7 @@ export const layer = Layer.effect(
export const locationLayer = layer.pipe(
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),

View File

@ -0,0 +1,29 @@
export * as CommandPlugin from "./command"
import { Effect } from "effect"
import { CommandV2 } from "../command"
import { Location } from "../location"
import { PluginV2 } from "../plugin"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const location = yield* Location.Service
const transform = yield* command.transform()
yield* transform((editor) => {
editor.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
})
editor.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true
})
})
}),
})

View File

@ -0,0 +1,65 @@
Create or update `AGENTS.md` for this repository.
The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
User-provided focus or constraints (honor these):
$ARGUMENTS
## How to investigate
Read the highest-value sources first:
- `README*`, root manifests, workspace config, lockfiles
- build, test, lint, formatter, typecheck, and codegen config
- CI workflows and pre-commit / task runner config
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
- repo-local OpenCode config such as `opencode.json`
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
## What to extract
Look for the highest-signal facts for an agent working in this repo:
- exact developer commands, especially non-obvious ones
- how to run a single test, a single package, or a focused verification step
- required command order when it matters, such as `lint -> typecheck -> test`
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
- important constraints from existing instruction files worth preserving
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
## Questions
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
Good questions:
- undocumented team conventions
- branch / PR / release expectations
- missing setup or test prerequisites that are known but not written down
Do not ask about anything the repo already makes clear.
## Writing rules
Include only high-signal, repo-specific guidance such as:
- exact commands and shortcuts the agent would otherwise guess wrong
- architecture notes that are not obvious from filenames
- conventions that differ from language or framework defaults
- setup requirements, environment quirks, and operational gotchas
- references to existing instruction sources that matter
Exclude:
- generic software advice
- long tutorials or exhaustive file trees
- obvious language conventions
- speculative claims or anything you could not verify
- content better stored in another file referenced via `opencode.json` `instructions`
When in doubt, omit.
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.

View File

@ -0,0 +1,100 @@
You are a code reviewer. Your job is to review code changes and provide actionable feedback.
---
Input: $ARGUMENTS
---
## Determining What to Review
Based on the input provided, determine which type of review to perform:
1. **No arguments (default)**: Review all uncommitted changes
- Run: `git diff` for unstaged changes
- Run: `git diff --cached` for staged changes
- Run: `git status --short` to identify untracked (net new) files
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
- Run: `git show $ARGUMENTS`
3. **Branch name**: Compare current branch to the specified branch
- Run: `git diff $ARGUMENTS...HEAD`
4. **PR URL or number** (contains "github.com" or "pull" or looks like a PR number): Review the pull request
- Run: `gh pr view $ARGUMENTS` to get PR context
- Run: `gh pr diff $ARGUMENTS` to get the diff
Use best judgement when processing input.
---
## Gathering Context
**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
- Use the diff to identify which files changed
- Use `git status --short` to identify untracked files, then read their full contents
- Read the full file to understand existing patterns, control flow, and error handling
- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)
---
## What to Look For
**Bugs** - Your primary focus.
- Logic errors, off-by-one mistakes, incorrect conditionals
- If-else guards: missing guards, incorrect branching, unreachable code paths
- Edge cases: null/empty/undefined inputs, error conditions, race conditions
- Security issues: injection, auth bypass, data exposure
- Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught.
**Structure** - Does the code fit the codebase?
- Does it follow existing patterns and conventions?
- Are there established abstractions it should use but doesn't?
- Excessive nesting that could be flattened with early returns or extraction
**Performance** - Only flag if obviously problematic.
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional).
---
## Before You Flag Something
**Be certain.** If you're going to call something a bug, you need to be confident it actually is one.
- Only review the changes - do not review pre-existing code that wasn't modified
- Don't flag something as a bug if you're unsure - investigate first
- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks
- If you need more context to be sure, use the tools below to get it
**Don't be a zealot about style.** When checking code against conventions:
- Verify the code is *actually* in violation. Don't complain about else statements if early returns are already being used correctly.
- Some "violations" are acceptable when they're the simplest option. A `let` statement is fine if the alternative is convoluted.
- Excessive nesting is a legitimate concern regardless of other style choices.
---
## Tools
Use these to inform your review:
- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
- **Web Search** - Research best practices if you're unsure about a pattern.
If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
---
## Output
1. If there is a bug, be direct and clear about why it is a bug.
2. Clearly communicate severity of issues. Do not overstate severity.
3. Critiques should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
5. Write so the reader can quickly understand the issue without reading too closely.
6. AVOID flattery, do not give any comments that are not helpful to the reader.

View File

@ -0,0 +1,30 @@
export * as SkillPlugin from "./skill"
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("skill"),
effect: Effect.gen(function* () {
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const content = yield* Effect.promise(() => Bun.file(new URL("./skill/customize-opencode.md", import.meta.url)).text())
yield* transform((editor) => {
editor.source(
new SkillV2.EmbeddedSource({
type: "embedded",
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content,
}),
}),
)
})
}),
})

View File

@ -1,6 +1,6 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see CUSTOMIZE_OPENCODE_SKILL_NAME
packages/core/src/plugin/skill.ts
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content.
-->

View File

@ -22,9 +22,6 @@ export const ID = Schema.String.pipe(
)
export type ID = typeof ID.Type
export const ModelID = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof ModelID.Type
export const AISDK = Schema.Struct({
type: Schema.Literal("aisdk"),
package: Schema.String,
@ -41,20 +38,6 @@ export const Native = Schema.Struct({
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const PublicAISDK = Schema.Struct({
type: Schema.Literal("aisdk"),
package: Schema.String,
url: Schema.String.pipe(Schema.optional),
})
export const PublicNative = Schema.Struct({
type: Schema.Literal("native"),
url: Schema.String.pipe(Schema.optional),
})
export const PublicApi = Schema.Union([PublicAISDK, PublicNative]).pipe(Schema.toTaggedUnion("type"))
export type PublicApi = typeof PublicApi.Type
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
@ -100,49 +83,3 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
})
}
}
export class PublicInfo extends Schema.Class<PublicInfo>("ProviderV2.PublicInfo")({
id: ID,
name: Schema.String,
enabled: Schema.Union([
Schema.Literal(false),
Schema.Struct({
via: Schema.Literal("env"),
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("account"),
service: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("custom"),
}),
]),
env: Schema.String.pipe(Schema.Array),
api: PublicApi,
}) {}
export function sanitizePublicUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined
} catch {
return undefined
}
}
export function toPublic(info: Info): PublicInfo {
const enabled = info.enabled === false || info.enabled.via !== "custom" ? info.enabled : { via: "custom" as const }
const api =
info.api.type === "aisdk"
? { type: info.api.type, package: info.api.package, url: sanitizePublicUrl(info.api.url) }
: { type: info.api.type, url: sanitizePublicUrl(info.api.url) }
return new PublicInfo({
id: info.id,
name: info.name,
enabled,
env: [...info.env],
api,
})
}

View File

@ -243,7 +243,7 @@ export const layer = Layer.effect(
agent: input.agent,
model: input.model
? {
id: ProviderV2.ModelID.make(input.model.id),
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}

View File

@ -21,17 +21,23 @@ export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
url: Schema.String,
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource]).pipe(
export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
type: Schema.Literal("embedded"),
skill: Schema.suspend(() => Info),
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
Schema.toTaggedUnion("type"),
withStatics(() => ({
equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => {
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
if (a.type !== b.type) return false
if (a.type === "directory" && b.type === "directory") return a.path === b.path
if (a.type === "url" && b.type === "url") return a.url === b.url
if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
return false
},
key: (source: DirectorySource | UrlSource) =>
source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`,
key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
source.type === "directory" ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` : `embedded:${source.skill.name}`,
})),
)
export type Source = typeof Source.Type
@ -89,6 +95,7 @@ export const layer = Layer.effect(
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") return [source.skill]
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) {
const files = yield* fs

View File

@ -7,6 +7,7 @@ export const Info = Schema.Struct({
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String),
subtask: Schema.optional(Schema.Boolean),
})
export type Info = Schema.Schema.Type<typeof Info>

View File

@ -61,6 +61,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: info.command,
instructions: info.instructions,
references: info.reference,
plugins: info.plugin?.map((plugin) =>

View File

@ -5,6 +5,7 @@ import { EventV2 } from "../event"
import { PermissionV1 } from "./permission"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
import { optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { NonNegativeInt } from "../schema"
@ -200,7 +201,7 @@ export const SubtaskPart = Schema.Struct({
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
@ -344,7 +345,7 @@ export const User = Schema.Struct({
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
variant: Schema.optional(Schema.String),
}),
system: Schema.optional(Schema.String),
@ -440,7 +441,7 @@ export const SubtaskPartInput = Schema.Struct({
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
@ -456,7 +457,7 @@ export const Assistant = Schema.Struct({
}),
error: Schema.optional(AssistantErrorSchema),
parentID: MessageID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
mode: Schema.String,
agent: Schema.String,
@ -532,7 +533,7 @@ const SessionRevert = Schema.Struct({
})
const SessionModel = Schema.Struct({
id: ProviderV2.ModelID,
id: ModelV2.ID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})

View File

@ -6,6 +6,7 @@ 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 { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@ -187,7 +188,12 @@ describe("CatalogV2", () => {
yield* events.publish(
PluginV2.Event.Added,
{ id: PluginV2.ID.make("test-transform") },
{ location: { directory: AbsolutePath.make("other") } },
{
location: new Location.Info({
directory: AbsolutePath.make("other"),
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
}),
},
)
yield* Effect.yieldNow

View File

@ -0,0 +1,56 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const it = testEffect(CommandV2.locationLayer)
describe("CommandV2", () => {
it.effect("applies command transforms and preserves later overrides", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
const transform = yield* command.transform()
yield* transform((editor) => {
editor.update("review", (command) => {
command.template = "First"
command.description = "Review code"
})
editor.update("review", (command) => {
command.template = "Second"
command.model = {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
}
})
})
expect(yield* command.get("review")).toEqual(
new CommandV2.Info({
name: "review",
template: "Second",
description: "Review code",
model: {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
},
}),
)
expect(yield* command.list()).toEqual([
new CommandV2.Info({
name: "review",
template: "Second",
description: "Review code",
model: {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
},
}),
])
}),
)
})

View File

@ -0,0 +1,81 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "commands", "nested"), { recursive: true })
await fs.writeFile(
path.join(tmp.path, "commands", "review.md"),
`---
description: File review
agent: reviewer
model: anthropic/claude
variant: high
subtask: true
---
Review files`,
)
await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
})
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Inline review" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
}),
),
)
expect(yield* command.list()).toEqual([
new CommandV2.Info({
name: "review",
template: "Review files",
description: "File review",
agent: "reviewer",
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
subtask: true,
}),
new CommandV2.Info({ name: "empty", template: "" }),
new CommandV2.Info({ name: "nested/docs", template: "Write docs" }),
])
}),
),
),
)
})

View File

@ -100,6 +100,34 @@ describe("Config", () => {
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
})
}),
)
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

View File

@ -0,0 +1,44 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = AbsolutePath.make("/repo/packages/app")
const project = AbsolutePath.make("/repo")
const it = testEffect(
CommandV2.locationLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
),
),
),
)
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
)
expect(yield* command.get("init")).toMatchObject({
name: "init",
description: "guided AGENTS.md setup",
})
expect((yield* command.get("init"))?.template).toContain("`/repo`")
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
subtask: true,
})
}),
)
})

View File

@ -0,0 +1,32 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { testEffect } from "../lib/effect"
const it = testEffect(
SkillV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers the built-in customize-opencode skill", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill))
expect(yield* skill.list()).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
}),
)
}),
)
})

View File

@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Schema } from "effect"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)),
)
const encodeProvider = Schema.encodeSync(ProviderV2.PublicInfo)
const encodeModel = Schema.encodeSync(ModelV2.PublicInfo)
describe("public catalog DTOs", () => {
test("provider DTO excludes credentials and internal settings", () => {
const providerID = ProviderV2.ID.make("test")
const encoded = encodeProvider(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
enabled: { via: "account", service: "test-account" },
env: ["TEST_API_KEY"],
api: { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } },
request: {
headers: { Authorization: "Bearer header-secret", "x-api-key": "header-secret" },
body: { apiKey: "body-secret", account: "account-body-secret" },
},
}),
),
)
expect(encoded).toEqual({
id: "test",
name: "test",
enabled: { via: "account", service: "test-account" },
env: ["TEST_API_KEY"],
api: { type: "native", url: "https://example.com" },
})
expect(JSON.stringify(encoded)).not.toMatch(/Authorization|x-api-key|apiKey|account-body-secret|settings-secret/)
})
test("provider DTO excludes custom enabled metadata", () => {
const providerID = ProviderV2.ID.make("custom")
const encoded = encodeProvider(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
enabled: { via: "custom", data: { apiKey: "custom-secret" } },
}),
),
)
expect(encoded.enabled).toEqual({ via: "custom" })
expect(JSON.stringify(encoded)).not.toContain("custom-secret")
})
it.effect("model DTO excludes resolved provider requests and variant requests", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "account", service: "test-account" }
provider.api = { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } }
provider.request.headers.Authorization = "Bearer provider-secret"
provider.request.body.apiKey = "provider-body-secret"
provider.request.body.account = "account-body-secret"
})
catalog.model.update(providerID, modelID, (model) => {
model.request.headers["x-api-key"] = "model-header-secret"
model.request.body.apiKey = "model-body-secret"
model.variants.push({
id: ModelV2.VariantID.make("fast"),
headers: { Authorization: "Bearer variant-secret" },
body: { apiKey: "variant-body-secret" },
})
})
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.request.headers.Authorization).toBe("Bearer provider-secret")
expect(model.request.headers["x-api-key"]).toBe("model-header-secret")
expect(model.request.body.apiKey).toBe("model-body-secret")
expect(model.request.body.account).toBe("account-body-secret")
expect(model.api).toHaveProperty("settings.apiKey", "settings-secret")
const encoded = encodeModel(ModelV2.toPublic(model))
expect(encoded.api).toEqual({ id: "model", type: "native", url: "https://example.com" })
expect(encoded.variants).toEqual([{ id: "fast" }])
expect(encoded).not.toHaveProperty("request")
expect(JSON.stringify(encoded)).not.toMatch(
/Authorization|x-api-key|apiKey|account-body-secret|provider-secret|model-header-secret|variant-secret|settings-secret/,
)
}),
)
})

View File

@ -87,6 +87,7 @@
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",

View File

@ -3,6 +3,7 @@ import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
import type * as ACPError from "./error"
@ -10,7 +11,7 @@ import type * as ACPError from "./error"
export type ModelOption = {
readonly providerID: ProviderV2.ID
readonly providerName: string
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
readonly modelName: string
}
@ -24,7 +25,7 @@ export type ModelVariants = NonNullable<Provider.Model["variants"]>
export type DefaultModel = {
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}
export type Snapshot = {

View File

@ -42,6 +42,7 @@ import { ACPSession } from "./session"
import { UsageService } from "./usage"
import { ACPProfile } from "./profile"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@ -650,7 +651,7 @@ function makeUsageService(sdk: OpencodeClient) {
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
modelID: ModelV2.ID.make(message.modelID),
})
if (!size) return
@ -812,7 +813,7 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
if (model) return { providerID: model.providerID, modelID: model.modelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
@ -872,7 +873,7 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)]
const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)]
const model = provider?.models[ModelV2.ID.make(selected.model.modelID)]
if (!model) {
return Effect.fail(
new ACPError.InvalidModelError({
@ -1000,7 +1001,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
)
if (user?.model?.providerID && user.model.modelID) {
return {
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID },
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID },
variant: user.model.variant,
modeId: user.agent,
}
@ -1009,7 +1010,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
const assistant = messages.findLast((message) => message.providerID && message.modelID)
if (assistant?.providerID && assistant.modelID) {
return {
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID },
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID },
variant: assistant.variant,
modeId: assistant.mode ?? assistant.agent,
}

View File

@ -1,12 +1,13 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Context, Effect, Layer, Ref } from "effect"
import * as ACPError from "./error"
export type SelectedModel = {
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
}
export type KnownMessagePartMetadata = {

View File

@ -4,6 +4,7 @@ import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@ope
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
@ -50,7 +51,7 @@ export interface Interface {
readonly contextLimit: (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) => Effect.Effect<number | undefined>
readonly sendUpdate: (input: {
readonly connection: UsageConnection
@ -112,7 +113,7 @@ export function totalSessionCost(messages: readonly SessionMessage[]): number {
export function findContextLimit(
providers: Record<ProviderV2.ID, Provider.Info>,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
): number | undefined {
return providers[providerID]?.models[modelID]?.limit.context
}
@ -144,7 +145,7 @@ export const layer = Layer.effect(
const cachedLimit = Effect.fnUntraced(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) {
return yield* SynchronizedRef.modifyEffect(
limits,
@ -171,7 +172,7 @@ export const layer = Layer.effect(
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) {
return yield* yield* cachedLimit(input)
})
@ -198,7 +199,7 @@ export const layer = Layer.effect(
const size = yield* contextLimit({
directory: input.directory,
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
modelID: ModelV2.ID.make(message.modelID),
})
if (!size) return

View File

@ -25,6 +25,7 @@ import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export const Info = Schema.Struct({
name: Schema.String,
@ -38,7 +39,7 @@ export const Info = Schema.Struct({
permission: PermissionV1.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
}),
),
@ -62,7 +63,7 @@ export interface Interface {
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
}) => Effect.Effect<
{
identifier: string
@ -350,7 +351,7 @@ export const layer = Layer.effect(
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())

View File

@ -348,7 +348,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
message: {
async sync(sessionID: string) {
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
},
fromSession(sessionID: string) {
const messages = store.messages[sessionID]

View File

@ -3,6 +3,8 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
@ -24,10 +26,11 @@ export const layer = Layer.effect(
const workspaceID = yield* WorkspaceRef
return yield* events.publish(definition, data, {
...options,
location: {
location: new Location.Info({
directory: AbsolutePath.make(ctx.directory),
...(workspaceID ? { workspaceID } : {}),
},
project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) },
}),
})
})
@ -41,6 +44,25 @@ export const layer = Layer.effect(
workspace: workspaceID,
payload: { id: event.id, type: event.type, properties: event.data },
})
const sync = EventV2.registry.get(event.type)?.sync
if (sync === undefined || event.seq === undefined || event.version === undefined) return
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") return
GlobalBus.emit("event", {
directory: event.location?.directory ?? ctx?.directory,
project: ctx?.project.id,
workspace: workspaceID,
payload: {
type: "sync",
syncEvent: {
id: event.id,
type: EventV2.versionedType(event.type, event.version),
seq: event.seq,
aggregateID,
data: event.data,
},
},
})
}),
)
yield* Effect.addFinalizer(() => unsubscribe)

View File

@ -27,6 +27,7 @@ import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { ProviderTransform } from "./transform"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
@ -664,7 +665,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ProviderV2.ModelID.make(m.id),
id: ModelV2.ID.make(m.id),
providerID: ProviderV2.ID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
@ -920,7 +921,7 @@ const ProviderLimit = Schema.Struct({
})
export const Model = Schema.Struct({
id: ProviderV2.ModelID,
id: ModelV2.ID,
providerID: ProviderV2.ID,
api: ProviderApiInfo,
name: Schema.String,
@ -978,7 +979,7 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
@ -1018,7 +1019,7 @@ export interface Interface {
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
readonly getModel: (
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
@ -1027,7 +1028,7 @@ export interface Interface {
) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<
{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID },
{ providerID: ProviderV2.ID; modelID: ModelV2.ID },
DefaultModelError
>
}
@ -1080,7 +1081,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ProviderV2.ModelID.make(model.id),
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(provider.id),
name: model.name,
family: model.family,
@ -1138,7 +1139,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const base = fromModelsDevModel(provider, model)
models[id] = {
...base,
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
options: opts.provider?.body
@ -1163,7 +1164,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) {
function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider
? Object.keys(provider.models).filter((id) => {
const model = provider.models[id]
@ -1279,7 +1280,7 @@ export const layer = Layer.effect(
id,
{
...model,
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
providerID,
},
]),
@ -1314,7 +1315,7 @@ export const layer = Layer.effect(
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ProviderV2.ModelID.make(modelID),
id: ModelV2.ID.make(modelID),
api: {
id: apiID,
npm: apiNpm,
@ -1703,7 +1704,7 @@ export const layer = Layer.effect(
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) {
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
@ -1792,7 +1793,7 @@ export const layer = Layer.effect(
if (experimental.model) {
return {
...experimental.model,
id: ProviderV2.ModelID.make(experimental.model.id),
id: ModelV2.ID.make(experimental.model.id),
providerID: ProviderV2.ID.make(experimental.model.providerID),
}
}
@ -1846,16 +1847,16 @@ export const layer = Layer.effect(
const s = yield* InstanceState.get(state)
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => {
Effect.map((x): { providerID: ProviderV2.ID; modelID: ModelV2.ID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
return x.recent.flatMap((item) => {
if (!isRecord(item)) return []
if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }]
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ModelV2.ID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ModelV2.ID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
@ -1904,7 +1905,7 @@ export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(rest.join("/")),
modelID: ModelV2.ID.make(rest.join("/")),
}
}

View File

@ -20,7 +20,7 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2"
import { V2Api } from "@opencode-ai/server/api"
// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
@ -60,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(V2Api)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)
@ -69,6 +68,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(V2Api)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])

View File

@ -16,6 +16,7 @@ import {
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@ -51,7 +52,7 @@ const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
provider: ProviderV2.ID,
model: ProviderV2.ModelID,
model: ModelV2.ID,
})
const WorktreeList = Schema.Array(Schema.String)

View File

@ -20,11 +20,14 @@ const SyncEventSchemas = EventV2.registry
return [
Schema.Struct({
type: Schema.Literal("sync"),
name: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
seq: Schema.Finite,
aggregateID: Schema.Literal(definition.sync.aggregate),
data: definition.data,
syncEvent: Schema.Struct({
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
seq: Schema.Finite,
aggregateID: Schema.String,
data: definition.data,
}),
}).annotate({ identifier: `SyncEvent.${definition.type}` }),
]
})

View File

@ -24,6 +24,7 @@ import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const root = "/session"
export const ListQuery = Schema.Struct({
@ -57,13 +58,13 @@ export const UpdatePayload = Schema.Struct({
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))

View File

@ -1,27 +0,0 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
import { FileSystemGroup } from "./v2/fs"
import { QuestionGroup, SessionQuestionGroup } from "./v2/question"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.add(FileSystemGroup)
.add(QuestionGroup)
.add(SessionQuestionGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

View File

@ -1,12 +0,0 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
}),
)

View File

@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui"
import { UnauthorizedError } from "../errors"
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
@ -20,13 +20,6 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
},
) {}
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@opencode/ExperimentalHttpApiV2Authorization",
{
error: UnauthorizedError,
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
@ -152,27 +145,3 @@ export const ptyConnectAuthorizationLayer = Layer.effect(
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
return V2Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) =>
Effect.gen(function* () {
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
}),
),
)
}),
)
}),
)

View File

@ -44,6 +44,7 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
@ -56,6 +57,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { V2Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public"
import {
authorizationLayer,
@ -82,7 +84,8 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { v2Handlers } from "@opencode-ai/server/handlers"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@ -144,14 +147,17 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
providerHandlers,
sessionHandlers,
syncHandlers,
v2Handlers,
tuiHandlers,
workspaceHandlers,
]),
)
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@ -186,7 +192,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, v2Routes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@ -226,6 +232,7 @@ export function createRoutes(
ShareNext.defaultLayer,
Snapshot.defaultLayer,
EventV2Bridge.defaultLayer,
EventV2.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,

View File

@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "session.compaction" })
@ -201,7 +202,7 @@ export interface Interface {
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
@ -585,7 +586,7 @@ export const layer = Layer.effect(
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
auto: boolean
overflow?: boolean
}) {

View File

@ -5,6 +5,7 @@ import { NonNegativeInt } from "@opencode-ai/core/schema"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
@ -120,7 +121,7 @@ export const Info = Schema.Struct({
assistant: Schema.optional(
Schema.Struct({
system: Schema.Array(Schema.String),
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
path: Schema.Struct({
cwd: Schema.String,

View File

@ -241,7 +241,7 @@ export const layer = Layer.effect(
session: Session.Info
history: SessionV1.WithParts[]
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
}) {
if (input.session.parentID) return
if (!Session.isDefaultTitle(input.session.title)) return
@ -653,7 +653,7 @@ export const layer = Layer.effect(
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
sessionID: SessionID,
) {
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
@ -681,7 +681,7 @@ export const layer = Layer.effect(
if (current?.model) {
return {
providerID: ProviderV2.ID.make(current.model.providerID),
modelID: ProviderV2.ModelID.make(current.model.id),
modelID: ModelV2.ID.make(current.model.id),
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
}
}
@ -1679,7 +1679,7 @@ export const defaultLayer = Layer.suspend(() =>
)
const ModelRef = Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
})
export const PromptInput = Schema.Struct({

View File

@ -40,9 +40,10 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
@ -82,7 +83,7 @@ export function fromRow(row: SessionRow): Info {
agent: row.agent ?? undefined,
model: row.model
? {
id: ProviderV2.ModelID.make(row.model.id),
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: row.model.variant,
}
@ -112,13 +113,6 @@ export function fromRow(row: SessionRow): Info {
}
}
function eventLocation(info: Pick<Info, "directory" | "workspaceID">) {
return {
directory: AbsolutePath.make(info.directory),
workspaceID: info.workspaceID,
}
}
export function toRow(info: Info) {
return {
id: info.id,
@ -209,7 +203,7 @@ const Revert = Schema.Struct({
})
const Model = Schema.Struct({
id: ProviderV2.ModelID,
id: ModelV2.ID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
@ -544,20 +538,6 @@ export const layer: Layer.Layer<
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return
return {
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ?? undefined,
}
})
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
title?: string
@ -597,7 +577,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Created,
{ sessionID: result.id, info: result },
{ location: eventLocation(result) },
)
return result
@ -688,7 +667,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Deleted,
{ sessionID, info: session },
{ location: eventLocation(session) },
)
yield* events.remove(sessionID)
} catch (e) {
@ -698,14 +676,12 @@ export const layer: Layer.Layer<
const updateMessage = <T extends SessionV1.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(msg.sessionID)
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location })
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg })
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(part.sessionID)
yield* events.publish(
SessionV1.Event.PartUpdated,
{
@ -713,7 +689,6 @@ export const layer: Layer.Layer<
part: structuredClone(part),
time: Date.now(),
},
{ location },
)
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@ -819,7 +794,7 @@ export const layer: Layer.Layer<
revert: info.revert === null ? undefined : (info.revert ?? current.revert),
permission: info.permission === null ? undefined : (info.permission ?? current.permission),
} as Info
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) })
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next })
})
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
@ -917,14 +892,12 @@ export const layer: Layer.Layer<
sessionID: SessionID
messageID: MessageID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.MessageRemoved,
{
sessionID: input.sessionID,
messageID: input.messageID,
},
{ location },
)
return input.messageID
})
@ -934,7 +907,6 @@ export const layer: Layer.Layer<
messageID: MessageID
partID: PartID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.PartRemoved,
{
@ -942,7 +914,6 @@ export const layer: Layer.Layer<
messageID: input.messageID,
partID: input.partID,
},
{ location },
)
return input.partID
})

View File

@ -20,6 +20,7 @@ import { PartID } from "./schema"
import { Log } from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session.tools" })
@ -75,7 +76,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
for (const item of yield* registry.tools({
modelID: ProviderV2.ModelID.make(input.model.api.id),
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {

View File

@ -16,6 +16,7 @@ import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "share-next" })
@ -284,7 +285,7 @@ export const layer = Layer.effect(
.map((item) => [`${item.providerID}/${item.modelID}`, item] as const),
).values(),
),
(item) => provider.getModel(ProviderV2.ID.make(item.providerID), ProviderV2.ModelID.make(item.modelID)),
(item) => provider.getModel(ProviderV2.ID.make(item.providerID), ModelV2.ID.make(item.modelID)),
{ concurrency: 8 },
)

View File

@ -15,7 +15,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" })
@ -33,6 +32,9 @@ const SKILL_PATTERN = "**/SKILL.md"
const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode"
const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
const CUSTOMIZE_OPENCODE_SKILL_BODY = await Bun.file(
new URL("../../../core/src/plugin/skill/customize-opencode.md", import.meta.url),
).text()
export const Info = Schema.Struct({
name: Schema.String,

View File

@ -51,6 +51,7 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "tool.registry" })
@ -74,7 +75,7 @@ export interface Interface {
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: {
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
}

View File

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
@ -14,7 +15,7 @@ const command = (name: string): Command.Info => ({
})
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
providerID,
api: {
id,
@ -50,7 +51,7 @@ const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.Model
const snapshot = (directory: string) => {
const providerID = ProviderV2.ID.make(`provider-${directory}`)
const modelID = ProviderV2.ModelID.make(`model-${directory}`)
const modelID = ModelV2.ID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
@ -63,7 +64,7 @@ const snapshot = (directory: string) => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderV2.ID, Provider.Info>
@ -148,7 +149,7 @@ describe("ACP directory snapshot", () => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined()
expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)

View File

@ -12,6 +12,7 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
import * as ACPService from "@/acp/service"
import * as ACPError from "@/acp/error"
@ -19,9 +20,9 @@ import { UsageService } from "@/acp/usage"
import type { Provider } from "@/provider/provider"
const providerID = ProviderV2.ID.make("test")
const modelID = ProviderV2.ModelID.make("test-model")
const configuredModelID = ProviderV2.ModelID.make("configured-model")
const secondModelID = ProviderV2.ModelID.make("second-model")
const modelID = ModelV2.ID.make("test-model")
const configuredModelID = ModelV2.ID.make("configured-model")
const secondModelID = ModelV2.ID.make("second-model")
const provider: Provider.Info = {
id: providerID,

View File

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import * as ACPError from "@/acp/error"
import * as ACPSession from "@/acp/session"
import { testEffect } from "../lib/effect"
@ -10,7 +11,7 @@ const sessionTest = testEffect(ACPSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(modelID),
modelID: ModelV2.ID.make(modelID),
})
const mcpServer: McpServer = {

View File

@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { UsageService } from "@/acp/usage"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
@ -41,7 +42,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({
},
})
const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({
const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
@ -77,7 +78,7 @@ const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context:
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
const providerID = ProviderV2.ID.make("anthropic")
const modelID = ProviderV2.ModelID.make("claude-sonnet")
const modelID = ModelV2.ID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
@ -179,12 +180,12 @@ describe("acp usage", () => {
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
expect(first).toBe(200_000)

View File

@ -1,10 +1,11 @@
import { Effect, Layer } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export namespace ProviderTest {
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2")
const id = override.id ?? ModelV2.ID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
return {
id,

View File

@ -18,6 +18,7 @@ import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
@ -75,7 +76,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
{
model: {
providerID: ProviderV2.ID.anthropic,
modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
},
},
out,

View File

@ -10,6 +10,7 @@ import { Provider } from "@/provider/provider"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
@ -113,7 +114,7 @@ it.instance(
() =>
Effect.gen(function* () {
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ProviderV2.ModelID.make("openai.gpt-5.5"))
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
@ -143,7 +144,7 @@ it.instance(
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(
ProviderV2.ID.amazonBedrock,
ProviderV2.ModelID.make("openai.gpt-oss-safeguard-120b"),
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
)
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")

View File

@ -14,6 +14,7 @@ import { createUnified } from "ai-gateway-provider/providers/unified"
import { ProviderTransform } from "@/provider/transform"
import type * as Provider from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
type Captured = { url: string; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
@ -56,7 +57,7 @@ afterEach(() => {
})
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
id: ProviderV2.ModelID.make(`cloudflare-ai-gateway/${apiId}`),
id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`),
providerID: ProviderV2.ID.make("cloudflare-ai-gateway"),
name: apiId,
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },

View File

@ -4,6 +4,7 @@ import { streamText } from "ai"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
@ -31,7 +32,7 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", ()
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
@ -55,7 +56,7 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () =
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
@ -89,7 +90,7 @@ it.live("headerTimeout aborts when response headers do not arrive", () =>
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
@ -121,7 +122,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],

View File

@ -19,6 +19,7 @@ import { Filesystem } from "@/util/filesystem"
import { InstanceLayer } from "@/project/instance-layer"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const originalEnv = new Map<string, string | undefined>()
@ -293,7 +294,7 @@ it.instance("getModel returns model for valid provider/model", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
@ -306,7 +307,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const exit = yield* Provider.use
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model"))
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("nonexistent-model"))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
@ -315,7 +316,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
it.instance("getModel throws ModelNotFoundError for invalid provider", () =>
Effect.gen(function* () {
const exit = yield* Provider.use
.getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model"))
.getModel(ProviderV2.ID.make("nonexistent-provider"), ModelV2.ID.make("some-model"))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
@ -464,7 +465,7 @@ it.instance(
const providers = yield* list
expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined()
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet"))
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("my-sonnet"))
expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
@ -981,11 +982,11 @@ it.instance("getModel returns consistent results", () =>
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model1 = yield* Provider.use.getModel(
ProviderV2.ID.anthropic,
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
ModelV2.ID.make("claude-sonnet-4-20250514"),
)
const model2 = yield* Provider.use.getModel(
ProviderV2.ID.anthropic,
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
ModelV2.ID.make("claude-sonnet-4-20250514"),
)
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
@ -1017,7 +1018,7 @@ it.instance("ModelNotFoundError includes suggestions for typos", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const error = yield* Provider.use
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4"))
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonet-4"))
.pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect((error.suggestions ?? []).length).toBeGreaterThan(0)
@ -1028,7 +1029,7 @@ it.instance("ModelNotFoundError for provider includes suggestions", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const error = yield* Provider.use
.getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4"))
.getModel(ProviderV2.ID.make("antropic"), ModelV2.ID.make("claude-sonnet-4"))
.pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect(error.suggestions).toContain("anthropic")
@ -1039,7 +1040,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers",
Effect.gen(function* () {
yield* remove("OPENCODE_API_KEY")
const error = yield* Provider.use
.getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model"))
.getModel(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-fake-model"))
.pipe(Effect.flip)
if (!Provider.ModelNotFoundError.isInstance(error)) throw error
expect(error.suggestions ?? []).toContain("claude-haiku-4-5")
@ -1577,7 +1578,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1593,7 +1594,7 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex-anthropic"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1609,7 +1610,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1700,13 +1701,13 @@ it.effect("plugin config providers persist after instance dispose", () =>
const first = yield* loadAndList
expect(first[ProviderV2.ID.make("demo")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
yield* Effect.promise(() => disposeAllInstances())
const second = yield* loadAndList
expect(second[ProviderV2.ID.make("demo")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
}).pipe(provideMultiInstance),
)

View File

@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ProviderTransform } from "@/provider/transform"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
describe("ProviderTransform.options - setCacheKey", () => {
const sessionID = "test-session-123"
@ -1123,7 +1124,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ProviderV2.ModelID.make("deepseek/deepseek-chat"),
id: ModelV2.ID.make("deepseek/deepseek-chat"),
providerID: ProviderV2.ID.make("deepseek"),
api: {
id: "deepseek-chat",
@ -1185,7 +1186,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ProviderV2.ModelID.make("openai/gpt-4"),
id: ModelV2.ID.make("openai/gpt-4"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-4",

View File

@ -43,6 +43,22 @@ function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
function data(validate: (value: any) => void) {
return (body: any) => {
object(body)
validate(body.data)
}
}
function locationData(validate: (value: any) => void) {
return (body: any) => {
object(body)
object(body.location)
object(body.location.project)
validate(body.data)
}
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@ -609,20 +625,48 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}),
),
http.protected.get("/api/model", "v2.model.list").json(200, array),
http.protected.get("/api/provider", "v2.provider.list").json(200, array),
http.protected.get("/api/health", "v2.health.get").json(200, (body) => {
object(body)
check(body.healthy === true, "v2 server should report healthy")
}),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
http.protected
.get("/api/event", "v2.event.subscribe")
.stream()
.status(
200,
(ctx, result) =>
Effect.sync(() => {
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location")
}),
"status",
),
http.protected
.get("/api/fs/read", "v2.fs.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, object),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array),
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
@ -630,7 +674,7 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@ -666,7 +710,10 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
object(body)
array(body.data)
}),
http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
@ -678,7 +725,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@ -701,7 +748,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@ -723,7 +770,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",

View File

@ -12,6 +12,7 @@ import { original } from "./environment"
import { runtime } from "./runtime"
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export function runScenario(options: Options) {
return (scenario: Scenario) => {
@ -153,7 +154,7 @@ function withContext<A, E>(
agent: "build",
model: {
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
},
}
const part: SessionV1.TextPart = {

View File

@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type OpenApiSchema = {
readonly $ref?: string
readonly items?: OpenApiSchema
readonly properties?: Record<string, OpenApiSchema>
}
type OpenApiSpec = {
readonly components?: { readonly schemas?: Record<string, OpenApiSchema> }
readonly paths: Record<
string,
{
readonly get?: {
readonly responses?: Record<string, { readonly content?: Record<string, { schema?: OpenApiSchema }> }>
}
}
>
}
function responseSchema(spec: OpenApiSpec, path: string) {
return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema
}
function componentName(ref: string | undefined) {
return ref?.replace("#/components/schemas/", "")
}
describe("PublicApi v2 catalog redaction", () => {
test("routes use redacted provider and model DTO schemas", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const provider = responseSchema(spec, "/api/provider/{providerID}")
const providers = responseSchema(spec, "/api/provider")
const models = responseSchema(spec, "/api/model")
expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo")
const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties
const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties
expect(providerProperties).not.toHaveProperty("request")
expect(modelProperties).not.toHaveProperty("request")
expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/)
expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/)
})
test("DTOs sanitize provider and model API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const providers = [
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "native",
url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment",
settings: {},
},
}),
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment",
},
}),
].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider)))
const models = [
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")),
api: {
id: ModelV2.ID.make("native"),
type: "native",
url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment",
settings: {},
},
}),
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")),
api: {
id: ModelV2.ID.make("aisdk"),
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment",
},
}),
].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model)))
expect(providers.map((provider) => provider.api)).toEqual([
{ type: "native", url: "https://example.com:8443" },
{ type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" },
])
expect(models.map((model) => model.api)).toEqual([
{ id: "native", type: "native", url: "https://example.com:9443" },
{ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" },
])
expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/)
})
test("DTOs omit malformed API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const provider = Schema.encodeSync(ProviderV2.PublicInfo)(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} },
}),
),
)
const modelID = ModelV2.ID.make("aisdk")
const model = Schema.encodeSync(ModelV2.PublicInfo)(
ModelV2.toPublic(
new ModelV2.Info({
...ModelV2.Info.empty(providerID, modelID),
api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" },
}),
),
)
expect(provider.api).toEqual({ type: "native" })
expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" })
expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/)
})
})

View File

@ -3,7 +3,14 @@ import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type Method = "get" | "post" | "put" | "delete" | "patch"
type OpenApiSchema = { readonly $ref?: string; readonly anyOf?: ReadonlyArray<OpenApiSchema> }
type OpenApiSchema = {
readonly $ref?: string
readonly anyOf?: ReadonlyArray<OpenApiSchema>
readonly type?: string
readonly enum?: readonly unknown[]
readonly properties?: Record<string, OpenApiSchema>
readonly required?: readonly string[]
}
type OpenApiResponse = {
readonly description?: string
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
@ -20,7 +27,10 @@ type OpenApiOperation = {
readonly security?: unknown
}
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
type OpenApiSpec = {
readonly paths: Record<string, OpenApiPathItem>
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
}
const methods = ["get", "post", "put", "delete", "patch"] as const
@ -56,6 +66,23 @@ function isBuiltInEndpointError(name: string) {
}
describe("PublicApi OpenAPI v2 errors", () => {
test("documents nested legacy global sync events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const schema = spec.components.schemas.SyncEventSessionCreated
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
expect(schema?.properties?.type?.enum).toEqual(["sync"])
expect(schema?.properties?.syncEvent).toMatchObject({
required: ["type", "id", "seq", "aggregateID", "data"],
properties: {
type: { enum: ["session.created.1"] },
id: { type: "string" },
seq: { type: "number" },
aggregateID: { type: "string" },
},
})
})
test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec

View File

@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"

View File

@ -13,6 +13,7 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
@ -32,7 +33,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()

View File

@ -25,6 +25,7 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Database } from "@opencode-ai/core/database/database"
import { httpApiLayer } from "./httpapi-layer"
@ -310,7 +311,7 @@ function seedMessage(directory: string, sessionID: string) {
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
tools: {},
} satisfies SessionV1.User)
const part = yield* svc.updatePart({
@ -392,7 +393,7 @@ describe("HttpApi SDK", () => {
const url = new URL(request!.url)
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ content: "hello" })
expect(file.data).toMatchObject({ data: { content: "hello" } })
expect(url.searchParams.get("directory")).toBe(directory)
expect(url.searchParams.get("workspace")).toBe(workspaceID)
expect(url.searchParams.get("location[directory]")).toBe(directory)

View File

@ -88,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const part = yield* svc.updatePart({
@ -391,8 +391,9 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id)
expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
.items,
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
headers,
})).data,
).toMatchObject([{ type: "assistant" }])
}),
{ git: true, config: { formatter: false, lsp: false } },
@ -456,7 +457,7 @@ describe("session HttpApi", () => {
})}`,
{ headers },
)
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor.next
expect(sessionCursor).toBeTruthy()
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
order: "asc",
@ -483,10 +484,10 @@ describe("session HttpApi", () => {
})
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageCursor = messageBody.cursor.next
expect(messageCursor).toBeTruthy()
expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id])
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
id: secondMessage.id,
order: "desc",
@ -497,7 +498,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from(
@ -507,7 +508,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request(
@ -587,17 +588,17 @@ describe("session HttpApi", () => {
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
type PromptBody = { id: string; type: string; text: string }
const firstBody = yield* json<PromptBody>(first)
const retriedBody = yield* json<PromptBody>(retried)
const firstBody = yield* json<{ data: PromptBody }>(first)
const retriedBody = yield* json<{ data: PromptBody }>(retried)
expect(first.status).toBe(200)
expect(retried.status).toBe(200)
expect(retriedBody).toEqual(firstBody)
expect(firstBody).toMatchObject({ type: "user", text: "hello" })
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, {
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers,
})
expect(messages.items).toHaveLength(0)
expect(messages.data).toHaveLength(0)
const admitted = yield* Database.Service.use(({ db }) =>
db
.select()

View File

@ -0,0 +1,82 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context, Schema } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return HttpApiApp.webHandler().handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
)
}
const Event = Schema.Struct({
id: Schema.String,
type: Schema.String,
location: Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
data: Schema.Unknown,
})
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const value = await reader.read()
if (value.done) throw new Error("event stream closed")
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
}
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
for (let index = 0; index < 20; index++) {
const event = await readEvent(reader)
if (event.type === type) return event
}
throw new Error(`timed out waiting for ${type}`)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("v2 location HttpApi", () => {
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
for (const route of ["/api/command", "/api/skill"]) {
const response = await request(route, tmp.path)
expect(response.status).toBe(200)
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
expect(body.data).toBeArray()
expect(body.location.directory).toBe(tmp.path)
expect(body.location.project.id).toBeTruthy()
}
})
test("streams native EventV2 payloads with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path)
const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected")
const created = await request("/session", tmp.path, { method: "POST" })
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } },
data: { sessionID: expect.any(String) },
})
await reader.cancel()
})
})

View File

@ -18,6 +18,7 @@ import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
@ -31,7 +32,7 @@ function seedNegativeTokenSession() {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()

View File

@ -18,6 +18,7 @@ import { Storage } from "@/storage/storage"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageID } from "@/session/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@ -79,7 +80,7 @@ describe("session diff with missing patch (#26574)", () => {
role: "user",
time: { created: Date.now() },
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
summary: {
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
},

View File

@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
@ -18,7 +19,7 @@ const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
const model = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
}
afterEach(async () => {

View File

@ -33,6 +33,7 @@ import { TestConfig } from "../fixture/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LLMEvent, Usage } from "@opencode-ai/llm"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
void Log.init({ print: false })
@ -47,7 +48,7 @@ const summary = Layer.succeed(
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
}
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)

Some files were not shown because too many files have changed in this diff Show More