summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock3
-rw-r--r--opencode.json3
-rw-r--r--packages/opencode/package.json3
-rw-r--r--packages/opencode/src/cli/error.ts13
-rw-r--r--packages/opencode/src/config/config.ts150
-rw-r--r--packages/opencode/src/index.ts56
-rw-r--r--packages/opencode/src/provider/provider.ts6
-rw-r--r--packages/opencode/src/server/server.ts2
-rw-r--r--packages/opencode/src/util/error.ts4
-rw-r--r--packages/opencode/src/util/log.ts6
10 files changed, 174 insertions, 72 deletions
diff --git a/bun.lock b/bun.lock
index 01573b4e3..3381985d4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -43,6 +43,7 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4",
+ "zod-validation-error": "3.5.2",
},
"devDependencies": {
"@ai-sdk/anthropic": "1.2.12",
@@ -1655,6 +1656,8 @@
"zod-to-ts": ["[email protected]", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
+ "zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0" } }, "sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw=="],
+
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
diff --git a/opencode.json b/opencode.json
index d69947d23..4fa088bec 100644
--- a/opencode.json
+++ b/opencode.json
@@ -1,5 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {},
- "mcp": {}
+ "mcp": {},
+ "provider": {}
}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 0895476b4..5a409c7f5 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -42,6 +42,7 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
- "zod-openapi": "4.2.4"
+ "zod-openapi": "4.2.4",
+ "zod-validation-error": "3.5.2"
}
}
diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts
new file mode 100644
index 000000000..21df94a62
--- /dev/null
+++ b/packages/opencode/src/cli/error.ts
@@ -0,0 +1,13 @@
+import { Config } from "../config/config"
+
+export function FormatError(input: unknown) {
+ if (Config.JsonError.isInstance(input))
+ return `Config file at ${input.data.path} is not valid JSON`
+ if (Config.InvalidError.isInstance(input))
+ return [
+ `Config file at ${input.data.path} is invalid`,
+ ...(input.data.issues?.map(
+ (issue) => "↳ " + issue.message + " " + issue.path.join("."),
+ ) ?? []),
+ ].join("\n")
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 4fc6c0213..d53f4dda3 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -8,6 +8,7 @@ import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
+import { NamedError } from "../util/error"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -15,27 +16,9 @@ export namespace Config {
export const state = App.state("config", async (app) => {
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
- const [resolved] = await Filesystem.findUp(
- file,
- app.path.cwd,
- app.path.root,
- )
- if (!resolved) continue
- try {
- result = mergeDeep(
- result,
- await import(resolved).then((mod) => Info.parse(mod.default)),
- )
- log.info("found", { path: resolved })
- break
- } catch (e) {
- if (e instanceof z.ZodError) {
- for (const issue of e.issues) {
- log.info(issue.message)
- }
- throw e
- }
- continue
+ const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
+ for (const resolved of found.toReversed()) {
+ result = mergeDeep(result, await load(resolved))
}
}
log.info("loaded", result)
@@ -45,9 +28,16 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
- command: z.string().array().describe("Command and arguments to run the MCP server"),
- environment: z.record(z.string(), z.string()).optional().describe("Environment variables to set when running the MCP server"),
+ command: z
+ .string()
+ .array()
+ .describe("Command and arguments to run the MCP server"),
+ environment: z
+ .record(z.string(), z.string())
+ .optional()
+ .describe("Environment variables to set when running the MCP server"),
})
+ .strict()
.openapi({
ref: "Config.McpLocal",
})
@@ -57,6 +47,7 @@ export namespace Config {
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
})
+ .strict()
.openapi({
ref: "Config.McpRemote",
})
@@ -66,41 +57,84 @@ export namespace Config {
export const Keybinds = z
.object({
- leader: z.string().optional().describe("Leader key for keybind combinations"),
+ leader: z
+ .string()
+ .optional()
+ .describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
- session_interrupt: z.string().optional().describe("Interrupt current session"),
- session_compact: z.string().optional().describe("Toggle compact mode for session"),
+ session_interrupt: z
+ .string()
+ .optional()
+ .describe("Interrupt current session"),
+ session_compact: z
+ .string()
+ .optional()
+ .describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
- project_init: z.string().optional().describe("Initialize project configuration"),
+ project_init: z
+ .string()
+ .optional()
+ .describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
- history_previous: z.string().optional().describe("Navigate to previous history item"),
- history_next: z.string().optional().describe("Navigate to next history item"),
- messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
- messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
- messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
- messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
- messages_previous: z.string().optional().describe("Navigate to previous message"),
+ history_previous: z
+ .string()
+ .optional()
+ .describe("Navigate to previous history item"),
+ history_next: z
+ .string()
+ .optional()
+ .describe("Navigate to next history item"),
+ messages_page_up: z
+ .string()
+ .optional()
+ .describe("Scroll messages up by one page"),
+ messages_page_down: z
+ .string()
+ .optional()
+ .describe("Scroll messages down by one page"),
+ messages_half_page_up: z
+ .string()
+ .optional()
+ .describe("Scroll messages up by half page"),
+ messages_half_page_down: z
+ .string()
+ .optional()
+ .describe("Scroll messages down by half page"),
+ messages_previous: z
+ .string()
+ .optional()
+ .describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
- messages_first: z.string().optional().describe("Navigate to first message"),
+ messages_first: z
+ .string()
+ .optional()
+ .describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
+ .strict()
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
- $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
- theme: z.string().optional().describe("Theme name to use for the interface"),
+ $schema: z
+ .string()
+ .optional()
+ .describe("JSON schema reference for configuration validation"),
+ theme: z
+ .string()
+ .optional()
+ .describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
@@ -129,8 +163,12 @@ export namespace Config {
)
.optional()
.describe("Custom provider configurations and model overrides"),
- mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
+ mcp: z
+ .record(z.string(), Mcp)
+ .optional()
+ .describe("MCP (Model Context Protocol) server configurations"),
})
+ .strict()
.openapi({
ref: "Config.Info",
})
@@ -138,10 +176,7 @@ export namespace Config {
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
- let result = await Bun.file(path.join(Global.Path.config, "config.json"))
- .json()
- .then((mod) => Info.parse(mod))
- .catch(() => ({}) as Info)
+ let result = await load(path.join(Global.Path.config, "config.json"))
await import(path.join(Global.Path.config, "config"), {
with: {
@@ -160,9 +195,38 @@ export namespace Config {
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
- return Info.parse(result)
+
+ return result
})
+ async function load(path: string) {
+ const data = await Bun.file(path)
+ .json()
+ .catch((err) => {
+ if (err.code === "ENOENT") return {}
+ throw new JsonError({ path }, { cause: err })
+ })
+
+ const parsed = Info.safeParse(data)
+ if (parsed.success) return parsed.data
+ throw new InvalidError({ path, issues: parsed.error.issues })
+ }
+
+ export const JsonError = NamedError.create(
+ "ConfigJsonError",
+ z.object({
+ path: z.string(),
+ }),
+ )
+
+ export const InvalidError = NamedError.create(
+ "ConfigInvalidError",
+ z.object({
+ path: z.string(),
+ issues: z.custom<z.ZodIssue[]>().optional(),
+ }),
+ )
+
export function get() {
return state()
}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 9013ded2c..ffe4dcc79 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -18,6 +18,8 @@ import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
+import { NamedError } from "./util/error"
+import { FormatError } from "./cli/error"
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
@@ -84,21 +86,21 @@ const cli = yargs(hideBin(process.argv))
},
})
- ; (async () => {
- if (Installation.VERSION === "dev") return
- if (Installation.isSnapshot()) return
- const config = await Config.global()
- if (config.autoupdate === false) return
- const latest = await Installation.latest()
- if (Installation.VERSION === latest) return
- const method = await Installation.method()
- if (method === "unknown") return
- await Installation.upgrade(method, latest)
- .then(() => {
- Bus.publish(Installation.Event.Updated, { version: latest })
- })
- .catch(() => { })
- })()
+ ;(async () => {
+ if (Installation.VERSION === "dev") return
+ if (Installation.isSnapshot()) return
+ const config = await Config.global()
+ if (config.autoupdate === false) return
+ const latest = await Installation.latest()
+ if (Installation.VERSION === latest) return
+ const method = await Installation.method()
+ if (method === "unknown") return
+ await Installation.upgrade(method, latest)
+ .then(() => {
+ Bus.publish(Installation.Event.Updated, { version: latest })
+ })
+ .catch(() => {})
+ })()
await proc.exited
server.stop()
@@ -133,7 +135,25 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
- Log.Default.error(e, {
- stack: e instanceof Error ? e.stack : undefined,
- })
+ const data: Record<string, any> = {}
+ if (e instanceof NamedError) {
+ const obj = e.toObject()
+ Object.assign(data, {
+ ...obj.data,
+ })
+ }
+ if (e instanceof Error) {
+ Object.assign(data, {
+ name: e.name,
+ message: e.message,
+ cause: e.cause?.toString(),
+ })
+ }
+ Log.Default.error("fatal", data)
+ const formatted = FormatError(e)
+ if (formatted) UI.error(formatted)
+ if (!formatted)
+ UI.error(
+ "Unexpected error, check log file at " + Log.file() + " for more details",
+ )
}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 6ce8c27c6..1215d29ed 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -181,7 +181,11 @@ export namespace Provider {
mergeProvider(providerID, provider.options ?? {}, "config")
}
- for (const providerID of Object.keys(providers)) {
+ for (const [providerID, provider] of Object.entries(providers)) {
+ if (Object.keys(provider.info.models).length === 0) {
+ delete providers[providerID]
+ continue
+ }
log.info("found", { providerID })
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index c7fd13a38..99e6caa3c 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -10,7 +10,7 @@ import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { Global } from "../global"
-import { mapValues } from "remeda"
+import { filter, mapValues } from "remeda"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts
index d8de15832..566c5fb2d 100644
--- a/packages/opencode/src/util/error.ts
+++ b/packages/opencode/src/util/error.ts
@@ -30,10 +30,6 @@ export abstract class NamedError extends Error {
) {
super(name, options)
this.name = name
- log.error(name, {
- ...this.data,
- cause: options?.cause?.toString(),
- })
}
static isInstance(input: any): input is InstanceType<typeof result> {
diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts
index 956af00f8..a6691d5d0 100644
--- a/packages/opencode/src/util/log.ts
+++ b/packages/opencode/src/util/log.ts
@@ -68,13 +68,13 @@ export namespace Log {
}
const result = {
info(message?: any, extra?: Record<string, any>) {
- process.stderr.write(build(message, extra))
+ process.stderr.write("INFO " + build(message, extra))
},
error(message?: any, extra?: Record<string, any>) {
- process.stderr.write(build(message, extra))
+ process.stderr.write("ERROR " + build(message, extra))
},
warn(message?: any, extra?: Record<string, any>) {
- process.stderr.write(build(message, extra))
+ process.stderr.write("WARN " + build(message, extra))
},
tag(key: string, value: string) {
if (tags) tags[key] = value