Просмотр исходного кода

feat: unwrap config namespaces to flat exports + barrel (#22746)

Kit Langton 17 часов назад
Родитель
Сommit
1ca257e356
30 измененных файлов с 409 добавлено и 412 удалено
  1. 1 1
      packages/opencode/script/schema.ts
  2. 1 1
      packages/opencode/src/cli/cmd/plug.ts
  3. 1 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  4. 1 1
      packages/opencode/src/cli/cmd/tui/attach.ts
  5. 1 1
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  6. 1 1
      packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
  7. 1 1
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  8. 1 1
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  9. 1 1
      packages/opencode/src/cli/cmd/tui/thread.ts
  10. 1 1
      packages/opencode/src/cli/cmd/tui/util/scroll.ts
  11. 1 1
      packages/opencode/src/cli/error.ts
  12. 2 2
      packages/opencode/src/config/config.ts
  13. 3 0
      packages/opencode/src/config/index.ts
  14. 78 80
      packages/opencode/src/config/markdown.ts
  15. 140 142
      packages/opencode/src/config/paths.ts
  16. 1 1
      packages/opencode/src/config/tui-migrate.ts
  17. 161 163
      packages/opencode/src/config/tui.ts
  18. 1 1
      packages/opencode/src/plugin/install.ts
  19. 1 1
      packages/opencode/src/session/prompt.ts
  20. 1 1
      packages/opencode/src/skill/skill.ts
  21. 1 1
      packages/opencode/test/cli/tui/plugin-add.test.ts
  22. 1 1
      packages/opencode/test/cli/tui/plugin-install.test.ts
  23. 1 1
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  24. 1 1
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  25. 1 1
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  26. 1 1
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  27. 1 1
      packages/opencode/test/cli/tui/thread.test.ts
  28. 1 1
      packages/opencode/test/config/markdown.test.ts
  29. 1 1
      packages/opencode/test/config/tui.test.ts
  30. 1 1
      packages/opencode/test/fixture/tui-runtime.ts

+ 1 - 1
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, {

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

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

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

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

+ 1 - 1
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({

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

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

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

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

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

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

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

+ 78 - 80
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 = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
-  export const SHELL_REGEX = /!`([^`]+)`/g
+export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
+export const SHELL_REGEX = /!`([^`]+)`/g
 
-  export function files(template: string) {
-    return Array.from(template.matchAll(FILE_REGEX))
-  }
+export function files(template: string) {
+  return Array.from(template.matchAll(FILE_REGEX))
+}
 
-  export function shell(template: string) {
-    return Array.from(template.matchAll(SHELL_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[] = []
 
-  // 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
-      }
+  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 processed = result.join("\n")
-    return content.replace(frontmatter, () => processed)
+    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)
   }
 
-  export async function parse(filePath: string) {
-    const template = await Filesystem.readText(filePath)
+  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 {
-      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 },
-        )
-      }
+      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 const FrontmatterError = NamedError.create(
+  "ConfigFrontmatterError",
+  z.object({
+    path: z.string(),
+    message: z.string(),
+  }),
+)

+ 140 - 142
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 async function projectFiles(name: string, directory: string, worktree: string) {
+  return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
+}
 
-  export function fileInDirectory(dir: string, name: string) {
-    return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
-  }
+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 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<z.core.$ZodIssue[]>().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 })
-    })
-  }
+export function fileInDirectory(dir: string, name: string) {
+  return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
+}
 
-  type ParseSource = string | { source: string; dir: string }
+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<z.core.$ZodIssue[]>().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 })
+  })
+}
 
-  function source(input: ParseSource) {
-    return typeof input === "string" ? input : input.source
-  }
+type ParseSource = string | { source: string; dir: string }
 
-  function dir(input: ParseSource) {
-    return typeof input === "string" ? path.dirname(input) : input.dir
-  }
+function source(input: ParseSource) {
+  return typeof input === "string" ? input : input.source
+}
 
-  /** 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] || ""
-    })
+function dir(input: ParseSource) {
+  return typeof input === "string" ? path.dirname(input) : input.dir
+}
 
-    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)
+/** 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
     }
 
-    out += text.slice(cursor)
-    return out
+    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
   }
 
-  /** 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 ---`,
+  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")
 
-    return data
+    throw new JsonError({
+      path: configSource,
+      message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
+    })
   }
+
+  return data
 }

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

+ 161 - 163
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 State = {
-    config: Info
-    deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-  }
+type Acc = {
+  result: Info
+}
 
-  export type Info = z.output<typeof Info> & {
-    // Internal resolved plugin list used by runtime loading.
-    plugin_origins?: Config.PluginOrigin[]
-  }
+type State = {
+  config: Info
+  deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
+}
 
-  export interface Interface {
-    readonly get: () => Effect.Effect<Info>
-    readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
-  }
+export type Info = z.output<typeof Info> & {
+  // Internal resolved plugin list used by runtime loading.
+  plugin_origins?: Config.PluginOrigin[]
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
+export interface Interface {
+  readonly get: () => Effect.Effect<Info>
+  readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
+}
 
-  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"
-  }
+export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
 
-  function customPath() {
-    return Flag.OPENCODE_TUI_CONFIG
-  }
+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 normalize(raw: Record<string, unknown>) {
-    const data = { ...raw }
-    if (!("tui" in data)) return data
-    if (!isRecord(data.tui)) {
-      delete data.tui
-      return data
-    }
+function customPath() {
+  return Flag.OPENCODE_TUI_CONFIG
+}
 
-    const tui = data.tui
+function normalize(raw: Record<string, unknown>) {
+  const data = { ...raw }
+  if (!("tui" in data)) return data
+  if (!isRecord(data.tui)) {
     delete data.tui
-    return {
-      ...tui,
-      ...data,
-    }
+    return 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
+  const tui = data.tui
+  delete data.tui
+  return {
+    ...tui,
+    ...data,
   }
+}
 
-  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)
-    }
+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
+}
 
-    if (custom) {
-      await mergeFile(acc, custom, ctx)
-      log.debug("loaded custom tui config", { path: custom })
-    }
+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 projectFiles) {
-      await mergeFile(acc, file, ctx)
-    }
+  for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+    await mergeFile(acc, file, ctx)
+  }
 
-    const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
+  if (custom) {
+    await mergeFile(acc, custom, ctx)
+    log.debug("loaded custom tui config", { path: custom })
+  }
 
-    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)
-      }
-    }
+  for (const file of projectFiles) {
+    await mergeFile(acc, file, ctx)
+  }
 
-    if (existsSync(managed)) {
-      for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
-        await mergeFile(acc, file, ctx)
-      }
-    }
+  const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
 
-    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(
-        ",",
-      )
+  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)
     }
-    acc.result.keybinds = Config.Keybinds.parse(keybinds)
+  }
 
-    return {
-      config: acc.result,
-      dirs: acc.result.plugin?.length ? dirs : [],
+  if (existsSync(managed)) {
+    for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
+      await mergeFile(acc, file, ctx)
     }
   }
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const cfg = yield* Config.Service
-      const state = yield* InstanceState.make<State>(
-        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())
+  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)
 
-  export async function waitForDependencies() {
-    await runPromise((svc) => svc.waitForDependencies())
+  return {
+    config: acc.result,
+    dirs: acc.result.plugin?.length ? dirs : [],
   }
+}
 
-  async function loadFile(filepath: string): Promise<Info> {
-    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 {}
-    })
-  }
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const cfg = yield* Config.Service
+    const state = yield* InstanceState.make<State>(
+      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())
+}
 
-  async function load(text: string, configFilepath: string): Promise<Info> {
-    const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
-    if (!isRecord(raw)) return {}
+export async function waitForDependencies() {
+  await runPromise((svc) => svc.waitForDependencies())
+}
 
-    // 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)
+async function loadFile(filepath: string): Promise<Info> {
+  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 {}
+  })
+}
 
-    const parsed = Info.safeParse(normalized)
-    if (!parsed.success) {
-      log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
-      return {}
-    }
+async function load(text: string, configFilepath: string): Promise<Info> {
+  const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
+  if (!isRecord(raw)) 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)
-      }
-    }
+  // 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)
 
-    return data
+  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
 }

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
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<string, unknown>]