|
@@ -8,6 +8,7 @@ import { mergeDeep } from "remeda"
|
|
|
import { Global } from "../global"
|
|
import { Global } from "../global"
|
|
|
import fs from "fs/promises"
|
|
import fs from "fs/promises"
|
|
|
import { lazy } from "../util/lazy"
|
|
import { lazy } from "../util/lazy"
|
|
|
|
|
+import { NamedError } from "../util/error"
|
|
|
|
|
|
|
|
export namespace Config {
|
|
export namespace Config {
|
|
|
const log = Log.create({ service: "config" })
|
|
const log = Log.create({ service: "config" })
|
|
@@ -15,27 +16,9 @@ export namespace Config {
|
|
|
export const state = App.state("config", async (app) => {
|
|
export const state = App.state("config", async (app) => {
|
|
|
let result = await global()
|
|
let result = await global()
|
|
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
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)
|
|
log.info("loaded", result)
|
|
@@ -45,9 +28,16 @@ export namespace Config {
|
|
|
export const McpLocal = z
|
|
export const McpLocal = z
|
|
|
.object({
|
|
.object({
|
|
|
type: z.literal("local").describe("Type of MCP server connection"),
|
|
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({
|
|
.openapi({
|
|
|
ref: "Config.McpLocal",
|
|
ref: "Config.McpLocal",
|
|
|
})
|
|
})
|
|
@@ -57,6 +47,7 @@ export namespace Config {
|
|
|
type: z.literal("remote").describe("Type of MCP server connection"),
|
|
type: z.literal("remote").describe("Type of MCP server connection"),
|
|
|
url: z.string().describe("URL of the remote MCP server"),
|
|
url: z.string().describe("URL of the remote MCP server"),
|
|
|
})
|
|
})
|
|
|
|
|
+ .strict()
|
|
|
.openapi({
|
|
.openapi({
|
|
|
ref: "Config.McpRemote",
|
|
ref: "Config.McpRemote",
|
|
|
})
|
|
})
|
|
@@ -66,41 +57,84 @@ export namespace Config {
|
|
|
|
|
|
|
|
export const Keybinds = z
|
|
export const Keybinds = z
|
|
|
.object({
|
|
.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"),
|
|
help: z.string().optional().describe("Show help dialog"),
|
|
|
editor_open: z.string().optional().describe("Open external editor"),
|
|
editor_open: z.string().optional().describe("Open external editor"),
|
|
|
session_new: z.string().optional().describe("Create a new session"),
|
|
session_new: z.string().optional().describe("Create a new session"),
|
|
|
session_list: z.string().optional().describe("List all sessions"),
|
|
session_list: z.string().optional().describe("List all sessions"),
|
|
|
session_share: z.string().optional().describe("Share current session"),
|
|
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"),
|
|
tool_details: z.string().optional().describe("Show tool details"),
|
|
|
model_list: z.string().optional().describe("List available models"),
|
|
model_list: z.string().optional().describe("List available models"),
|
|
|
theme_list: z.string().optional().describe("List available themes"),
|
|
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_clear: z.string().optional().describe("Clear input field"),
|
|
|
input_paste: z.string().optional().describe("Paste from clipboard"),
|
|
input_paste: z.string().optional().describe("Paste from clipboard"),
|
|
|
input_submit: z.string().optional().describe("Submit input"),
|
|
input_submit: z.string().optional().describe("Submit input"),
|
|
|
input_newline: z.string().optional().describe("Insert newline in 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_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"),
|
|
messages_last: z.string().optional().describe("Navigate to last message"),
|
|
|
app_exit: z.string().optional().describe("Exit the application"),
|
|
app_exit: z.string().optional().describe("Exit the application"),
|
|
|
})
|
|
})
|
|
|
|
|
+ .strict()
|
|
|
.openapi({
|
|
.openapi({
|
|
|
ref: "Config.Keybinds",
|
|
ref: "Config.Keybinds",
|
|
|
})
|
|
})
|
|
|
export const Info = z
|
|
export const Info = z
|
|
|
.object({
|
|
.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"),
|
|
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
|
|
autoshare: z
|
|
autoshare: z
|
|
|
.boolean()
|
|
.boolean()
|
|
@@ -129,8 +163,12 @@ export namespace Config {
|
|
|
)
|
|
)
|
|
|
.optional()
|
|
.optional()
|
|
|
.describe("Custom provider configurations and model overrides"),
|
|
.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({
|
|
.openapi({
|
|
|
ref: "Config.Info",
|
|
ref: "Config.Info",
|
|
|
})
|
|
})
|
|
@@ -138,10 +176,7 @@ export namespace Config {
|
|
|
export type Info = z.output<typeof Info>
|
|
export type Info = z.output<typeof Info>
|
|
|
|
|
|
|
|
export const global = lazy(async () => {
|
|
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"), {
|
|
await import(path.join(Global.Path.config, "config"), {
|
|
|
with: {
|
|
with: {
|
|
@@ -160,9 +195,38 @@ export namespace Config {
|
|
|
await fs.unlink(path.join(Global.Path.config, "config"))
|
|
await fs.unlink(path.join(Global.Path.config, "config"))
|
|
|
})
|
|
})
|
|
|
.catch(() => {})
|
|
.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() {
|
|
export function get() {
|
|
|
return state()
|
|
return state()
|
|
|
}
|
|
}
|