diff --git a/bun.lock b/bun.lock index a011a648fe..644de37f2e 100644 --- a/bun.lock +++ b/bun.lock @@ -523,7 +523,9 @@ "zod": "catalog:", }, "devDependencies": { + "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", "@types/semver": "catalog:", }, }, diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 348f05113e..2b20d9c312 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -1,6 +1,9 @@ research dist +dist-* gen app.log src/provider/models-snapshot.js src/provider/models-snapshot.d.ts +script/build-*.ts +temporary-*.md diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c0f82c1495..7ed33ebe09 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -14,6 +14,7 @@ "fix-node-pty": "bun run script/fix-node-pty.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", + "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "db": "bun drizzle-kit" }, "bin": { diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index cf63d67438..c0f302f21a 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" +import { TuiConfig } from "../src/cli/cmd/tui/config/tui" function generate(schema: z.ZodType) { const result = z.toJSONSchema(schema, { diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 57cce66680..53bc7ed5fb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -49,6 +49,7 @@ import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" +import { InstallationVersion } from "@/installation/version" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -570,7 +571,7 @@ export namespace ACP { authMethods: [authMethod], agentInfo: { name: "OpenCode", - version: Installation.VERSION, + version: InstallationVersion, }, } } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06c03d9f49..dc6d5e8896 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" +import { InstallationVersion } from "../../installation/version" import path from "path" import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" @@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({ params: { protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: "opencode-debug", version: Installation.VERSION }, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, id: 1, }), @@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({ try { const client = new Client({ name: "opencode-debug", - version: Installation.VERSION, + version: InstallationVersion, }) await client.connect(transport) prompts.log.success("Connection successful (already authenticated)") diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5102169b5c..8255c007d0 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" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" @@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise }) { renderer, }) const [ready, setReady] = createSignal(false) - TuiPluginRuntime.init(api) + TuiPluginRuntime.init({ + api, + config: tuiConfig, + }) .catch((error) => { console.error("Failed to load TUI plugins", error) }) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 9fcbf4c1f3..9a93f3f57a 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -2,9 +2,7 @@ import { cmd } from "../cmd" import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config" -import { Instance } from "@/project/instance" -import { existsSync } from "fs" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" export const AttachCommand = cmd({ command: "attach ", @@ -66,10 +64,7 @@ export const AttachCommand = cmd({ const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() - const config = await Instance.provide({ - directory: directory && existsSync(directory) ? directory : process.cwd(), - fn: () => TuiConfig.get(), - }) + const config = await TuiConfig.get() await tui({ url: args.url, config, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 365a22445b..017e52d2b4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -20,7 +20,7 @@ export function DialogAgent() { return ( { local.agent.set(option.value) diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index 38df35a04a..c74d3bbc63 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { createSignal } from "solid-js" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { win32FlushInputBuffer } from "../win32" import { getScrollAcceleration } from "../util/scroll" @@ -53,7 +53,7 @@ export function ErrorComponent(props: { ) } - issueURL.searchParams.set("opencode-version", Installation.VERSION) + issueURL.searchParams.set("opencode-version", InstallationVersion) const copyIssueURL = () => { void Clipboard.copy(issueURL.toString()).then(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 20003d8467..b4ab82729f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) { if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return + const agent = local.agent.current() + if (!agent) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() @@ -662,7 +664,7 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { void sdk.client.session.shell({ sessionID, - agent: local.agent.current().name, + agent: agent.name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, @@ -689,7 +691,7 @@ export function Prompt(props: PromptProps) { sessionID, command: command.slice(1), arguments: args, - agent: local.agent.current().name, + agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, @@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) { sessionID, ...selectedModel, messageID, - agent: local.agent.current().name, + agent: agent.name, model: selectedModel, variant, parts: [ @@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary - return local.agent.color(local.agent.current().name) + const agent = local.agent.current() + if (!agent) return theme.border + return local.agent.color(agent.name) }) const showVariant = createMemo(() => { @@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const color = local.agent.color(local.agent.current().name) + const agent = local.agent.current() + const color = agent ? local.agent.color(agent.name) : theme.border return { frames: createFrames({ color, @@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) { const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { - const mime = Filesystem.mimeType(filepath) + const mime = await Filesystem.mimeType(filepath) const filename = path.basename(filepath) // Handle SVG as raw text content, not as base64 image if (mime === "image/svg+xml") { @@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) { /> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - - {local.model.parsed().model} - - {currentProviderLabel()} - - · - - {local.model.variant.current()} - - - + }> + {(agent) => ( + <> + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + + + + {local.model.parsed().model} + + {currentProviderLabel()} + + · + + {local.model.variant.current()} + + + + + + )} diff --git a/packages/opencode/src/cli/cmd/tui/config/cwd.ts b/packages/opencode/src/cli/cmd/tui/config/cwd.ts new file mode 100644 index 0000000000..22f342d8d3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/cwd.ts @@ -0,0 +1,5 @@ +import { Context } from "effect" + +export const CurrentWorkingDirectory = Context.Reference("CurrentWorkingDirectory", { + defaultValue: () => process.cwd(), +}) diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts similarity index 91% rename from packages/opencode/src/config/tui-migrate.ts rename to packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index ed19474be2..3ce5c4b739 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -2,13 +2,11 @@ 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 * as ConfigPaths from "./paths" import { TuiInfo, TuiOptions } from "./tui-schema" -import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" -import { Log } from "@/util" -import { Filesystem } from "@/util" import { Global } from "@/global" +import { Filesystem, Log } from "@/util" +import * as ConfigPaths from "@/config/paths" const log = Log.create({ service: "tui.migrate" }) @@ -26,9 +24,9 @@ const TuiLegacy = z .strip() interface MigrateInput { + cwd: string directories: string[] custom?: string - managed: string } /** @@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) { }) } -async function opencodeFiles(input: { directories: string[]; managed: string }) { - const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree) +async function opencodeFiles(input: { directories: string[]; cwd: string }) { + const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd) const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")] for (const dir of unique(input.directories)) { files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) } if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG) - files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode")) const existing = await Promise.all( unique(files).map(async (file) => { diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts similarity index 78% rename from packages/opencode/src/config/tui-schema.ts rename to packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 3be988370d..66569efea5 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,9 +1,10 @@ import z from "zod" -import * as Config from "./config" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" const KeybindOverride = z .object( - Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< + Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< string, z.ZodOptional >, @@ -30,7 +31,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), - plugin: Config.PluginSpec.array().optional(), + plugin: ConfigPlugin.Spec.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts new file mode 100644 index 0000000000..6f2c161fb5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -0,0 +1,208 @@ +import z from "zod" +import { mergeDeep, unique } from "remeda" +import { Context, Effect, Fiber, Layer } from "effect" +import * as ConfigPaths from "@/config/paths" +import { migrateTuiConfig } from "./tui-migrate" +import { TuiInfo } from "./tui-schema" +import { Flag } from "@/flag/flag" +import { isRecord } from "@/util/record" +import { Global } from "@/global" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Npm } from "@opencode-ai/shared/npm" +import { CurrentWorkingDirectory } from "./cwd" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" +import { InstallationLocal, InstallationVersion } from "@/installation/version" +import { makeRuntime } from "@/cli/effect/runtime" +import { Filesystem, Log } from "@/util" + +export namespace TuiConfig { + const log = Log.create({ service: "tui.config" }) + + export const Info = TuiInfo + + type Acc = { + result: Info + } + + type State = { + config: Info + deps: Array> + } + + export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: ConfigPlugin.Origin[] + } + + export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect + } + + export class Service extends Context.Service()("@opencode/TuiConfig") {} + + function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { + if (Filesystem.contains(ctx.directory, file)) return "local" + // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" + return "global" + } + + 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 + delete data.tui + return { + ...tui, + ...data, + } + } + + async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { + const data = await loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.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 }) { + let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) + const directories = await ConfigPaths.directories(ctx.directory) + const custom = customPath() + await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) + // 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) + + 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) + } + } + + 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", + ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ]).join(",") + } + acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + + return { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } + } + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const directory = yield* CurrentWorkingDirectory + const npm = yield* Npm.Service + const data = yield* Effect.promise(() => loadState({ directory })) + const deps = yield* Effect.forEach( + data.dirs, + (dir) => + npm + .install(dir, { + add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + }) + .pipe(Effect.forkScoped), + { + concurrency: "unbounded", + }, + ) + + const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), + ) + return Service.of({ get, waitForDependencies }) + }).pipe(Effect.withSpan("TuiConfig.layer")), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) + } + + export async function get() { + return runPromise((svc) => svc.get()) + } + + 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 ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index b1dcdd7808..bf40f6b87d 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" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" 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/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 4c298ec113..bb73c65378 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,4 +1,5 @@ import { createStore } from "solid-js/store" +import { createSimpleContext } from "./helper" import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" @@ -6,14 +7,20 @@ import { uniqueBy } from "remeda" import path from "path" import { Global } from "@/global" import { iife } from "@/util/iife" -import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" -import { Provider } from "@/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util" +export function parseModel(model: string) { + const [providerID, ...rest] = model.split("/") + return { + providerID: providerID, + modelID: rest.join("/"), + } +} + export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = iife(() => { const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden)) - const [agentStore, setAgentStore] = createStore<{ - current: string - }>({ - current: agents()[0].name, + const [agentStore, setAgentStore] = createStore({ + current: undefined as string | undefined, }) const { theme } = useTheme() const colors = createMemo(() => [ @@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((x) => x.name === agentStore.current) ?? agents()[0] + return agents().find((x) => x.name === agentStore.current) ?? agents().at(0) }, set(name: string) { if (!agents().some((x) => x.name === name)) @@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() const fallbackModel = createMemo(() => { if (args.model) { - const { providerID, modelID } = Provider.parseModel(args.model) + const { providerID, modelID } = parseModel(args.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } if (sync.data.config.model) { - const { providerID, modelID } = Provider.parseModel(sync.data.config.model) + const { providerID, modelID } = parseModel(sync.data.config.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() return ( getFirstValidModel( - () => modelStore.model[a.name], - () => a.model, + () => a && modelStore.model[a.name], + () => a && a.model, fallbackModel, ) ?? undefined ) @@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - setModelStore("model", agent.current().name, { ...val }) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, { ...val }) }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const next = favorites[index] if (!next) return - setModelStore("model", agent.current().name, { ...next }) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, { ...next }) const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() setModelStore( @@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return } - setModelStore("model", agent.current().name, model) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, model) if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() @@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { const value = agent.current() + if (!value) return if (value.model) { if (isModelValid(value.model)) model.set({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2558f9751f..46227e28aa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,7 +29,7 @@ import { useExit } from "./exit" import { useArgs } from "./args" import { batch, createEffect, on } from "solid-js" import { Log } from "@/util" -import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" +import { emptyConsoleState, type ConsoleState } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider: Provider[] provider_default: Record provider_next: ProviderListResponse - console_state: ConsoleStateType + console_state: ConsoleState provider_auth: Record agent: Agent[] command: Command[] @@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console .get({ workspace }, { throwOnError: true }) - .then((x) => ConsoleState.parse(x.data)) + .then((x) => x.data) .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) @@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ] await Promise.all(blockingRequests) - .then(() => { + .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) const consoleStateResponse = consoleStatePromise 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 cfe59ba803..05fdd025c7 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" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts new file mode 100644 index 0000000000..734106f8a6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layer.ts @@ -0,0 +1,6 @@ +import { Layer } from "effect" +import { TuiConfig } from "./config/tui" +import { Npm } from "@opencode-ai/shared/npm" +import { Observability } from "@/effect/observability" + +export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 42988fcb1f..d2b495ca31 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" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createPluginKeybind } from "../context/plugin-keybinds" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" @@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" type RouteEntry = { key: symbol @@ -189,7 +189,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { function appApi(): TuiPluginApi["app"] { return { get version() { - return Installation.VERSION + return InstallationVersion }, } } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index da003607c4..af37ffbd76 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1,4 +1,4 @@ -import "@opentui/solid/runtime-plugin-support" +// import "@opentui/solid/runtime-plugin-support" import { type TuiDispose, type TuiPlugin, @@ -12,13 +12,10 @@ import { } from "@opencode-ai/plugin/tui" import path from "path" import { fileURLToPath } from "url" - -import { Config } from "@/config" -import { TuiConfig } from "@/config" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" import { readPackageThemes, readPluginId, @@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" +import { ConfigPlugin } from "@/config/plugin" type PluginLoad = { - options: Config.PluginOptions | undefined + options: ConfigPlugin.Options | undefined spec: string target: string retry: boolean source: PluginSource | "internal" id: string module: TuiPluginModule - origin: Config.PluginOrigin + origin: ConfigPlugin.Origin theme_root: string theme_files: string[] } @@ -77,7 +75,7 @@ type RuntimeState = { slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map - pending: Map + pending: Map } const log = Log.create({ service: "tui.plugin" }) @@ -147,7 +145,7 @@ function resolveRoot(root: string) { } function createThemeInstaller( - meta: Config.PluginOrigin, + meta: ConfigPlugin.Origin, root: string, spec: string, plugin: PluginEntry, @@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I } } -async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise) { +async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise) { return PluginLoader.loadExternal({ items: list, kind: "tui", @@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] return { plugins, ok } } -function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin { +function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin { return { spec, scope: "local", @@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { if (!spec) return false const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec) - const next = Config.pluginSpecifier(cfg.spec) + const next = ConfigPlugin.pluginSpecifier(cfg.spec) if (state.plugins.some((plugin) => plugin.load.spec === next)) { state.pending.delete(spec) return true } - - const ready = await Instance.provide({ - directory: state.directory, - fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), - }).catch((error) => { - fail("failed to add tui plugin", { path: next, error }) - return [] as PluginLoad[] - }) + const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()) if (!ready.length) { return false } @@ -905,7 +896,7 @@ async function installPluginBySpec( const tui = manifest.targets.find((item) => item.kind === "tui") if (tui) { const file = patch.items.find((item) => item.kind === "tui")?.file - const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec + const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec state.pending.set(spec, { spec: next, scope: global ? "global" : "local", @@ -926,7 +917,7 @@ export namespace TuiPluginRuntime { let runtime: RuntimeState | undefined export const Slot = View - export async function init(api: HostPluginApi) { + export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -936,7 +927,7 @@ export namespace TuiPluginRuntime { } dir = cwd - loaded = load(api) + loaded = load(input) return loaded } @@ -975,7 +966,8 @@ export namespace TuiPluginRuntime { } } - async function load(api: Api) { + async function load(input: { api: Api; config: TuiConfig.Info }) { + const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) const next: RuntimeState = { @@ -987,45 +979,40 @@ export namespace TuiPluginRuntime { pending: new Map(), } runtime = next + try { + const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) + } - await Instance.provide({ - directory: cwd, - fn: async () => { - const config = await TuiConfig.get() - const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { - log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) - } + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { id: item.id }) + const entry = loadInternalPlugin(item) + const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: true, + }) + } - for (const item of INTERNAL_TUI_PLUGINS) { - log.info("loading internal tui plugin", { id: item.id }) - const entry = loadInternalPlugin(item) - const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - addPluginEntry(next, { - id: entry.id, - load: entry, - meta, - themes: {}, - plugin: entry.module.tui, - enabled: true, - }) - } + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + await addExternalPluginEntries(next, ready) - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) - await addExternalPluginEntries(next, ready) - - applyInitialPluginEnabledState(next, config) - for (const plugin of next.plugins) { - if (!plugin.enabled) continue - // Keep plugin execution sequential for deterministic side effects: - // command registration order affects keybind/command precedence, - // route registration is last-wins when ids collide, - // and hook chains rely on stable plugin ordering. - await activatePluginEntry(next, plugin, false) - } - }, - }).catch((error) => { + applyInitialPluginEnabledState(next, config) + for (const plugin of next.plugins) { + if (!plugin.enabled) continue + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + await activatePluginEntry(next, plugin, false) + } + } catch (error) { fail("failed to load tui plugins", { directory: cwd, error }) - }) + } } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 3c87cfe472..06bc270644 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" @@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Code {" "} - {Installation.VERSION} + {InstallationVersion} diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 89b32d166e..96ceb905c5 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -8,14 +8,13 @@ import { UI } from "@/cli/ui" import { Log } from "@/util" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" -import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" 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" -import { Instance } from "@/project/instance" import { writeHeapSnapshot } from "v8" +import { TuiConfig } from "./config/tui" declare global { const OPENCODE_WORKER_PATH: string @@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({ } const prompt = await input(args.prompt) - const config = await Instance.provide({ - directory: cwd, - fn: () => TuiConfig.get(), - }) + const config = await TuiConfig.get() - const network = await resolveNetworkOptions(args) + const network = resolveNetworkOptionsNoConfig(args) const external = process.argv.includes("--port") || process.argv.includes("--hostname") || @@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({ process.exit(0) }, }) +// scratch diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb..f534d90b77 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "../component/border" import { TextAttributes } from "@opentui/core" import z from "zod" -import { TuiEvent } from "../event" +import { type TuiEvent } from "../event" export type ToastOptions = z.infer @@ -56,8 +56,7 @@ function init() { const toast = { show(options: ToastOptions) { - const parsedOptions = TuiEvent.ToastShow.properties.parse(options) - const { duration, ...currentToast } = parsedOptions + const { duration, ...currentToast } = options setStore("currentToast", currentToast) if (timeoutHandle) clearTimeout(timeoutHandle) timeoutHandle = setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 6968b07eb4..8c535833c6 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,12 +1,21 @@ import { platform, release } from "os" -import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../../../util" -import { Process } from "../../../../util" -import { which } from "../../../../util/which" +import * as Filesystem from "../../../../util/filesystem" +import * as Process from "../../../../util/process" + +// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup +const getWhich = lazy(async () => { + const { which } = await import("../../../../util/which") + return which +}) + +const getClipboardy = lazy(async () => { + const { default: clipboardy } = await import("clipboardy") + return clipboardy +}) /** * Writes text to clipboard via OSC 52 escape sequence. @@ -94,14 +103,16 @@ export async function read(): Promise { } } + const clipboardy = await getClipboardy() const text = await clipboardy.read().catch(() => {}) if (text) { return { data: text, mime: "text/plain" } } } -const getCopyMethod = lazy(() => { +const getCopyMethod = lazy(async () => { const os = platform() + const which = await getWhich() if (os === "darwin" && which("osascript")) { console.log("clipboard: using osascript") @@ -180,11 +191,13 @@ const getCopyMethod = lazy(() => { console.log("clipboard: no native support") return async (text: string) => { + const clipboardy = await getClipboardy() await clipboardy.write(text).catch(() => {}) } }) export async function copy(text: string): Promise { writeOsc52(text) - await getCopyMethod()(text) + const method = await getCopyMethod() + await method(text) } diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index d27bdb90ce..30d0069639 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" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" export class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 3ffa0f228c..b80648c24f 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -3,6 +3,7 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "../../installation" +import { InstallationVersion } from "../../installation/version" export const UpgradeCommand = { command: "upgrade [target]", @@ -47,13 +48,13 @@ export const UpgradeCommand = { ? args.target.replace(/^v/, "") : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest())) - if (Installation.VERSION === target) { + if (InstallationVersion === target) { prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.outro("Done") return } - prompts.log.info(`From ${Installation.VERSION} → ${target}`) + prompts.log.info(`From ${InstallationVersion} → ${target}`) const spinner = prompts.spinner() spinner.start("Upgrading...") const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/cli/effect/runtime.ts new file mode 100644 index 0000000000..4d85fa55b6 --- /dev/null +++ b/packages/opencode/src/cli/effect/runtime.ts @@ -0,0 +1,20 @@ +import { Observability } from "@/effect/observability" +import { Layer, type Context, ManagedRuntime, type Effect } from "effect" + +export const memoMap = Layer.makeMemoMapUnsafe() + +export function makeRuntime(service: Context.Service, layer: Layer.Layer) { + let rt: ManagedRuntime.ManagedRuntime | undefined + const getRuntime = () => + (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer, { memoMap })) + + return { + runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(service.use(fn)), + runPromiseExit: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromiseExit(service.use(fn), options), + runPromise: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromise(service.use(fn), options), + runFork: (fn: (svc: S) => Effect.Effect) => getRuntime().runFork(service.use(fn)), + runCallback: (fn: (svc: S) => Effect.Effect) => getRuntime().runCallback(service.use(fn)), + } +} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 735f1a721e..89b557e2d2 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,48 +1,80 @@ -import { AccountServiceError, AccountTransportError } from "@/account" -import { ConfigMarkdown } from "@/config" +import { NamedError } from "@opencode-ai/shared/util/error" import { errorFormat } from "@/util/error" -import { Config } from "../config" -import { MCP } from "../mcp" -import { Provider } from "../provider" -import { UI } from "./ui" + +interface ErrorLike { + name?: string + _tag?: string + message?: string + data?: Record +} + +function isTaggedError(error: unknown, tag: string): boolean { + return ( + typeof error === "object" && error !== null && "_tag" in error && (error as Record)._tag === tag + ) +} export function FormatError(input: unknown) { - if (MCP.Failed.isInstance(input)) - return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` - if (input instanceof AccountTransportError || input instanceof AccountServiceError) { - return input.message + // MCPFailed: { name: string } + if (NamedError.hasName(input, "MCPFailed")) { + return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` } - if (Provider.ModelNotFoundError.isInstance(input)) { - const { providerID, modelID, suggestions } = input.data + + // AccountServiceError, AccountTransportError: TaggedErrorClass + if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { + return (input as ErrorLike).message ?? "" + } + + // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } + if (NamedError.hasName(input, "ProviderModelNotFoundError")) { + const data = (input as ErrorLike).data + const suggestions = data?.suggestions as string[] | undefined return [ - `Model not found: ${providerID}/${modelID}`, + `Model not found: ${data?.providerID}/${data?.modelID}`, ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), `Try: \`opencode models\` to list available models`, `Or check your config (opencode.json) provider/model names`, ].join("\n") } - if (Provider.InitError.isInstance(input)) { - return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` - } - if (Config.JsonError.isInstance(input)) { - return ( - `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") - ) - } - if (Config.ConfigDirectoryTypoError.isInstance(input)) { - return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` - } - if (ConfigMarkdown.FrontmatterError.isInstance(input)) { - return input.data.message - } - if (Config.InvalidError.isInstance(input)) - return [ - `Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` + - (input.data.message ? `: ${input.data.message}` : ""), - ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), - ].join("\n") - if (UI.CancelledError.isInstance(input)) return "" + // ProviderInitError: { providerID: string } + if (NamedError.hasName(input, "ProviderInitError")) { + return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.` + } + + // ConfigJsonError: { path: string, message?: string } + if (NamedError.hasName(input, "ConfigJsonError")) { + const data = (input as ErrorLike).data + return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "") + } + + // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } + if (NamedError.hasName(input, "ConfigDirectoryTypoError")) { + const data = (input as ErrorLike).data + return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.` + } + + // ConfigFrontmatterError: { message: string } + if (NamedError.hasName(input, "ConfigFrontmatterError")) { + return (input as ErrorLike).data?.message ?? "" + } + + // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } + if (NamedError.hasName(input, "ConfigInvalidError")) { + const data = (input as ErrorLike).data + const path = data?.path + const message = data?.message + const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined + return [ + `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), + ...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), + ].join("\n") + } + + // UICancelledError: void (no data) + if (NamedError.hasName(input, "UICancelledError")) { + return "" + } } export function FormatUnknownError(input: unknown): string { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index ea281aafb9..a489ea14c5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } - export async function resolveNetworkOptions(args: NetworkOptions) { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) + return resolveNetworkOptionsNoConfig(args, config) +} + +export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) { const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2628f9673f..7c6f08874b 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -3,6 +3,7 @@ import { Config } from "@/config" import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@/flag/flag" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) @@ -15,10 +16,10 @@ export async function upgrade() { return } - if (Installation.VERSION === latest) return + if (InstallationVersion === latest) return if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return - const kind = Installation.getReleaseType(Installation.VERSION, latest) + const kind = Installation.getReleaseType(InstallationVersion, latest) if (config.autoupdate === "notify" || kind !== "patch") { await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 66471e908a..ecdf20c892 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -21,6 +21,7 @@ import { import { Instance, type InstanceContext } from "../project/instance" import * as LSPServer from "../lsp/server" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import * as ConfigMarkdown from "./markdown" import { existsSync } from "fs" import { Bus } from "@/bus" @@ -1266,7 +1267,7 @@ export const layer: Layer.Layer< const pkg = path.join(dir, "package.json") const gitignore = path.join(dir, ".gitignore") const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") - const target = Installation.isLocal() ? "*" : Installation.VERSION + const target = Installation.isLocal() ? "*" : InstallationVersion const json = yield* fs.readJson(pkg).pipe( Effect.catch(() => Effect.succeed({} satisfies Package)), Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index d878fc99a2..fbcca1aa9a 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -1,4 +1,3 @@ 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/keybinds.ts b/packages/opencode/src/config/keybinds.ts new file mode 100644 index 0000000000..9b8d9e2834 --- /dev/null +++ b/packages/opencode/src/config/keybinds.ts @@ -0,0 +1,164 @@ +import z from "zod" + +export namespace ConfigKeybinds { + export const Keybinds = z + .object({ + leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), + editor_open: z.string().optional().default("e").describe("Open external editor"), + theme_list: z.string().optional().default("t").describe("List available themes"), + sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), + scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), + username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + status_view: z.string().optional().default("s").describe("View status"), + session_export: z.string().optional().default("x").describe("Export session to editor"), + session_new: z.string().optional().default("n").describe("Create a new session"), + session_list: z.string().optional().default("l").describe("List all sessions"), + session_timeline: z.string().optional().default("g").describe("Show session timeline"), + session_fork: z.string().optional().default("none").describe("Fork session from message"), + session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), + session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), + stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), + model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), + model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_share: z.string().optional().default("none").describe("Share current session"), + session_unshare: z.string().optional().default("none").describe("Unshare current session"), + session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), + session_compact: z.string().optional().default("c").describe("Compact the session"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), + messages_half_page_down: z + .string() + .optional() + .default("ctrl+alt+d") + .describe("Scroll messages down by half page"), + messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), + messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), + messages_next: z.string().optional().default("none").describe("Navigate to next message"), + messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), + messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), + messages_copy: z.string().optional().default("y").describe("Copy message"), + messages_undo: z.string().optional().default("u").describe("Undo message"), + messages_redo: z.string().optional().default("r").describe("Redo message"), + messages_toggle_conceal: z + .string() + .optional() + .default("h") + .describe("Toggle code block concealment in messages"), + tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), + model_list: z.string().optional().default("m").describe("List available models"), + model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), + model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), + command_list: z.string().optional().default("ctrl+p").describe("List available commands"), + agent_list: z.string().optional().default("a").describe("List agents"), + agent_cycle: z.string().optional().default("tab").describe("Next agent"), + agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), + variant_list: z.string().optional().default("none").describe("List model variants"), + input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), + input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_submit: z.string().optional().default("return").describe("Submit input"), + input_newline: z + .string() + .optional() + .default("shift+return,ctrl+return,alt+return,ctrl+j") + .describe("Insert newline in input"), + input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), + input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), + input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), + input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), + input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), + input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), + input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), + input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), + input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), + input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), + input_select_line_home: z + .string() + .optional() + .default("ctrl+shift+a") + .describe("Select to start of line in input"), + input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), + input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), + input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), + input_select_visual_line_home: z + .string() + .optional() + .default("alt+shift+a") + .describe("Select to start of visual line in input"), + input_select_visual_line_end: z + .string() + .optional() + .default("alt+shift+e") + .describe("Select to end of visual line in input"), + input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), + input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), + input_select_buffer_home: z + .string() + .optional() + .default("shift+home") + .describe("Select to start of buffer in input"), + input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), + input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), + input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), + input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), + input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), + input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), + input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), + input_word_forward: z + .string() + .optional() + .default("alt+f,alt+right,ctrl+right") + .describe("Move word forward in input"), + input_word_backward: z + .string() + .optional() + .default("alt+b,alt+left,ctrl+left") + .describe("Move word backward in input"), + input_select_word_forward: z + .string() + .optional() + .default("alt+shift+f,alt+shift+right") + .describe("Select word forward in input"), + input_select_word_backward: z + .string() + .optional() + .default("alt+shift+b,alt+shift+left") + .describe("Select word backward in input"), + input_delete_word_forward: z + .string() + .optional() + .default("alt+d,alt+delete,ctrl+delete") + .describe("Delete word forward in input"), + input_delete_word_backward: z + .string() + .optional() + .default("ctrl+w,ctrl+backspace,alt+backspace") + .describe("Delete word backward in input"), + history_previous: z.string().optional().default("up").describe("Previous history item"), + history_next: z.string().optional().default("down").describe("Next history item"), + session_child_first: z.string().optional().default("down").describe("Go to first child session"), + session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), + session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), + session_parent: z.string().optional().default("up").describe("Go to parent session"), + terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), + tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), + plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), + display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), + }) + .strict() + .meta({ + ref: "KeybindsConfig", + }) +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 82dde2df9f..eeb9d62d3f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -7,11 +7,11 @@ import { Filesystem } from "@/util" import { Flag } from "@/flag/flag" import { Global } from "@/global" -export async function projectFiles(name: string, directory: string, worktree: string) { +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) { +export async function directories(directory: string, worktree?: string) { return [ Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts new file mode 100644 index 0000000000..d13a9d5adc --- /dev/null +++ b/packages/opencode/src/config/plugin.ts @@ -0,0 +1,75 @@ +import { Glob } from "@opencode-ai/shared/util/glob" +import z from "zod" +import { pathToFileURL } from "url" +import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import path from "path" + +export namespace ConfigPlugin { + const Options = z.record(z.string(), z.unknown()) + export type Options = z.infer + + export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) + export type Spec = z.infer + + export type Scope = "global" | "local" + + export type Origin = { + spec: Spec + source: string + scope: Scope + } + + export async function load(dir: string) { + const plugins: ConfigPlugin.Spec[] = [] + + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) + } + return plugins + } + + export function pluginSpecifier(plugin: ConfigPlugin.Spec): string { + return Array.isArray(plugin) ? plugin[0] : plugin + } + + export function pluginOptions(plugin: Spec): Options | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined + } + + export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } + + export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { + const seen = new Set() + const list: Origin[] = [] + + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) + } + + return list.toReversed() + } +} diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts deleted file mode 100644 index 3cde908b03..0000000000 --- a/packages/opencode/src/config/tui.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { existsSync } from "fs" -import z from "zod" -import { mergeDeep, unique } from "remeda" -import { Context, Effect, Fiber, Layer } from "effect" -import * as Config from "./config" -import * as ConfigPaths from "./paths" -import { migrateTuiConfig } from "./tui-migrate" -import { TuiInfo } from "./tui-schema" -import { Flag } from "@/flag/flag" -import { Log } from "@/util" -import { isRecord } from "@/util/record" -import { Global } from "@/global" -import { InstanceState } from "@/effect" -import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" - -const log = Log.create({ service: "tui.config" }) - -export const Info = TuiInfo - -type Acc = { - result: Info -} - -type State = { - config: Info - deps: Array> -} - -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 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 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 - 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/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index aabafc5b4d..f06c41e319 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,8 +47,10 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" +import { Npm } from "@opencode-ai/shared/npm" export const AppLayer = Layer.mergeAll( + Npm.defaultLayer, AppFileSystem.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index 2f4040113d..efd16ffc09 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" import * as EffectLogger from "./logger" import { Flag } from "@/flag/flag" -import { CHANNEL, VERSION } from "@/installation/meta" +import { InstallationChannel, InstallationVersion } from "@/installation/version" const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT export const enabled = !!base @@ -21,9 +21,9 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS const resource = { serviceName: "opencode", - serviceVersion: VERSION, + serviceVersion: InstallationVersion, attributes: { - "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, + "deployment.environment.name": InstallationChannel, "opencode.client": Flag.OPENCODE_CLIENT, }, } @@ -76,3 +76,5 @@ export const layer = !base return Layer.mergeAll(trace, logs) }), ) + +export const Observability = { enabled, layer } diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts index 2269065913..ee8df2b0b9 100644 --- a/packages/opencode/src/file/file.ts +++ b/packages/opencode/src/file/file.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" @@ -345,6 +345,7 @@ export const layer = Layer.effect( const appFs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service const git = yield* Git.Service + const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -419,7 +420,7 @@ export const layer = Layer.effect( }) const init = Effect.fn("File.init")(function* () { - yield* ensure() + yield* ensure().pipe(Effect.forkIn(scope)) }) const status = Effect.fn("File.status")(function* () { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d9f4651fbf..67de87c2aa 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -11,6 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" +import { InstallationVersion } from "./installation/version" import { NamedError } from "@opencode-ai/shared/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" @@ -68,7 +69,7 @@ const cli = yargs(args) .wrap(100) .help("help", "show help") .alias("help", "h") - .version("version", "show version number", Installation.VERSION) + .version("version", "show version number", InstallationVersion) .alias("version", "v") .option("print-logs", { describe: "print logs to stderr", @@ -105,7 +106,7 @@ const cli = yargs(args) process.env.OPENCODE_PID = String(process.pid) Log.Default.info("opencode", { - version: Installation.VERSION, + version: InstallationVersion, args: process.argv.slice(2), }) diff --git a/packages/opencode/src/installation/installation.ts b/packages/opencode/src/installation/installation.ts index dcaa0cd723..96a99b77a3 100644 --- a/packages/opencode/src/installation/installation.ts +++ b/packages/opencode/src/installation/installation.ts @@ -8,9 +8,9 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" import { Log } from "../util" -import { CHANNEL as channel, VERSION as version } from "./meta" import semver from "semver" +import { InstallationChannel, InstallationVersion } from "./version" const log = Log.create({ service: "installation" }) @@ -54,16 +54,14 @@ export const Info = z }) export type Info = z.infer -export const VERSION = version -export const CHANNEL = channel -export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` +export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` export function isPreview() { - return CHANNEL !== "latest" + return InstallationChannel !== "latest" } export function isLocal() { - return CHANNEL === "local" + return InstallationChannel === "local" } export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { @@ -222,7 +220,7 @@ export const layer: Layer.Layer Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) + const client = new Client({ name: "opencode", version: InstallationVersion }) return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), @@ -763,7 +764,7 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) + const client = new Client({ name: "opencode", version: InstallationVersion }) return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) diff --git a/packages/opencode/src/npm/npm.ts b/packages/opencode/src/npm/npm.ts index 7f17446057..d74c10d555 100644 --- a/packages/opencode/src/npm/npm.ts +++ b/packages/opencode/src/npm/npm.ts @@ -7,7 +7,6 @@ import path from "path" import { readdir, rm } from "fs/promises" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" -import { Arborist } from "@npmcli/arborist" const log = Log.create({ service: "npm" }) const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined @@ -61,6 +60,7 @@ export async function outdated(pkg: string, cachedVersion: string): Promise { + const { Arborist } = await import("@npmcli/arborist") const arb = new Arborist({ path: dir, binLinks: true, diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index e0f1afa63f..c61cb78509 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util" import { Installation } from "../installation" +import { InstallationVersion } from "../installation/version" import { OAUTH_DUMMY_KEY } from "../auth" import os from "os" import { setTimeout as sleep } from "node:timers/promises" @@ -510,7 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { method: "POST", headers: { "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID }), }) @@ -534,7 +535,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { method: "POST", headers: { "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ device_auth_id: deviceData.device_auth_id, @@ -594,7 +595,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "chat.headers": async (input, output) => { if (input.model.providerID !== "openai") return output.headers.originator = "opencode" - output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})` + output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})` output.headers.session_id = input.sessionID }, "chat.params": async (input, output) => { diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c1318287c5..c9b7e3c1c7 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Model } from "@opencode-ai/sdk/v2" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { iife } from "@/util/iife" import { Log } from "../../util" import { setTimeout as sleep } from "node:timers/promises" @@ -70,7 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { base(auth.enterpriseUrl), { Authorization: `Bearer ${auth.refresh}`, - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, provider.models, ).catch((error) => { @@ -150,7 +151,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const headers: Record = { "x-initiator": isAgent ? "agent" : "user", ...(init?.headers as Record), - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, Authorization: `Bearer ${info.refresh}`, "Openai-Intent": "conversation-edits", } @@ -226,7 +227,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { headers: { Accept: "application/json", "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID, @@ -256,7 +257,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { headers: { Accept: "application/json", "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID, diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 8b7e30c40e..0525a7ba0b 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" +import * as ConfigPaths from "@/config/paths" import { Global } from "@/global" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 12617f9010..0245d311e0 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -1,5 +1,3 @@ -import { Config } from "@/config" -import { Installation } from "@/installation" import { checkPluginCompatibility, createPluginEntry, @@ -10,11 +8,13 @@ import { type PluginPackage, type PluginSource, } from "./shared" +import { ConfigPlugin } from "@/config/plugin" +import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { export type Plan = { spec: string - options: Config.PluginOptions | undefined + options: ConfigPlugin.Options | undefined deprecated: boolean } export type Resolved = Plan & { @@ -33,7 +33,7 @@ export namespace PluginLoader { mod: Record } - type Candidate = { origin: Config.PluginOrigin; plan: Plan } + type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Report = { start?: (candidate: Candidate, retry: boolean) => void missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void @@ -46,9 +46,9 @@ export namespace PluginLoader { ) => void } - function plan(item: Config.PluginSpec): Plan { - const spec = Config.pluginSpecifier(item) - return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } + function plan(item: ConfigPlugin.Spec): Plan { + const spec = ConfigPlugin.pluginSpecifier(item) + return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } export async function resolve( @@ -88,7 +88,7 @@ export namespace PluginLoader { if (base.source === "npm") { try { - await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) + await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) } catch (error) { return { ok: false, stage: "compatibility", error } } @@ -111,8 +111,8 @@ export namespace PluginLoader { candidate: Candidate, kind: PluginKind, retry: boolean, - finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, - missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, + finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, report: Report | undefined, ): Promise { const plan = candidate.plan @@ -141,11 +141,11 @@ export namespace PluginLoader { } type Input = { - items: Config.PluginOrigin[] + items: ConfigPlugin.Origin[] kind: PluginKind wait?: () => Promise - finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise - missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise + finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise + missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise report?: Report } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a405607bea..e506d2feda 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -26,7 +26,7 @@ export const InstanceBootstrap = Effect.gen(function* () { Vcs.Service, Snapshot.Service, ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ) + ).pipe(Effect.withSpan("InstanceBootstrap.init")) yield* Bus.Service.use((svc) => svc.subscribeCallback(Command.Event.Executed, async (payload) => { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 77a45cb1be..43ae9a5e9f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -14,6 +14,7 @@ import * as ModelsDev from "./models" import { Auth } from "../auth" import { Env } from "../env" import { Instance } from "../project/instance" +import { InstallationVersion } from "../installation/version" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { Global } from "../global" @@ -24,39 +25,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" -// Direct imports for bundled providers -import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" -import { createAnthropic } from "@ai-sdk/anthropic" -import { createAzure } from "@ai-sdk/azure" -import { createGoogleGenerativeAI } from "@ai-sdk/google" -import { createVertex } from "@ai-sdk/google-vertex" -import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" -import { createOpenAI } from "@ai-sdk/openai" -import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot" -import { createXai } from "@ai-sdk/xai" -import { createMistral } from "@ai-sdk/mistral" -import { createGroq } from "@ai-sdk/groq" -import { createDeepInfra } from "@ai-sdk/deepinfra" -import { createCerebras } from "@ai-sdk/cerebras" -import { createCohere } from "@ai-sdk/cohere" -import { createGateway } from "@ai-sdk/gateway" -import { createTogetherAI } from "@ai-sdk/togetherai" -import { createPerplexity } from "@ai-sdk/perplexity" -import { createVercel } from "@ai-sdk/vercel" -import { createVenice } from "venice-ai-sdk-provider" -import { createAlibaba } from "@ai-sdk/alibaba" -import { - createGitLab, - VERSION as GITLAB_PROVIDER_VERSION, - isWorkflowModel, - discoverWorkflowModels, -} from "gitlab-ai-provider" -import { fromNodeProviderChain } from "@aws-sdk/credential-providers" -import { GoogleAuth } from "google-auth-library" import * as ProviderTransform from "./transform" -import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" const log = Log.create({ service: "provider" }) @@ -119,30 +88,31 @@ type BundledSDK = { languageModel(modelId: string): LanguageModelV3 } -const BUNDLED_PROVIDERS: Record BundledSDK> = { - "@ai-sdk/amazon-bedrock": createAmazonBedrock, - "@ai-sdk/anthropic": createAnthropic, - "@ai-sdk/azure": createAzure, - "@ai-sdk/google": createGoogleGenerativeAI, - "@ai-sdk/google-vertex": createVertex, - "@ai-sdk/google-vertex/anthropic": createVertexAnthropic, - "@ai-sdk/openai": createOpenAI, - "@ai-sdk/openai-compatible": createOpenAICompatible, - "@openrouter/ai-sdk-provider": createOpenRouter, - "@ai-sdk/xai": createXai, - "@ai-sdk/mistral": createMistral, - "@ai-sdk/groq": createGroq, - "@ai-sdk/deepinfra": createDeepInfra, - "@ai-sdk/cerebras": createCerebras, - "@ai-sdk/cohere": createCohere, - "@ai-sdk/gateway": createGateway, - "@ai-sdk/togetherai": createTogetherAI, - "@ai-sdk/perplexity": createPerplexity, - "@ai-sdk/vercel": createVercel, - "@ai-sdk/alibaba": createAlibaba, - "gitlab-ai-provider": createGitLab, - "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, - "venice-ai-sdk-provider": createVenice, +const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK>> = { + "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock), + "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic), + "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure), + "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI), + "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex), + "@ai-sdk/google-vertex/anthropic": () => + import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic), + "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI), + "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible), + "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter), + "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai), + "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral), + "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq), + "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra), + "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras), + "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere), + "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway), + "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI), + "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity), + "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), + "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), + "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), + "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible), + "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise @@ -307,7 +277,9 @@ function custom(dep: CustomDep): Record { if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) return { autoload: false } - const providerOptions: AmazonBedrockProviderSettings = { + const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) + + const providerOptions: Record = { region: defaultRegion, } @@ -465,6 +437,7 @@ function custom(dep: CustomDep): Record { project, location, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const { GoogleAuth } = await import("google-auth-library") const auth = new GoogleAuth() const client = await auth.getApplicationDefault() const token = await client.credential.getAccessToken() @@ -534,6 +507,12 @@ function custom(dep: CustomDep): Record { }, }), gitlab: Effect.fnUntraced(function* (input: Info) { + const { + VERSION: GITLAB_PROVIDER_VERSION, + isWorkflowModel, + discoverWorkflowModels, + } = yield* Effect.promise(() => import("gitlab-ai-provider")) + const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" const auth = yield* dep.auth(input.id) @@ -547,7 +526,7 @@ function custom(dep: CustomDep): Record { const providerConfig = (yield* dep.config()).provider?.["gitlab"] const aiGatewayHeaders = { - "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "anthropic-beta": "context-1m-2025-08-07", ...providerConfig?.options?.aiGatewayHeaders, } @@ -566,7 +545,7 @@ function custom(dep: CustomDep): Record { aiGatewayHeaders, featureFlags, }, - async getModel(sdk: ReturnType, modelID: string, options?: Record) { + async getModel(sdk: any, modelID: string, options?: Record) { if (modelID.startsWith("duo-workflow-")) { const workflowRef = options?.workflowRef as string | undefined // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef @@ -701,7 +680,7 @@ function custom(dep: CustomDep): Record { options: { apiKey, headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, }, }, async getModel(sdk: any, modelID: string) { @@ -772,7 +751,7 @@ function custom(dep: CustomDep): Record { skipCache: input.options?.skipCache, collectLog: input.options?.collectLog, headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, }, } @@ -1454,13 +1433,14 @@ const layer: Layer.Layer< return wrapSSE(res, chunkTimeout, chunkAbortCtl) } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { + const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] + if (bundledLoader) { log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm, }) - const loaded = bundledFn({ + const factory = await bundledLoader() + const loaded = factory({ name: model.providerID, ...options, }) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index ac73bb64d8..8208cf9669 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -10,6 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Instance } from "../../project/instance" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { Log } from "../../util" import { lazy } from "../../util/lazy" import { Config } from "../../config" @@ -89,7 +90,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - return c.json({ healthy: true, version: Installation.VERSION }) + return c.json({ healthy: true, version: InstallationVersion }) }, ) .get( diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 2d1577e7e3..d38c29765a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,6 +20,7 @@ import { Wildcard } from "@/util" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -365,7 +366,7 @@ export namespace LLM { : { "x-session-affinity": input.sessionID, ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }), ...input.model.headers, ...headers, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9ebddf8dee..e288aec73a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -7,6 +7,7 @@ import z from "zod" import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "../flag/flag" import { Installation } from "../installation" +import { InstallationVersion } from "../installation/version" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage" import { SyncEvent } from "../sync" @@ -399,7 +400,7 @@ export const layer: Layer.Layer = const result: Info = { id: SessionID.descending(input.id), slug: Slug.create(), - version: Installation.VERSION, + version: InstallationVersion, projectID: ctx.project.id, directory: input.directory, workspaceID: input.workspaceID, diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1b6b2d9b37..2c0076452e 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -11,7 +11,7 @@ import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "../flag/flag" -import { CHANNEL } from "../installation/meta" +import { InstallationChannel } from "../installation/version" import { InstanceState } from "@/effect" import { iife } from "@/util/iife" import { init } from "#db" @@ -28,9 +28,9 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export function getChannelPath() { - if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") - const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") + const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) } diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts new file mode 100644 index 0000000000..bbb97e0f0f --- /dev/null +++ b/packages/opencode/src/temporary.ts @@ -0,0 +1,33 @@ +import yargs from "yargs" +import { TuiThreadCommand } from "./cli/cmd/tui/thread" +import { InstallationVersion } from "./installation/version" +import { hideBin } from "yargs/helpers" +import { Log } from "./node" + +Log.init({ + print: false, +}) + +const cli = yargs(hideBin(process.argv)) + .parserConfiguration({ "populate--": true }) + .scriptName("opencode") + .wrap(100) + .help("help", "show help") + .alias("help", "h") + .version("version", "show version number", InstallationVersion) + .alias("version", "v") + .option("print-logs", { + describe: "print logs to stderr", + type: "boolean", + }) + .option("log-level", { + describe: "log level", + type: "string", + choices: ["DEBUG", "INFO", "WARN", "ERROR"], + }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) + .command(TuiThreadCommand) + .parse() diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index c3f59d3297..3ff2c6e3f4 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,6 +1,5 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" -import { lookup } from "mime-types" import { realpathSync } from "fs" import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" @@ -101,7 +100,8 @@ export async function writeStream( } } -export function mimeType(p: string): string { +export async function mimeType(p: string): Promise { + const { lookup } = await import("mime-types") return lookup(p) || "application/octet-stream" } diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 11865beddd..972da0f50f 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" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,15 +31,18 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ + api: createTuiPluginApi(), + config, + }) await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") @@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -72,10 +74,10 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { await Bun.write( path.join(tmp.extra.mod, "index.ts"), @@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ + api: createTuiPluginApi(), + config, + }) await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") @@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index bd490ac4f9..ca7e8fcd21 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" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,11 +50,10 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const cfg: Awaited> = { + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, } - const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ @@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => { }) try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec) expect(out).toMatchObject({ ok: true, @@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index 078e4484db..8725fe8b9b 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async () }, }) - const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]]) + const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.dispose() const marker = await fs.readFile(tmp.extra.marker, "utf8") @@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => { }, }) - const restore = mockTuiRuntime(tmp.path, [ + const { config, restore } = mockTuiRuntime(tmp.path, [ [tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }], [tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }], ]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // bad plugin's onDispose ran during rollback await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned") // good plugin still loaded @@ -155,11 +155,11 @@ export default { }, }) - const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec]) + const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec]) const err = spyOn(console, "error").mockImplementation(() => {}) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const marker = await fs.readFile(tmp.extra.marker, "utf8") expect(marker).toContain("one") @@ -202,10 +202,10 @@ test( }, }) - const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec]) + const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const done = await new Promise((resolve) => { const timer = setTimeout(() => resolve("timeout"), 7000) 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 7020ac7426..395e8ce429 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" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export") expect(hit?.enabled).toBe(true) @@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -106,7 +105,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -169,7 +167,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () = source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // plugin code never ran await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() // plugin not listed @@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () = await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -232,7 +229,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () => source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -291,7 +287,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => { const error = spyOn(console, "error").mockImplementation(() => {}) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) expect(error).not.toHaveBeenCalled() @@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() warn.mockRestore() error.mockRestore() @@ -357,7 +352,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -405,7 +399,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true) } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -463,7 +456,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin") } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } 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 25233adaa5..ba7a4b3959 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" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() if (pure === undefined) { delete process.env.OPENCODE_PURE diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 4dc2aeccd4..dc64fb3365 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -5,8 +5,8 @@ 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" -import { Filesystem } from "../../../src/util" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" +import { Filesystem } from "../../../src/util/" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -328,8 +328,55 @@ export default { try { expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) - await TuiPluginRuntime.init( - createTuiPluginApi({ + const localOpts = { + fn_marker: tmp.extra.fnMarker, + marker: tmp.extra.localMarker, + source: tmp.extra.localDest.replace(".opencode/themes/", ""), + dest: tmp.extra.localDest, + theme_path: `./${tmp.extra.localThemeFile}`, + theme_name: tmp.extra.localThemeName, + kv_key: "plugin_state_key", + session_id: "ses_test", + keybinds: { modal: "ctrl+alt+m", close: "q" }, + } + const invalidOpts = { + marker: tmp.extra.invalidMarker, + theme_path: `./${tmp.extra.invalidThemeFile}`, + theme_name: tmp.extra.invalidThemeName, + } + const preloadedOpts = { + marker: tmp.extra.preloadedMarker, + dest: tmp.extra.preloadedDest, + theme_path: `./${tmp.extra.preloadedThemeFile}`, + theme_name: tmp.extra.preloadedThemeName, + } + const globalOpts = { + marker: tmp.extra.globalMarker, + theme_path: `./${tmp.extra.globalThemeFile}`, + theme_name: tmp.extra.globalThemeName, + } + + const config: TuiConfig.Info = { + plugin: [ + [tmp.extra.localSpec, localOpts], + [tmp.extra.invalidSpec, invalidOpts], + [tmp.extra.preloadedSpec, preloadedOpts], + [tmp.extra.globalSpec, globalOpts], + ], + plugin_origins: [ + { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { + spec: [tmp.extra.globalSpec, globalOpts], + scope: "global", + source: path.join(Global.Path.config, "tui.json"), + }, + ], + } + + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ tuiConfig: { theme: "smoke", diff_style: "stacked", @@ -366,7 +413,8 @@ export default { }, }, }), - ) + config, + }) const local = await row(tmp.extra.localMarker) const global = await row(tmp.extra.globalMarker) const invalid = await row(tmp.extra.invalidMarker) @@ -459,7 +507,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // bad plugin was skipped (no metadata entry) await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow() // good plugin loaded fine @@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -555,7 +602,18 @@ export default { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + const a = path.join(tmp.path, "order-a.ts") + const b = path.join(tmp.path, "order-b.ts") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + const config: TuiConfig.Info = { + plugin: [aSpec, bSpec], + plugin_origins: [ + { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + ], + } + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") expect(lines).toEqual(["a-start", "a-end", "b"]) } finally { @@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() - const api = () => + const mkApi = () => createTuiPluginApi({ theme: { has(name) { @@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => { }, }) + const mkConfig = (): TuiConfig.Info => ({ + plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], + plugin_origins: [ + { + spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + try { - await TuiPluginRuntime.init(api()) + await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) await TuiPluginRuntime.dispose() await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111") @@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => { await fs.utimes(tmp.extra.pluginPath, stamp, stamp) await fs.utimes(tmp.extra.themePath, stamp, stamp) - await TuiPluginRuntime.init(api()) + await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) const text = await fs.readFile(tmp.extra.dest, "utf8") expect(text).toContain("#222222") expect(text).not.toContain("#111111") diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 3f04e3c6fa..11fdf5ce46 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" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({ @@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -117,7 +116,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on") expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({ @@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 7b781c49e8..40f4021a23 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -8,13 +8,11 @@ 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" -import { Instance } from "../../../src/project/instance" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const stop = new Error("stop") const seen = { tui: [] as string[], - inst: [] as string[], } function setup() { @@ -42,11 +40,6 @@ function setup() { }) spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) - spyOn(TuiConfig, "get").mockResolvedValue({}) - spyOn(Instance, "provide").mockImplementation(async (input) => { - seen.inst.push(input.directory) - return input.fn() - }) } describe("tui thread", () => { @@ -86,7 +79,6 @@ describe("tui thread", () => { const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" seen.tui.length = 0 - seen.inst.length = 0 await fs.symlink(tmp.path, link, type) Object.defineProperty(process.stdin, "isTTY", { @@ -105,7 +97,6 @@ describe("tui thread", () => { process.chdir(tmp.path) process.env.PWD = link await expect(call(project)).rejects.toBe(stop) - expect(seen.inst[0]).toBe(tmp.path) expect(seen.tui[0]).toBe(tmp.path) } finally { process.chdir(cwd) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e309416f1d..92c919dc26 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" import * as Network from "../../src/util/network" import { Npm } from "../../src/npm" +import { ConfigPlugin } from "@/config/plugin" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), @@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { const cfg = await load() const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] - const names = plugins.map((item) => Config.pluginSpecifier(item)) + const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) expect(names).toContain("shared-plugin@2.0.0") expect(names).not.toContain("shared-plugin@1.0.0") @@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { expect(names).toContain("local-only@1.0.0") expect(origins.map((item) => item.spec)).toEqual(plugins) - const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") + const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") expect(hit?.scope).toBe("local") }, }) @@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() const file = path.join(tmp.path, "opencode.json") - expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3") - expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg") + expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3") + expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg") }) test("resolves windows-style relative plugin directory specs", async () => { @@ -1925,8 +1926,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec(".\\plugin", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) + const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) }) test("resolves relative file plugin paths to file urls", async () => { @@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec("./plugin.ts", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href) + const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href) }) test("resolves plugin directory paths to directory urls", async () => { @@ -1956,8 +1957,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec("./plugin", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) + const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) }) test("resolves plugin directories without package.json to index.ts", async () => { diff --git a/packages/opencode/test/config/plugin.test.ts b/packages/opencode/test/config/plugin.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 62587d2704..c7b6d4a504 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -3,13 +3,15 @@ import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "../../src/config" -import { TuiConfig } from "../../src/config" import { Global } from "../../src/global" import { Filesystem } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" +import { Effect, Layer } from "effect" +import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" +import { ConfigPlugin } from "@/config/plugin" -const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) @@ -18,6 +20,13 @@ beforeEach(async () => { await clear(true) }) +const getTuiConfig = async (directory: string) => + Effect.runPromise( + TuiConfig.Service.use((svc) => svc.get()).pipe( + Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + ), + ) + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG @@ -25,7 +34,6 @@ afterEach(async () => { await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) - await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await clear(true) }) @@ -83,9 +91,9 @@ test("keeps server and tui plugin merge semantics aligned", async () => { directory: tmp.path, fn: async () => { const server = await load() - const tui = await TuiConfig.get() - const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) - const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) + const tui = await getTuiConfig(tmp.path) + const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) + const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) expect(serverPlugins).toEqual(tuiPlugins) expect(serverPlugins).toContain("shared-plugin@2.0.0") @@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => { const serverOrigins = server.plugin_origins ?? [] const tuiOrigins = tui.plugin_origins ?? [] - expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins) - expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins) + expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins) + expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins) expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope)) }, }) @@ -113,14 +121,9 @@ test("loads tui config with the same precedence order as server config paths", a }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("local") - expect(config.diff_style).toBe("stacked") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("local") + expect(config.diff_style).toBe("stacked") }) test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { @@ -141,26 +144,21 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(5) - expect(config.keybinds?.app_exit).toBe("ctrl+q") - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - expect(JSON.parse(text)).toMatchObject({ - theme: "migrated-theme", - scroll_speed: 5, - }) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.keybinds).toBeUndefined() - expect(server.tui).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - }, + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(5) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + expect(JSON.parse(text)).toMatchObject({ + theme: "migrated-theme", + scroll_speed: 5, }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.keybinds).toBeUndefined() + expect(server.tui).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) }) test("migrates project legacy tui keys even when global tui.json already exists", async () => { @@ -181,19 +179,14 @@ test("migrates project legacy tui keys even when global tui.json already exists" }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("project-migrated") - expect(config.scroll_speed).toBe(2) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("project-migrated") + expect(config.scroll_speed).toBe(2) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.tui).toBeUndefined() - }, - }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.tui).toBeUndefined() }) test("drops unknown legacy tui keys during migration", async () => { @@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(2) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(2) - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - const migrated = JSON.parse(text) - expect(migrated.scroll_speed).toBe(2) - expect(migrated.foo).toBeUndefined() - }, - }) + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + const migrated = JSON.parse(text) + expect(migrated.scroll_speed).toBe(2) + expect(migrated.foo).toBeUndefined() }) test("skips migration when opencode.jsonc is syntactically invalid", async () => { @@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () => }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBeUndefined() - expect(config.scroll_speed).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) - const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) - expect(source).toContain('"theme": "broken-theme"') - expect(source).toContain('"tui": { "scroll_speed": 2 }') - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBeUndefined() + expect(config.scroll_speed).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) + const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(source).toContain('"theme": "broken-theme"') + expect(source).toContain('"tui": { "scroll_speed": 2 }') }) test("skips migration when tui.json already exists", async () => { @@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("stacked") - expect(config.theme).toBeUndefined() + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("stacked") + expect(config.theme).toBeUndefined() - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBe("legacy") - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) - }, - }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBe("legacy") + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) }) test("continues loading tui config when legacy source cannot be stripped", async () => { @@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async await fs.chmod(source, 0o444) try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("readonly-theme") - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("readonly-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - const server = JSON.parse(await Filesystem.readText(source)) - expect(server.theme).toBe("readonly-theme") - }, - }) + const server = JSON.parse(await Filesystem.readText(source)) + expect(server.theme).toBe("readonly-theme") } finally { await fs.chmod(source, 0o644) } @@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await TuiConfig.get() - const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) - expect(backup).toContain("// top-level comment") - expect(backup).toContain("// nested comment") - expect(backup).toContain('"theme": "jsonc-theme"') - expect(backup).toContain('"scroll_speed": 1.5') - }, - }) + await getTuiConfig(tmp.path) + const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) + expect(backup).toContain("// top-level comment") + expect(backup).toContain("// nested comment") + expect(backup).toContain('"theme": "jsonc-theme"') + expect(backup).toContain('"scroll_speed": 1.5') }) test("migrates legacy tui keys across multiple opencode.json levels", async () => { @@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () = await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2)) }, }) - - await Instance.provide({ - directory: path.join(tmp.path, "apps", "client"), - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("nested-theme") - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) - }, - }) + const config = await getTuiConfig(path.join(tmp.path, "apps", "client")) + expect(config.theme).toBe("nested-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) }) test("flattens nested tui key inside tui.json", async () => { @@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.scroll_speed).toBe(3) - expect(config.diff_style).toBe("stacked") - // top-level keys take precedence over nested tui keys - expect(config.theme).toBe("outer") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.scroll_speed).toBe(3) + expect(config.diff_style).toBe("stacked") + // top-level keys take precedence over nested tui keys + expect(config.theme).toBe("outer") }) test("top-level keys in tui.json take precedence over nested tui key", async () => { @@ -395,14 +352,9 @@ test("top-level keys in tui.json take precedence over nested tui key", async () }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("auto") - expect(config.scroll_speed).toBe(2) - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("auto") + expect(config.scroll_speed).toBe(2) }) test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => { @@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - // project tui.json overrides the custom path, same as server config precedence - expect(config.theme).toBe("project") - // project also set diff_style, so that wins - expect(config.diff_style).toBe("auto") - }, - }) + const config = await getTuiConfig(tmp.path) + // project tui.json overrides the custom path, same as server config precedence + expect(config.theme).toBe("project") + // project also set diff_style, so that wins + expect(config.diff_style).toBe("auto") }) test("merges keybind overrides across precedence layers", async () => { @@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } })) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.app_exit).toBe("ctrl+q") - expect(config.keybinds?.theme_list).toBe("ctrl+k") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds?.theme_list).toBe("ctrl+k") }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { @@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } })) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+y") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+y") }) wintest("ignores terminal suspend bindings on Windows", async () => { @@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { @@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn process.env.OPENCODE_TUI_CONFIG = custom }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("from-env") - expect(config.diff_style).toBe("stacked") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("from-env") + expect(config.diff_style).toBe("stacked") }) test("does not derive tui path from OPENCODE_CONFIG", async () => { @@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => { process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json") }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBeUndefined() - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBeUndefined() }) test("applies env and file substitutions in tui.json", async () => { @@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => { ) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("env-theme") - expect(config.keybinds?.app_exit).toBe("ctrl+q") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("env-theme") + expect(config.keybinds?.app_exit).toBe("ctrl+q") } finally { if (original === undefined) delete process.env.TUI_THEME_TEST else process.env.TUI_THEME_TEST = original @@ -575,46 +481,8 @@ test("applies file substitutions when first identical token is in a commented li ) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("resolved-theme") - }, - }) -}) - -test("loads managed tui config and gives it highest precedence", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2), - ) - await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write( - path.join(managedConfigDir, "tui.json"), - JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("managed-theme") - expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) - expect(config.plugin_origins).toEqual([ - { - spec: "shared-plugin@2.0.0", - scope: "global", - source: path.join(managedConfigDir, "tui.json"), - }, - ]) - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("resolved-theme") }) test("loads .opencode/tui.json", async () => { @@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => { await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("stacked") - }, - }) -}) - -test("gracefully falls back when tui.json has invalid JSON", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), "{ invalid json }") - await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2)) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("managed-fallback") - expect(config.keybinds).toBeDefined() - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("stacked") }) test("supports tuple plugin specs with options in tui.json", async () => { @@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), }, - }) + ]) }) test("deduplicates tuple plugin specs by name with higher precedence winning", async () => { @@ -702,28 +540,23 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual([ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@2.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - { - spec: ["second-plugin@3.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual([ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@2.0.0", { source: "project" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), }, - }) + { + spec: ["second-plugin@3.0.0", { source: "project" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ]) }) test("tracks global and local plugin metadata in merged tui config", async () => { @@ -744,25 +577,20 @@ test("tracks global and local plugin metadata in merged tui config", async () => }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) - expect(config.plugin_origins).toEqual([ - { - spec: "global-plugin@1.0.0", - scope: "global", - source: path.join(Global.Path.config, "tui.json"), - }, - { - spec: "local-plugin@2.0.0", - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) + expect(config.plugin_origins).toEqual([ + { + spec: "global-plugin@1.0.0", + scope: "global", + source: path.join(Global.Path.config, "tui.json"), }, - }) + { + spec: "local-plugin@2.0.0", + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ]) }) test("merges plugin_enabled flags across config layers", async () => { @@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin_enabled).toEqual({ - "internal:sidebar-context": false, - "demo.plugin": false, - "local.plugin": true, - }) - }, + const config = await getTuiConfig(tmp.path) + expect(config.plugin_enabled).toEqual({ + "internal:sidebar-context": false, + "demo.plugin": false, + "local.plugin": true, }) }) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 28fd2c8384..21dbc75b95 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Filesystem.mimeType(filepath)).toContain("application/json") + expect(await Filesystem.mimeType(filepath)).toContain("application/json") const result = await read("test.json") expect(result.type).toBe("text") @@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Filesystem.mimeType(filepath)).toContain(mime) + expect(await Filesystem.mimeType(filepath)).toContain(mime) }, }) } diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 493b23f7e8..ba8099fcdd 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,27 +1,31 @@ import { spyOn } from "bun:test" import path from "path" -import { TuiConfig } from "../../src/config" +import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" type PluginSpec = string | [string, Record] -export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { +export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record }) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") const plugin_origins = plugin.map((spec) => ({ spec, scope: "local" as const, source: path.join(dir, "tui.json"), })) - const get = spyOn(TuiConfig, "get").mockResolvedValue({ - plugin, - plugin_origins, - }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) - return () => { - cwd.mockRestore() - get.mockRestore() - wait.mockRestore() - delete process.env.OPENCODE_PLUGIN_META_FILE + const config: TuiConfig.Info = { + plugin, + plugin_origins, + ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), + } + + return { + config, + restore: () => { + cwd.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + }, } } diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index 7edc862c4c..6beb95ac5f 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Global } from "../../src/global" -import { Installation } from "../../src/installation" +import { InstallationChannel } from "../../src/installation/version" import { Database } from "../../src/storage" describe("Database.Path", () => { test("returns database path for the current channel", () => { - const expected = ["latest", "beta"].includes(Installation.CHANNEL) + const expected = ["latest", "beta"].includes(InstallationChannel) ? path.join(Global.Path.data, "opencode.db") - : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) + : path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) expect(Database.getChannelPath()).toBe(expected) }) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 8e1724b474..3b32c72e05 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -16,6 +16,7 @@ import { Tool } from "../../src/tool" import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Npm } from "@opencode-ai/shared/npm" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 1f3a66b950..d5f8a529bd 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -347,31 +347,31 @@ describe("filesystem", () => { }) describe("mimeType()", () => { - test("returns correct MIME type for JSON", () => { - expect(Filesystem.mimeType("test.json")).toContain("application/json") + test("returns correct MIME type for JSON", async () => { + expect(await Filesystem.mimeType("test.json")).toContain("application/json") }) - test("returns correct MIME type for JavaScript", () => { - expect(Filesystem.mimeType("test.js")).toContain("javascript") + test("returns correct MIME type for JavaScript", async () => { + expect(await Filesystem.mimeType("test.js")).toContain("javascript") }) - test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => { - const mime = Filesystem.mimeType("test.ts") + test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => { + const mime = await Filesystem.mimeType("test.ts") // .ts is ambiguous: TypeScript vs MPEG-2 TS video expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true) }) - test("returns correct MIME type for images", () => { - expect(Filesystem.mimeType("test.png")).toContain("image/png") - expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg") + test("returns correct MIME type for images", async () => { + expect(await Filesystem.mimeType("test.png")).toContain("image/png") + expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg") }) - test("returns default for unknown extension", () => { - expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream") + test("returns default for unknown extension", async () => { + expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream") }) - test("handles files without extension", () => { - expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream") + test("handles files without extension", async () => { + expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream") }) }) diff --git a/packages/opencode/time.ts b/packages/opencode/time.ts new file mode 100755 index 0000000000..c00936db26 --- /dev/null +++ b/packages/opencode/time.ts @@ -0,0 +1,4 @@ +import path from "path" +const toDynamicallyImport = path.join(process.cwd(), process.argv[2]) +await import(toDynamicallyImport) +console.log(performance.now()) diff --git a/packages/opencode/trace-imports.ts b/packages/opencode/trace-imports.ts new file mode 100755 index 0000000000..3aad338515 --- /dev/null +++ b/packages/opencode/trace-imports.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env bun +import * as path from "path" +import * as ts from "typescript" + +const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode" + +// Get entry file from command line arg or use default +const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts" + +const visited = new Set() + +function resolveImport(importPath: string, fromFile: string): string | null { + if (importPath.startsWith("@/")) { + return path.join(BASE_DIR, "src", importPath.slice(2)) + } + + if (importPath.startsWith("./") || importPath.startsWith("../")) { + const dir = path.dirname(fromFile) + return path.resolve(dir, importPath) + } + + return null +} + +function isInternalImport(importPath: string): boolean { + return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../") +} + +async function tryExtensions(filePath: string): Promise { + const extensions = [".ts", ".tsx", ".js", ".jsx"] + + try { + const file = Bun.file(filePath) + const stat = await file.stat() + + if (stat?.isDirectory()) { + for (const ext of extensions) { + const indexPath = path.join(filePath, "index" + ext) + const indexFile = Bun.file(indexPath) + if (await indexFile.exists()) return indexPath + } + return null + } + + // It's a file + return filePath + } catch { + // Path doesn't exist, try adding extensions + for (const ext of extensions) { + const withExt = filePath + ext + const extFile = Bun.file(withExt) + if (await extFile.exists()) return withExt + } + return null + } +} + +function extractImports(sourceFile: ts.SourceFile): string[] { + const imports: string[] = [] + + function visit(node: ts.Node) { + // import x from "path" or import { x } from "path" + if (ts.isImportDeclaration(node)) { + // Skip type-only imports + if (node.importClause?.isTypeOnly) return + + const moduleSpec = node.moduleSpecifier + if (ts.isStringLiteral(moduleSpec)) { + imports.push(moduleSpec.text) + } + } + + // export { x } from "path" + if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (ts.isStringLiteral(node.moduleSpecifier)) { + imports.push(node.moduleSpecifier.text) + } + } + + // Dynamic import: import("path") + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) { + imports.push(arg.text) + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return imports +} + +async function traceFile(filePath: string, depth = 0): Promise { + const normalizedPath = path.relative(BASE_DIR, filePath) + + if (visited.has(filePath)) { + return + } + + // Only trace TypeScript/JavaScript files + if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) { + return + } + + visited.add(filePath) + console.log("\t".repeat(depth) + normalizedPath) + + let content: string + try { + content = await Bun.file(filePath).text() + } catch { + return + } + + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true) + + const imports = extractImports(sourceFile) + const internalImports = imports.filter(isInternalImport) + const externalImports = imports.filter((imp) => !isInternalImport(imp)) + + // Print external imports + for (const imp of externalImports) { + console.log("\t".repeat(depth + 1) + `[ext] ${imp}`) + } + + for (const imp of internalImports) { + const resolved = resolveImport(imp, filePath) + if (!resolved) continue + + const actualPath = await tryExtensions(resolved) + if (!actualPath) continue + + await traceFile(actualPath, depth + 1) + } +} + +async function main() { + const entryPath = path.join(BASE_DIR, ENTRY_FILE) + + // Check if file exists + const file = Bun.file(entryPath) + if (!(await file.exists())) { + console.error(`File not found: ${ENTRY_FILE}`) + console.error(`Resolved to: ${entryPath}`) + process.exit(1) + } + + await traceFile(entryPath) +} + +main().catch(console.error) diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index ff9886313a..5cb51012ae 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -10,7 +10,8 @@ "customConditions": ["browser"], "paths": { "@/*": ["./src/*"], - "@tui/*": ["./src/cli/cmd/tui/*"] + "@tui/*": ["./src/cli/cmd/tui/*"], + "@test/*": ["./test/*"] }, "plugins": [ { diff --git a/packages/shared/package.json b/packages/shared/package.json index 252b381d48..ac2d8f2097 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,7 +6,8 @@ "license": "MIT", "private": true, "scripts": { - "test": "bun test" + "test": "bun test", + "typecheck": "tsgo --noEmit" }, "bin": { "opencode": "./bin/opencode" @@ -17,7 +18,9 @@ "imports": {}, "devDependencies": { "@types/semver": "catalog:", - "@types/bun": "catalog:" + "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", + "@tsconfig/bun": "catalog:" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts index 8bd0cc468b..955cafa190 100644 --- a/packages/shared/src/npm.ts +++ b/packages/shared/src/npm.ts @@ -1,6 +1,5 @@ import path from "path" import semver from "semver" -import { Arborist } from "@npmcli/arborist" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "./filesystem" @@ -19,8 +18,8 @@ export namespace Npm { } export interface Interface { - readonly add: (pkg: string) => Effect.Effect - readonly install: (dir: string) => Effect.Effect + readonly add: (pkg: string) => Effect.Effect + readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string) => Effect.Effect> } @@ -92,6 +91,7 @@ export namespace Npm { }) const add = Effect.fn("Npm.add")(function* (pkg: string) { + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const dir = directory(pkg) yield* flock.acquire(`npm-install:${dir}`) @@ -133,10 +133,17 @@ export namespace Npm { return resolveEntryPoint(first.name, first.path) }, Effect.scoped) - const install = Effect.fn("Npm.install")(function* (dir: string) { + const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + const canWrite = yield* afs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + if (!canWrite) return + yield* flock.acquire(`npm-install:${dir}`) const reify = Effect.fnUntraced(function* () { + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const arb = new Arborist({ path: dir, binLinks: true, @@ -145,7 +152,14 @@ export namespace Npm { ignoreScripts: true, }) yield* Effect.tryPromise({ - try: () => arb.reify().catch(() => {}), + try: () => + arb + .reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }) + .catch(() => {}), catch: () => {}, }).pipe(Effect.orElseSucceed(() => {})) }) @@ -167,6 +181,7 @@ export namespace Npm { ...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.optionalDependencies || {}), + ...(input?.add || []), ]) const root = lockAny?.packages?.[""] || {} diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts index 12c27a0a77..9d3b7c661a 100644 --- a/packages/shared/src/util/error.ts +++ b/packages/shared/src/util/error.ts @@ -4,6 +4,12 @@ export abstract class NamedError extends Error { abstract schema(): z.core.$ZodType abstract toObject(): { name: string; data: any } + static hasName(error: unknown, name: string): boolean { + return ( + typeof error === "object" && error !== null && "name" in error && (error as Record).name === name + ) + } + static create(name: Name, data: Data) { const schema = z .object({ diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts index 4a1df1dee7..958bd9fd1d 100644 --- a/packages/shared/src/util/flock.ts +++ b/packages/shared/src/util/flock.ts @@ -345,10 +345,14 @@ export namespace Flock { return await fn() } - export const effect = Effect.fn("Flock.effect")(function* (key: string) { + export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) { return yield* Effect.acquireRelease( - Effect.promise((signal) => Flock.acquire(key, { signal })), - (foo) => Effect.promise(() => foo.release()), + Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe( + Effect.withSpan("Flock.acquire", { + attributes: { key }, + }), + ), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")), ).pipe(Effect.asVoid) }) } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index ff9886313a..d7745d7554 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -2,16 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "@opentui/solid", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], "noUncheckedIndexedAccess": false, - "customConditions": ["browser"], - "paths": { - "@/*": ["./src/*"], - "@tui/*": ["./src/cli/cmd/tui/*"] - }, "plugins": [ { "name": "@effect/language-service",