diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 4ea68d9bbb..4aa27423ba 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { Config } from "../src/config" -import { TuiConfig } from "../src/config/tui" +import { TuiConfig } from "../src/config" function generate(schema: z.ZodType) { const result = z.toJSONSchema(schema, { diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 42d06ff47f..9dfda16d64 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,7 +1,7 @@ import { intro, log, outro, spinner } from "@clack/prompts" import type { Argv } from "yargs" -import { ConfigPaths } from "../../config/paths" +import { ConfigPaths } from "../../config" import { Global } from "../../global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e7e9fd9cd2..9e96d5dcbc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d..9fcbf4c1f3 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -2,7 +2,7 @@ import { cmd } from "../cmd" import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Instance } from "@/project/instance" import { existsSync } from "fs" diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 9c883aa205..b1dcdd7808 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" import { Keybind } from "@/util" import { pipe, mapValues } from "remeda" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" import type { ParsedKey, Renderable } from "@opentui/core" import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 62dbf1ebd1..cfe59ba803 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -1,4 +1,4 @@ -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 3af70d8c25..42988fcb1f 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" import { createPluginKeybind } from "../context/plugin-keybinds" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index dd873b753a..da003607c4 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -14,7 +14,7 @@ import path from "path" import { fileURLToPath } from "url" import { Config } from "@/config" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3aaa5a54f8..89b32d166e 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -13,7 +13,7 @@ import { Filesystem } from "@/util" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Instance } from "@/project/instance" import { writeHeapSnapshot } from "v8" diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index 9b9398f302..d27bdb90ce 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -1,5 +1,5 @@ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" export class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 6ba110d34f..735f1a721e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,5 +1,5 @@ import { AccountServiceError, AccountTransportError } from "@/account" -import { ConfigMarkdown } from "@/config/markdown" +import { ConfigMarkdown } from "@/config" import { errorFormat } from "@/util/error" import { Config } from "../config" import { MCP } from "../mcp" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3da2dd6bdb..04801098b4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -21,7 +21,7 @@ import { import { Instance, type InstanceContext } from "../project/instance" import { LSPServer } from "../lsp/server" import { Installation } from "@/installation" -import { ConfigMarkdown } from "./markdown" +import { ConfigMarkdown } from "." import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" @@ -29,7 +29,7 @@ import { Event } from "../server/event" import { Glob } from "@opencode-ai/shared/util/glob" import { Account } from "@/account" import { isRecord } from "@/util/record" -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index 60e39c3163..d878fc99a2 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -1 +1,4 @@ export * as Config from "./config" +export * as ConfigMarkdown from "./markdown" +export * as ConfigPaths from "./paths" +export * as TuiConfig from "./tui" diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 8b5392be5e..7cad692665 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -3,97 +3,95 @@ import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "../util" -export namespace ConfigMarkdown { - export const FILE_REGEX = /(?" || value === "|" || value.startsWith('"') || value.startsWith("'")) { - result.push(line) - continue - } - - // if value contains a colon, convert to block scalar - if (value.includes(":")) { - result.push(`${key}: |-`) - result.push(` ${value}`) - continue - } - - result.push(line) - } - - const processed = result.join("\n") - return content.replace(frontmatter, () => processed) - } - - export async function parse(filePath: string) { - const template = await Filesystem.readText(filePath) - - try { - const md = matter(template) - return md - } catch { - try { - return matter(fallbackSanitization(template)) - } catch (err) { - throw new FrontmatterError( - { - path: filePath, - message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, - }, - { cause: err }, - ) - } - } - } - - export const FrontmatterError = NamedError.create( - "ConfigFrontmatterError", - z.object({ - path: z.string(), - message: z.string(), - }), - ) +export function files(template: string) { + return Array.from(template.matchAll(FILE_REGEX)) } + +export function shell(template: string) { + return Array.from(template.matchAll(SHELL_REGEX)) +} + +// other coding agents like claude code allow invalid yaml in their +// frontmatter, we need to fallback to a more permissive parser for those cases +export function fallbackSanitization(content: string): string { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return content + + const frontmatter = match[1] + const lines = frontmatter.split(/\r?\n/) + const result: string[] = [] + + for (const line of lines) { + // skip comments and empty lines + if (line.trim().startsWith("#") || line.trim() === "") { + result.push(line) + continue + } + + // skip lines that are continuations (indented) + if (line.match(/^\s+/)) { + result.push(line) + continue + } + + // match key: value pattern + const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/) + if (!kvMatch) { + result.push(line) + continue + } + + const key = kvMatch[1] + const value = kvMatch[2].trim() + + // skip if value is empty, already quoted, or uses block scalar + if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) { + result.push(line) + continue + } + + // if value contains a colon, convert to block scalar + if (value.includes(":")) { + result.push(`${key}: |-`) + result.push(` ${value}`) + continue + } + + result.push(line) + } + + const processed = result.join("\n") + return content.replace(frontmatter, () => processed) +} + +export async function parse(filePath: string) { + const template = await Filesystem.readText(filePath) + + try { + const md = matter(template) + return md + } catch { + try { + return matter(fallbackSanitization(template)) + } catch (err) { + throw new FrontmatterError( + { + path: filePath, + message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, + }, + { cause: err }, + ) + } + } +} + +export const FrontmatterError = NamedError.create( + "ConfigFrontmatterError", + z.object({ + path: z.string(), + message: z.string(), + }), +) diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index c5eb105c9d..82dde2df9f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -7,161 +7,159 @@ import { Filesystem } from "@/util" import { Flag } from "@/flag/flag" import { Global } from "@/global" -export namespace ConfigPaths { - export async function projectFiles(name: string, directory: string, worktree: string) { - return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) - } - - export async function directories(directory: string, worktree: string) { - return [ - Global.Path.config, - ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: directory, - stop: worktree, - }), - ) - : []), - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: Global.Path.home, - stop: Global.Path.home, - }), - )), - ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), - ] - } - - export function fileInDirectory(dir: string, name: string) { - return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] - } - - export const JsonError = NamedError.create( - "ConfigJsonError", - z.object({ - path: z.string(), - message: z.string().optional(), - }), - ) - - export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), - ) - - /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ - export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) - } - - type ParseSource = string | { source: string; dir: string } - - function source(input: ParseSource) { - return typeof input === "string" ? input : input.source - } - - function dir(input: ParseSource) { - return typeof input === "string" ? path.dirname(input) : input.dir - } - - /** Apply {env:VAR} and {file:path} substitutions to config text. */ - async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { - text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return process.env[varName] || "" - }) - - const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) - if (!fileMatches.length) return text - - const configDir = dir(input) - const configSource = source(input) - let out = "" - let cursor = 0 - - for (const match of fileMatches) { - const token = match[0] - const index = match.index! - out += text.slice(cursor, index) - - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { - out += token - cursor = index + token.length - continue - } - - let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) - } - - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - const fileContent = ( - await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" - - const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configSource, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) - }) - ).trim() - - out += JSON.stringify(fileContent).slice(1, -1) - cursor = index + token.length - } - - out += text.slice(cursor) - return out - } - - /** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ - export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") { - const configSource = source(input) - text = await substitute(text, input, missing) - - const errors: JsoncParseError[] = [] - const data = parseJsonc(text, errors, { allowTrailingComma: true }) - if (errors.length) { - const lines = text.split("\n") - const errorDetails = errors - .map((e) => { - const beforeOffset = text.substring(0, e.offset).split("\n") - const line = beforeOffset.length - const column = beforeOffset[beforeOffset.length - 1].length + 1 - const problemLine = lines[line - 1] - - const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` - if (!problemLine) return error - - return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` - }) - .join("\n") - - throw new JsonError({ - path: configSource, - message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, - }) - } - - return data - } +export async function projectFiles(name: string, directory: string, worktree: string) { + return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) +} + +export async function directories(directory: string, worktree: string) { + return [ + Global.Path.config, + ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: directory, + stop: worktree, + }), + ) + : []), + ...(await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + }), + )), + ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), + ] +} + +export function fileInDirectory(dir: string, name: string) { + return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] +} + +export const JsonError = NamedError.create( + "ConfigJsonError", + z.object({ + path: z.string(), + message: z.string().optional(), + }), +) + +export const InvalidError = NamedError.create( + "ConfigInvalidError", + z.object({ + path: z.string(), + issues: z.custom().optional(), + message: z.string().optional(), + }), +) + +/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ +export async function readFile(filepath: string) { + return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return + throw new JsonError({ path: filepath }, { cause: err }) + }) +} + +type ParseSource = string | { source: string; dir: string } + +function source(input: ParseSource) { + return typeof input === "string" ? input : input.source +} + +function dir(input: ParseSource) { + return typeof input === "string" ? path.dirname(input) : input.dir +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return process.env[varName] || "" + }) + + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + if (!fileMatches.length) return text + + const configDir = dir(input) + const configSource = source(input) + let out = "" + let cursor = 0 + + for (const match of fileMatches) { + const token = match[0] + const index = match.index! + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + if (filePath.startsWith("~/")) { + filePath = path.join(os.homedir(), filePath.slice(2)) + } + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const fileContent = ( + await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { + if (missing === "empty") return "" + + const errMsg = `bad file reference: "${token}"` + if (error.code === "ENOENT") { + throw new InvalidError( + { + path: configSource, + message: errMsg + ` ${resolvedPath} does not exist`, + }, + { cause: error }, + ) + } + throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) + }) + ).trim() + + out += JSON.stringify(fileContent).slice(1, -1) + cursor = index + token.length + } + + out += text.slice(cursor) + return out +} + +/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ +export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + const configSource = source(input) + text = await substitute(text, input, missing) + + const errors: JsoncParseError[] = [] + const data = parseJsonc(text, errors, { allowTrailingComma: true }) + if (errors.length) { + const lines = text.split("\n") + const errorDetails = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") + + throw new JsonError({ + path: configSource, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, + }) + } + + return data } diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index f9d37e479e..18cee554d5 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -2,7 +2,7 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" import z from "zod" -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import { TuiInfo, TuiOptions } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index c1e2b6e6b4..43f1bce460 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -3,7 +3,7 @@ import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { Config } from "." -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" import { Flag } from "@/flag/flag" @@ -14,201 +14,199 @@ import { InstanceState } from "@/effect" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -export namespace TuiConfig { - const log = Log.create({ service: "tui.config" }) +const log = Log.create({ service: "tui.config" }) - export const Info = TuiInfo +export const Info = TuiInfo - type Acc = { - result: Info - } +type Acc = { + result: Info +} - type State = { - config: Info - deps: Array> - } +type State = { + config: Info + deps: Array> +} - export type Info = z.output & { - // Internal resolved plugin list used by runtime loading. - plugin_origins?: Config.PluginOrigin[] - } +export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: Config.PluginOrigin[] +} - export interface Interface { - readonly get: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect - } +export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} - export class Service extends Context.Service()("@opencode/TuiConfig") {} +export class Service extends Context.Service()("@opencode/TuiConfig") {} - function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { - if (AppFileSystem.contains(ctx.directory, file)) return "local" - if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" - return "global" - } +function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { + if (AppFileSystem.contains(ctx.directory, file)) return "local" + if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" + return "global" +} - function customPath() { - return Flag.OPENCODE_TUI_CONFIG - } +function customPath() { + return Flag.OPENCODE_TUI_CONFIG +} - function normalize(raw: Record) { - const data = { ...raw } - if (!("tui" in data)) return data - if (!isRecord(data.tui)) { - delete data.tui - return data - } - - const tui = data.tui +function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { delete data.tui - return { - ...tui, - ...data, - } - } - - async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = Config.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins - } - - async function loadState(ctx: { directory: string; worktree: string }) { - let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree) - const custom = customPath() - const managed = Config.managedConfigDir() - await migrateTuiConfig({ directories, custom, managed }) - // Re-compute after migration since migrateTuiConfig may have created new tui.json files - projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - - const acc: Acc = { - result: {}, - } - - for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - await mergeFile(acc, file, ctx) - } - - if (custom) { - await mergeFile(acc, custom, ctx) - log.debug("loaded custom tui config", { path: custom }) - } - - for (const file of projectFiles) { - await mergeFile(acc, file, ctx) - } - - const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) - - for (const dir of dirs) { - if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue - for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - if (existsSync(managed)) { - for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - const keybinds = { ...acc.result.keybinds } - if (process.platform === "win32") { - // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. - keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( - ",", - ) - } - acc.result.keybinds = Config.Keybinds.parse(keybinds) - - return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], - } - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const state = yield* InstanceState.make( - Effect.fn("TuiConfig.state")(function* (ctx) { - const data = yield* Effect.promise(() => loadState(ctx)) - const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), { - concurrency: "unbounded", - }) - return { config: data.config, deps } - }), - ) - - const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config)) - - const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => - InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ), - ) - - return Service.of({ get, waitForDependencies }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get() { - return runPromise((svc) => svc.get()) - } - - export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) - } - - async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) - } - - async function load(text: string, configFilepath: string): Promise { - const raw = await ConfigPaths.parseText(text, configFilepath, "empty") - if (!isRecord(raw)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - const normalized = normalize(raw) - - const parsed = Info.safeParse(normalized) - if (!parsed.success) { - log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) - return {} - } - - const data = parsed.data - if (data.plugin) { - for (let i = 0; i < data.plugin.length; i++) { - data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath) - } - } - return data } + + const tui = data.tui + delete data.tui + return { + ...tui, + ...data, + } +} + +async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) { + const data = await loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = Config.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins +} + +async function loadState(ctx: { directory: string; worktree: string }) { + let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) + const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree) + const custom = customPath() + const managed = Config.managedConfigDir() + await migrateTuiConfig({ directories, custom, managed }) + // Re-compute after migration since migrateTuiConfig may have created new tui.json files + projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) + + const acc: Acc = { + result: {}, + } + + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + await mergeFile(acc, file, ctx) + } + + if (custom) { + await mergeFile(acc, custom, ctx) + log.debug("loaded custom tui config", { path: custom }) + } + + for (const file of projectFiles) { + await mergeFile(acc, file, ctx) + } + + const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) + + for (const dir of dirs) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + await mergeFile(acc, file, ctx) + } + } + + if (existsSync(managed)) { + for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { + await mergeFile(acc, file, ctx) + } + } + + const keybinds = { ...acc.result.keybinds } + if (process.platform === "win32") { + // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. + keybinds.terminal_suspend = "none" + keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( + ",", + ) + } + acc.result.keybinds = Config.Keybinds.parse(keybinds) + + return { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const state = yield* InstanceState.make( + Effect.fn("TuiConfig.state")(function* (ctx) { + const data = yield* Effect.promise(() => loadState(ctx)) + const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), { + concurrency: "unbounded", + }) + return { config: data.config, deps } + }), + ) + + const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + InstanceState.useEffect(state, (s) => + Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), + ), + ) + + return Service.of({ get, waitForDependencies }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function get() { + return runPromise((svc) => svc.get()) +} + +export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) +} + +async function loadFile(filepath: string): Promise { + const text = await ConfigPaths.readFile(filepath) + if (!text) return {} + return load(text, filepath).catch((error) => { + log.warn("failed to load tui config", { path: filepath, error }) + return {} + }) +} + +async function load(text: string, configFilepath: string): Promise { + const raw = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!isRecord(raw)) return {} + + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const normalized = normalize(raw) + + const parsed = Info.safeParse(normalized) + if (!parsed.success) { + log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) + return {} + } + + const data = parsed.data + if (data.plugin) { + for (let i = 0; i < data.plugin.length; i++) { + data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data } diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 0a6256d6f2..8b7e30c40e 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -7,7 +7,7 @@ import { printParseErrorCode, } from "jsonc-parser" -import { ConfigPaths } from "@/config/paths" +import { ConfigPaths } from "@/config" import { Global } from "@/global" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 157533af0a..1d4bb66bc5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -30,7 +30,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as Stream from "effect/Stream" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" -import { ConfigMarkdown } from "../config/markdown" +import { ConfigMarkdown } from "../config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index ef9f661cb5..f8ff7b8f5f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -12,7 +12,7 @@ import { Global } from "@/global" import { Permission } from "@/permission" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Config } from "../config" -import { ConfigMarkdown } from "../config/markdown" +import { ConfigMarkdown } from "../config" import { Glob } from "@opencode-ai/shared/util/glob" import { Log } from "../util" import { Discovery } from "./discovery" diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 748f291728..11865beddd 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index 290a7eea13..bd490ac4f9 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 68c3df4475..7020ac7426 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index f92d742924..25233adaa5 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 8446570cc3..4dc2aeccd4 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { Global } from "../../../src/global" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Filesystem } from "../../../src/util" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 10ddfe8e1c..3f04e3c6fa 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 1c5c7e65e4..7b781c49e8 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -8,7 +8,7 @@ import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Instance } from "../../../src/project/instance" const stop = new Error("stop") diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index 865af21077..b807850c39 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "bun:test" -import { ConfigMarkdown } from "../../src/config/markdown" +import { ConfigMarkdown } from "../../src/config" describe("ConfigMarkdown: normal template", () => { const template = `This is a @valid/path/to/a/file and it should also match at diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index c80905cd1d..62587d2704 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config" -import { TuiConfig } from "../../src/config/tui" +import { TuiConfig } from "../../src/config" import { Global } from "../../src/global" import { Filesystem } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index fdd3b6cfff..493b23f7e8 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,6 +1,6 @@ import { spyOn } from "bun:test" import path from "path" -import { TuiConfig } from "../../src/config/tui" +import { TuiConfig } from "../../src/config" type PluginSpec = string | [string, Record]