Dax Raad 8 месяцев назад
Родитель
Сommit
41dba0db08

+ 3 - 0
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=="],

+ 2 - 1
opencode.json

@@ -1,5 +1,6 @@
 {
   "$schema": "https://opencode.ai/config.json",
   "keybinds": {},
-  "mcp": {}
+  "mcp": {},
+  "provider": {}
 }

+ 2 - 1
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"
   }
 }

+ 13 - 0
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")
+}

+ 107 - 43
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()
   }

+ 38 - 18
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",
+    )
 }

+ 5 - 1
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 })
     }
 

+ 1 - 1
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"

+ 0 - 4
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> {

+ 3 - 3
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