diff --git a/packages/opencode/src/cli/cancelled-error.ts b/packages/opencode/src/cli/cancelled-error.ts new file mode 100644 index 000000000000..50edd7032722 --- /dev/null +++ b/packages/opencode/src/cli/cancelled-error.ts @@ -0,0 +1,7 @@ +import { Schema } from "effect" + +// Lives in its own module so that `src/cli/ui.ts` (loaded eagerly on every +// CLI invocation) doesn't have to pull `effect/Schema` just to define the +// error class. Callers that throw or match on this class are themselves +// lazy-loaded (github/agent prompts, error formatter). +export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 60526a62008b..3fb6dc3c34b0 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" +import { CancelledError } from "../cancelled-error" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" import { Provider } from "@/provider/provider" @@ -103,7 +104,7 @@ const AgentCreateCommand = effectCmd({ }, ], }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new CancelledError() scope = scopeResult } targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agents") @@ -119,7 +120,7 @@ const AgentCreateCommand = effectCmd({ placeholder: "What should this agent do?", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(query)) throw new UI.CancelledError() + if (prompts.isCancel(query)) throw new CancelledError() description = query } @@ -130,7 +131,7 @@ const AgentCreateCommand = effectCmd({ const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() + throw new CancelledError() }) spinner.stop(`Agent ${generated.identifier} generated`) @@ -147,7 +148,7 @@ const AgentCreateCommand = effectCmd({ })), initialValues: AVAILABLE_PERMISSIONS, }) - if (prompts.isCancel(result)) throw new UI.CancelledError() + if (prompts.isCancel(result)) throw new CancelledError() selected = result } @@ -177,7 +178,7 @@ const AgentCreateCommand = effectCmd({ ], initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + if (prompts.isCancel(modeResult)) throw new CancelledError() mode = modeResult } @@ -214,7 +215,7 @@ const AgentCreateCommand = effectCmd({ process.exit(1) } prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() + throw new CancelledError() } await Filesystem.write(filePath, content) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 9eb1faffea7f..1f07f9561336 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -3,6 +3,7 @@ import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import { CancelledError } from "../cancelled-error" import * as prompts from "@clack/prompts" import { EOL } from "os" import { Effect } from "effect" @@ -269,7 +270,7 @@ const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; ) if (prompts.isCancel(selectedSession)) { - return yield* Effect.die(new UI.CancelledError()) + return yield* Effect.die(new CancelledError()) } sessionID = selectedSession diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e501f0903c81..974d87370c8f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -17,6 +17,7 @@ import type { PullRequestEvent, } from "@octokit/webhooks-types" import { UI } from "../ui" +import { CancelledError } from "../cancelled-error" import { cmd } from "./cmd" import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@opencode-ai/core/models" @@ -248,7 +249,7 @@ export const GithubInstallCommand = effectCmd({ const project = ctx.project if (project.vcs !== "git") { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() + throw new CancelledError() } // Get repo info @@ -258,7 +259,7 @@ export const GithubInstallCommand = effectCmd({ const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() + throw new CancelledError() } return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } } @@ -288,7 +289,7 @@ export const GithubInstallCommand = effectCmd({ ), }) - if (prompts.isCancel(provider)) throw new UI.CancelledError() + if (prompts.isCancel(provider)) throw new CancelledError() return provider } @@ -310,7 +311,7 @@ export const GithubInstallCommand = effectCmd({ ), }) - if (prompts.isCancel(model)) throw new UI.CancelledError() + if (prompts.isCancel(model)) throw new CancelledError() return model } @@ -349,7 +350,7 @@ export const GithubInstallCommand = effectCmd({ s.stop( `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - throw new UI.CancelledError() + throw new CancelledError() } retries++ diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 2ae7cece6a27..a5122b285250 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -6,6 +6,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" +import { CancelledError } from "../cancelled-error" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" @@ -220,7 +221,7 @@ export const McpAuthCommand = effectCmd({ options, }), ) - if (prompts.isCancel(selected)) throw new UI.CancelledError() + if (prompts.isCancel(selected)) throw new CancelledError() serverName = selected } @@ -380,7 +381,7 @@ export const McpLogoutCommand = effectCmd({ }), }), ) - if (prompts.isCancel(selected)) throw new UI.CancelledError() + if (prompts.isCancel(selected)) throw new CancelledError() serverName = selected } @@ -468,7 +469,7 @@ export const McpAddCommand = effectCmd({ }, ], }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new CancelledError() configPath = scopeResult } @@ -476,7 +477,7 @@ export const McpAddCommand = effectCmd({ message: "Enter MCP server name", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(name)) throw new UI.CancelledError() + if (prompts.isCancel(name)) throw new CancelledError() const type = await prompts.select({ message: "Select MCP server type", @@ -493,7 +494,7 @@ export const McpAddCommand = effectCmd({ }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(type)) throw new CancelledError() if (type === "local") { const command = await prompts.text({ @@ -501,7 +502,7 @@ export const McpAddCommand = effectCmd({ placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + if (prompts.isCancel(command)) throw new CancelledError() const mcpConfig: ConfigMCP.Info = { type: "local", @@ -525,13 +526,13 @@ export const McpAddCommand = effectCmd({ return isValid ? undefined : "Invalid URL" }, }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + if (prompts.isCancel(url)) throw new CancelledError() const useOAuth = await prompts.confirm({ message: "Does this server require OAuth authentication?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(useOAuth)) throw new CancelledError() let mcpConfig: ConfigMCP.Info @@ -540,27 +541,27 @@ export const McpAddCommand = effectCmd({ message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new CancelledError() if (hasClientId) { const clientId = await prompts.text({ message: "Enter client ID", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(clientId)) throw new CancelledError() const hasSecret = await prompts.confirm({ message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new CancelledError() let clientSecret: string | undefined if (hasSecret) { const secret = await prompts.password({ message: "Enter client secret", }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() + if (prompts.isCancel(secret)) throw new CancelledError() clientSecret = secret } diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 25f1bf968c30..2ff0960018b6 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -2,6 +2,7 @@ import { Auth } from "../../auth" import { cmd } from "./cmd" import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import { CancelledError } from "../cancelled-error" import * as Prompt from "../effect/prompt" import { ModelsDev } from "@opencode-ai/core/models" @@ -20,7 +21,7 @@ import { Effect, Option } from "effect" type PluginAuth = NonNullable const promptValue = (value: Option.Option) => { - if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) + if (Option.isNone(value)) return Effect.die(new CancelledError()) return Effect.succeed(value.value) } diff --git a/packages/opencode/src/cli/cmd/tui/thread-spec.ts b/packages/opencode/src/cli/cmd/tui/thread-spec.ts new file mode 100644 index 000000000000..190ebfe84f68 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/thread-spec.ts @@ -0,0 +1,47 @@ +import type { Argv } from "yargs" +import { withNetworkOptions } from "@/cli/network-options" + +// Single source of truth for the default ($0) command's yargs spec. +// +// This module is imported eagerly from `src/index.ts` so the top-level +// `--help` listing can render the default command's options synchronously +// without dynamic-importing `./thread.ts` (and its Effect/SDK/TUI graph). +// The real `TuiThreadCommand` in `./thread.ts` spreads `TuiThreadSpec` so +// the option contract has exactly one definition. +export const TuiThreadSpec = { + command: "$0 [project]", + describe: "start opencode tui", + builder: (yargs: Argv) => + withNetworkOptions(yargs) + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + type: "string", + describe: "session id to continue", + }) + .option("fork", { + type: "boolean", + describe: "fork the session when continuing (use with --continue or --session)", + }) + .option("prompt", { + type: "string", + describe: "prompt to use", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }), +} as const diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7230dae16ae4..71761e49da6d 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -7,7 +7,8 @@ import { UI } from "@/cli/ui" import * as Log from "@opencode-ai/core/util/log" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" -import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" +import { resolveNetworkOptionsNoConfig } from "@/cli/network" +import { TuiThreadSpec } from "./thread-spec" import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" @@ -29,21 +30,24 @@ declare global { type RpcClient = ReturnType> function createWorkerFetch(client: RpcClient): typeof fetch { - const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const request = new Request(input, init) - const body = request.body ? await request.text() : undefined - const result = await client.call("fetch", { - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - body, - }) - return new Response(result.body, { - status: result.status, - headers: result.headers, - }) - } - return fn as typeof fetch + const fn = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init) + const body = request.body ? await request.text() : undefined + const result = await client.call("fetch", { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body, + }) + return new Response(result.body, { + status: result.status, + headers: result.headers, + }) + }, + { preconnect: fetch.preconnect }, + ) + return fn } function createEventSource(client: RpcClient): EventSource { @@ -77,41 +81,7 @@ export function resolveThreadDirectory(project?: string, envPWD = process.env.PW } export const TuiThreadCommand = cmd({ - command: "$0 [project]", - describe: "start opencode tui", - builder: (yargs) => - withNetworkOptions(yargs) - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) - .option("continue", { - alias: ["c"], - describe: "continue the last session", - type: "boolean", - }) - .option("session", { - alias: ["s"], - type: "string", - describe: "session id to continue", - }) - .option("fork", { - type: "boolean", - describe: "fork the session when continuing (use with --continue or --session)", - }) - .option("prompt", { - type: "string", - describe: "prompt to use", - }) - .option("agent", { - type: "string", - describe: "agent to use", - }), + ...TuiThreadSpec, handler: async (args) => { // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. // (Important when running under `bun run` wrappers on Windows.) diff --git a/packages/opencode/src/cli/lazy.ts b/packages/opencode/src/cli/lazy.ts new file mode 100644 index 000000000000..65511c760601 --- /dev/null +++ b/packages/opencode/src/cli/lazy.ts @@ -0,0 +1,62 @@ +import type { Argv, ArgumentsCamelCase, CommandModule } from "yargs" + +/** + * Metadata yargs needs synchronously to register a top-level command: + * - `command` / `aliases` — to match argv and drive completion + * - `describe` — to render the top-level `--help` listing + * - `deprecated` — to render the deprecation marker in help + * + * Everything else (`builder`, `handler`) can be async, which is what lets us + * defer loading the implementation module until the command actually fires. + */ +export type LazyMeta = { + readonly command: string | readonly string[] + readonly aliases?: string | readonly string[] + readonly describe?: string | false + readonly deprecated?: boolean | string +} + +/** + * Register a yargs `CommandModule` whose implementation module is loaded + * lazily. The metadata above ships eagerly so `--help`, completion, and argv + * matching cost nothing extra; the first invocation of `builder` or `handler` + * triggers a single shared `load()` that subsequent calls reuse. + * + * If `load()` rejects (e.g. a missing chunk in a compiled binary) the error + * is rewrapped with the command name so the failure points at the right call + * site instead of an anonymous dynamic-import frame. + */ +export function lazy( + meta: LazyMeta, + load: () => Promise>, +): CommandModule { + let cached: Promise> | undefined + const get = () => + (cached ??= load().catch((cause: unknown) => { + const name = typeof meta.command === "string" ? meta.command : meta.command[0] + throw new Error(`Failed to lazy-load command "${name}"`, { cause }) + })) + + return { + command: meta.command, + aliases: meta.aliases, + describe: meta.describe, + deprecated: meta.deprecated, + builder: async (yargs: Argv): Promise> => { + const mod = await get() + const builder = mod.builder + if (typeof builder === "function") return builder(yargs) + // Object-builder form: yargs treats `{ [k]: Options }` as shorthand for + // `.options(b)`. The loaded module already constrains `U`; yargs' public + // types just cannot express that relationship back out here. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + if (builder) return yargs.options(builder) as Argv + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + return yargs as Argv + }, + handler: async (args: ArgumentsCamelCase): Promise => { + const mod = await get() + await mod.handler(args) + }, + } +} diff --git a/packages/opencode/src/cli/network-options.ts b/packages/opencode/src/cli/network-options.ts new file mode 100644 index 000000000000..ca7a6439b978 --- /dev/null +++ b/packages/opencode/src/cli/network-options.ts @@ -0,0 +1,40 @@ +import type { Argv, InferredOptionTypes } from "yargs" + +// Pure yargs option metadata for the network flags shared by `$0`, `serve`, +// `web`, and `acp`. Kept in its own module — with zero Effect/Config imports — +// so the lazy CLI entrypoint can spread the spec into its synchronous default- +// command builder without dragging in the runtime resolver below. +export const networkOptions = { + port: { + type: "number" as const, + describe: "port to listen on", + default: 0, + }, + hostname: { + type: "string" as const, + describe: "hostname to listen on", + default: "127.0.0.1", + }, + mdns: { + type: "boolean" as const, + describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", + default: false, + }, + "mdns-domain": { + type: "string" as const, + describe: "custom domain name for mDNS service (default: opencode.local)", + default: "opencode.local", + }, + cors: { + type: "string" as const, + array: true, + describe: "additional domains to allow for CORS", + default: [] as string[], + }, +} + +export type NetworkOptions = InferredOptionTypes + +export function withNetworkOptions(yargs: Argv) { + return yargs.options(networkOptions) +} diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 41f8184ef5d7..dc7c15ce6d12 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,41 +1,12 @@ -import type { Argv, InferredOptionTypes } from "yargs" import { Config } from "@/config/config" import { Effect } from "effect" +import type { NetworkOptions } from "./network-options" -const options = { - port: { - type: "number" as const, - describe: "port to listen on", - default: 0, - }, - hostname: { - type: "string" as const, - describe: "hostname to listen on", - default: "127.0.0.1", - }, - mdns: { - type: "boolean" as const, - describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", - default: false, - }, - "mdns-domain": { - type: "string" as const, - describe: "custom domain name for mDNS service (default: opencode.local)", - default: "opencode.local", - }, - cors: { - type: "string" as const, - array: true, - describe: "additional domains to allow for CORS", - default: [] as string[], - }, -} - -export type NetworkOptions = InferredOptionTypes +// Re-export the synchronous spec from its dedicated light module so existing +// callers continue working unchanged while `src/index.ts` can import the spec +// without pulling Effect. +export { networkOptions, withNetworkOptions, type NetworkOptions } from "./network-options" -export function withNetworkOptions(yargs: Argv) { - return yargs.options(options) -} export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) return resolveNetworkOptionsNoConfig(args, config) diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 6ad6495cf10b..93d3b1986eae 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,5 +1,4 @@ import { EOL } from "os" -import { Schema } from "effect" import { logo as glyphs } from "./logo" const wordmark = [ @@ -9,8 +8,6 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} - export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d20f29dd4d2f..42ea230c83a7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,55 +1,37 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" -import { RunCommand } from "./cli/cmd/run" -import { GenerateCommand } from "./cli/cmd/generate" -import * as Log from "@opencode-ai/core/util/log" -import { ConsoleCommand } from "./cli/cmd/account" -import { ProvidersCommand } from "./cli/cmd/providers" -import { AgentCommand } from "./cli/cmd/agent" -import { UpgradeCommand } from "./cli/cmd/upgrade" -import { UninstallCommand } from "./cli/cmd/uninstall" -import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" -import { Installation } from "./installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { NamedError } from "@opencode-ai/core/util/error" -import { FormatError } from "./cli/error" -import { ServeCommand } from "./cli/cmd/serve" import { Filesystem } from "@/util/filesystem" -import { DebugCommand } from "./cli/cmd/debug" -import { StatsCommand } from "./cli/cmd/stats" -import { McpCommand } from "./cli/cmd/mcp" -import { GithubCommand } from "./cli/cmd/github" -import { ExportCommand } from "./cli/cmd/export" -import { ImportCommand } from "./cli/cmd/import" -import { AttachCommand } from "./cli/cmd/tui/attach" -import { TuiThreadCommand } from "./cli/cmd/tui/thread" -import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" -import { WebCommand } from "./cli/cmd/web" -import { PrCommand } from "./cli/cmd/pr" -import { SessionCommand } from "./cli/cmd/session" -import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "@opencode-ai/core/global" -import { JsonMigration } from "@/storage/json-migration" -import { Database } from "@/storage/db" import { errorMessage } from "./util/error" -import { PluginCommand } from "./cli/cmd/plug" -import { Heap } from "./cli/heap" -import { drizzle } from "drizzle-orm/bun-sqlite" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" import { isRecord } from "@/util/record" +import { lazy } from "./cli/lazy" +import { TuiThreadSpec } from "./cli/cmd/tui/thread-spec" + +// Heavy modules — deferred so parser-only paths (`--help`, `--version`, +// completion) and commands whose handlers don't need them skip their +// import-time bootstrap. +const loadLog = () => import("@opencode-ai/core/util/log") +const loadInstallation = () => import("./installation").then((m) => m.Installation) +const loadHeap = () => import("./cli/heap").then((m) => m.Heap) +const loadNamedError = () => import("@opencode-ai/core/util/error").then((m) => m.NamedError) +const loadFormatError = () => import("./cli/error").then((m) => m.FormatError) const processMetadata = ensureProcessMetadata("main") -process.on("unhandledRejection", (e) => { +process.on("unhandledRejection", async (e) => { + const Log = await loadLog() Log.Default.error("rejection", { e: errorMessage(e), }) }) -process.on("uncaughtException", (e) => { +process.on("uncaughtException", async (e) => { + const Log = await loadLog() Log.Default.error("exception", { e: errorMessage(e), }) @@ -93,11 +75,15 @@ const cli = yargs(args) process.env.OPENCODE_PURE = "1" } + const [Log, Installation, Heap] = await Promise.all([loadLog(), loadInstallation(), loadHeap()]) + await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isLocal(), level: (() => { - if (opts.logLevel) return opts.logLevel as Log.Level + if (opts.logLevel === "DEBUG" || opts.logLevel === "INFO" || opts.logLevel === "WARN" || opts.logLevel === "ERROR") { + return opts.logLevel + } if (Installation.isLocal()) return "DEBUG" return "INFO" })(), @@ -127,6 +113,11 @@ const cli = yargs(args) let last = -1 if (tty) process.stderr.write("\x1b[?25l") try { + const [{ JsonMigration }, { Database }, { drizzle }] = await Promise.all([ + import("@/storage/json-migration"), + import("@/storage/db"), + import("drizzle-orm/bun-sqlite"), + ]) await JsonMigration.run(drizzle({ client: Database.Client().$client }), { progress: (event) => { const percent = Math.floor((event.current / event.total) * 100) @@ -155,29 +146,42 @@ const cli = yargs(args) }) .usage("") .completion("completion", "generate shell completion script") - .command(AcpCommand) - .command(McpCommand) - .command(TuiThreadCommand) - .command(AttachCommand) - .command(RunCommand) - .command(GenerateCommand) - .command(DebugCommand) - .command(ConsoleCommand) - .command(ProvidersCommand) - .command(AgentCommand) - .command(UpgradeCommand) - .command(UninstallCommand) - .command(ServeCommand) - .command(WebCommand) - .command(ModelsCommand) - .command(StatsCommand) - .command(ExportCommand) - .command(ImportCommand) - .command(GithubCommand) - .command(PrCommand) - .command(SessionCommand) - .command(PluginCommand) - .command(DbCommand) + .command(lazy({ command: "acp", describe: "start ACP (Agent Client Protocol) server" }, () => import("./cli/cmd/acp").then((m) => m.AcpCommand))) + .command(lazy({ command: "mcp", describe: "manage MCP (Model Context Protocol) servers" }, () => import("./cli/cmd/mcp").then((m) => m.McpCommand))) + .command( + // Default ($0) command — yargs renders its option spec inline in + // top-level `--help`, so the spec must be resolvable synchronously. + // `TuiThreadSpec` is the single source of truth (also consumed by + // `cli/cmd/tui/thread.ts`); the handler still defers loading the + // implementation module. + TuiThreadSpec.command, + TuiThreadSpec.describe, + TuiThreadSpec.builder, + async (args) => { + const { TuiThreadCommand } = await import("./cli/cmd/tui/thread") + await TuiThreadCommand.handler(args as Parameters[0]) + }, + ) + .command(lazy({ command: "attach ", describe: "attach to a running opencode server" }, () => import("./cli/cmd/tui/attach").then((m) => m.AttachCommand))) + .command(lazy({ command: "run [message..]", describe: "run opencode with a message" }, () => import("./cli/cmd/run").then((m) => m.RunCommand))) + .command(lazy({ command: "generate" }, () => import("./cli/cmd/generate").then((m) => m.GenerateCommand))) + .command(lazy({ command: "debug", describe: "debugging and troubleshooting tools" }, () => import("./cli/cmd/debug").then((m) => m.DebugCommand))) + .command(lazy({ command: "console", describe: false }, () => import("./cli/cmd/account").then((m) => m.ConsoleCommand))) + .command(lazy({ command: "providers", aliases: ["auth"], describe: "manage AI providers and credentials" }, () => import("./cli/cmd/providers").then((m) => m.ProvidersCommand))) + .command(lazy({ command: "agent", describe: "manage agents" }, () => import("./cli/cmd/agent").then((m) => m.AgentCommand))) + .command(lazy({ command: "upgrade [target]", describe: "upgrade opencode to the latest or a specific version" }, () => import("./cli/cmd/upgrade").then((m) => m.UpgradeCommand))) + .command(lazy({ command: "uninstall", describe: "uninstall opencode and remove all related files" }, () => import("./cli/cmd/uninstall").then((m) => m.UninstallCommand))) + .command(lazy({ command: "serve", describe: "starts a headless opencode server" }, () => import("./cli/cmd/serve").then((m) => m.ServeCommand))) + .command(lazy({ command: "web", describe: "start opencode server and open web interface" }, () => import("./cli/cmd/web").then((m) => m.WebCommand))) + .command(lazy({ command: "models [provider]", describe: "list all available models" }, () => import("./cli/cmd/models").then((m) => m.ModelsCommand))) + .command(lazy({ command: "stats", describe: "show token usage and cost statistics" }, () => import("./cli/cmd/stats").then((m) => m.StatsCommand))) + .command(lazy({ command: "export [sessionID]", describe: "export session data as JSON" }, () => import("./cli/cmd/export").then((m) => m.ExportCommand))) + .command(lazy({ command: "import ", describe: "import session data from JSON file or URL" }, () => import("./cli/cmd/import").then((m) => m.ImportCommand))) + .command(lazy({ command: "github", describe: "manage GitHub agent" }, () => import("./cli/cmd/github").then((m) => m.GithubCommand))) + .command(lazy({ command: "pr ", describe: "fetch and checkout a GitHub PR branch, then run opencode" }, () => import("./cli/cmd/pr").then((m) => m.PrCommand))) + .command(lazy({ command: "session", describe: "manage sessions" }, () => import("./cli/cmd/session").then((m) => m.SessionCommand))) + .command(lazy({ command: "plugin ", aliases: ["plug"], describe: "install plugin and update config" }, () => import("./cli/cmd/plug").then((m) => m.PluginCommand))) + .command(lazy({ command: "db", describe: "database tools" }, () => import("./cli/cmd/db").then((m) => m.DbCommand))) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || @@ -213,6 +217,7 @@ try { }) } + const NamedError = await loadNamedError() if (e instanceof NamedError) { const obj = e.toObject() if (isRecord(obj.data)) { @@ -234,7 +239,9 @@ try { importKind: e.importKind, }) } + const Log = await loadLog() Log.Default.error("fatal", data) + const FormatError = await loadFormatError() const formatted = FormatError(e) if (formatted) UI.error(formatted) if (formatted === undefined) { diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b29ca2b3bae1..353554f8553b 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { AccountTransportError } from "../../src/account/schema" import { FormatError } from "../../src/cli/error" -import { UI } from "../../src/cli/ui" +import { CancelledError } from "../../src/cli/cancelled-error" describe("cli.error", () => { test("formats legacy and tagged config errors the same way", () => { @@ -90,6 +90,6 @@ describe("cli.error", () => { }) test("formats cancelled UI errors as empty output", () => { - expect(FormatError(new UI.CancelledError())).toBe("") + expect(FormatError(new CancelledError())).toBe("") }) }) diff --git a/packages/opencode/test/cli/lazy.test.ts b/packages/opencode/test/cli/lazy.test.ts new file mode 100644 index 000000000000..11986f02b7aa --- /dev/null +++ b/packages/opencode/test/cli/lazy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, mock, test } from "bun:test" +import yargs, { type Argv, type CommandModule } from "yargs" +import { lazy } from "../../src/cli/lazy" + +// Minimal CommandModule stub with sensible defaults; tests override the parts +// they care about. Kept narrow on purpose — these tests cover the lazy() +// wrapper's behaviour, not yargs's option parsing. +function stubCommand(overrides: Partial> = {}): CommandModule { + return { + command: "stub", + describe: "stub", + builder: (y) => y, + handler: () => {}, + ...overrides, + } +} + +describe("cli.lazy", () => { + test("registers metadata synchronously without invoking the loader", () => { + const load = mock(() => Promise.resolve(stubCommand())) + const cmd = lazy( + { command: "foo", describe: "manage foos", aliases: ["f"], deprecated: "use bar" }, + load, + ) + + expect(load).not.toHaveBeenCalled() + expect(cmd.command).toBe("foo") + expect(cmd.describe).toBe("manage foos") + expect(cmd.aliases).toEqual(["f"]) + expect(cmd.deprecated).toBe("use bar") + }) + + test("loader runs at most once across repeated builder + handler invocations", async () => { + const handler = mock(() => undefined) + const load = mock(() => Promise.resolve(stubCommand({ handler }))) + const cmd = lazy({ command: "x", describe: "x" }, load) + + const y = yargs([]) + await (cmd.builder as (a: Argv) => unknown)(y) + await (cmd.builder as (a: Argv) => unknown)(y) + await (cmd.handler as (a: unknown) => unknown)({}) + + expect(load).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledTimes(1) + }) + + test("forwards function-form builders so option specs reach yargs", async () => { + const inner = mock((y: Argv) => y.option("flag", { type: "boolean" })) + const cmd = lazy( + { command: "x", describe: "x" }, + () => Promise.resolve(stubCommand({ builder: inner as never })), + ) + + await (cmd.builder as (a: Argv) => Promise)(yargs([])) + expect(inner).toHaveBeenCalledTimes(1) + }) + + test("routes object-form builders through yargs.options()", async () => { + const cmd = lazy( + { command: "x", describe: "x" }, + () => + Promise.resolve( + stubCommand({ + builder: { flag: { type: "boolean", describe: "..." } } as never, + }), + ), + ) + + const parsed = await ((await (cmd.builder as (a: Argv) => Promise)(yargs([]))) + .parseAsync(["--flag"]) as Promise>) + expect(parsed.flag).toBe(true) + }) + + test("returns yargs unchanged when the loaded module has no builder", async () => { + const cmd = lazy( + { command: "x", describe: "x" }, + () => Promise.resolve(stubCommand({ builder: undefined })), + ) + + const y = yargs([]) + const result = await (cmd.builder as (a: Argv) => Promise)(y) + expect(result).toBe(y) + }) + + test("forwards parsed args to the loaded handler and awaits it", async () => { + let seen: unknown + const handler = mock(async (args: unknown) => { + seen = args + }) + const cmd = lazy( + { command: "x", describe: "x" }, + () => Promise.resolve(stubCommand({ handler })), + ) + + await (cmd.handler as (a: unknown) => Promise)({ foo: "bar" }) + expect(seen).toEqual({ foo: "bar" }) + }) + + test("wraps loader rejections with the command name and preserves the original error", async () => { + const original = new Error("chunk missing") + const cmd = lazy({ command: "broken", describe: "broken" }, () => Promise.reject(original)) + + let caught: unknown + try { + await (cmd.builder as (a: Argv) => Promise)(yargs([])) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toBe('Failed to lazy-load command "broken"') + expect((caught as Error).cause).toBe(original) + }) + + test("uses the first entry when command is an array of names", async () => { + const cmd = lazy( + { command: ["primary", "alt"], describe: "..." }, + () => Promise.reject(new Error("nope")), + ) + + let caught: unknown + try { + await (cmd.handler as (a: unknown) => Promise)({}) + } catch (e) { + caught = e + } + + expect((caught as Error).message).toBe('Failed to lazy-load command "primary"') + }) + + test("caches failed loads so a single error surfaces from every entrypoint", async () => { + const load = mock(() => Promise.reject(new Error("boom"))) + const cmd = lazy({ command: "x", describe: "x" }, load) + + const errors: unknown[] = [] + for (const fn of [() => (cmd.builder as (a: Argv) => unknown)(yargs([])), () => (cmd.handler as (a: unknown) => unknown)({})]) { + try { + await fn() + } catch (e) { + errors.push(e) + } + } + + expect(load).toHaveBeenCalledTimes(1) + expect(errors).toHaveLength(2) + expect((errors[0] as Error).message).toBe('Failed to lazy-load command "x"') + expect((errors[1] as Error).message).toBe('Failed to lazy-load command "x"') + }) +}) diff --git a/packages/opencode/test/cli/tui/thread-spec.test.ts b/packages/opencode/test/cli/tui/thread-spec.test.ts new file mode 100644 index 000000000000..6fc4d87d3851 --- /dev/null +++ b/packages/opencode/test/cli/tui/thread-spec.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import yargs from "yargs" +import { TuiThreadSpec } from "../../../src/cli/cmd/tui/thread-spec" +import { TuiThreadCommand } from "../../../src/cli/cmd/tui/thread" + +describe("tui thread spec", () => { + test("defines the shared default command help surface", () => { + const cli = TuiThreadSpec.builder(yargs([])) + const defaults = cli.parseSync([]) + const aliases = cli.parseSync(["-m", "model", "-c", "-s", "session-id"]) + + expect(TuiThreadSpec.command).toBe("$0 [project]") + expect(TuiThreadSpec.describe).toBe("start opencode tui") + expect(defaults.port).toBe(0) + expect(defaults.hostname).toBe("127.0.0.1") + expect(defaults.mdns).toBe(false) + expect(defaults.mdnsDomain).toBe("opencode.local") + expect(defaults.cors).toEqual([]) + expect(aliases.model).toBe("model") + expect(aliases.continue).toBe(true) + expect(aliases.session).toBe("session-id") + }) + + test("TuiThreadCommand inherits command, describe, and builder from the spec by reference", () => { + // Reference identity guards against the drift the dedup refactor was + // designed to prevent: if anyone copies the spec into `thread.ts` (or + // wraps the builder), this fails before the duplicated definitions can + // diverge from `src/index.ts`'s default-command registration. + expect(TuiThreadCommand.command).toBe(TuiThreadSpec.command) + expect(TuiThreadCommand.describe).toBe(TuiThreadSpec.describe) + expect(TuiThreadCommand.builder).toBe(TuiThreadSpec.builder) + }) + + test("parses each declared option to its expected type", () => { + // Register through `.command()` so the `[project]` positional binds the + // same way it does in `src/index.ts`. Parsing against the bare builder + // would skip positionals. + let parsed: Record | undefined + yargs([]) + .command(TuiThreadSpec.command, TuiThreadSpec.describe, TuiThreadSpec.builder, (args) => { + parsed = args + }) + .strict() + .parseSync([ + "my-project", + "--port", + "1234", + "--hostname", + "0.0.0.0", + "--mdns", + "--mdns-domain", + "custom.local", + "--cors", + "example.com", + "--model", + "anthropic/claude", + "--continue", + "--session", + "abc", + "--fork", + "--prompt", + "hi", + "--agent", + "default", + ]) + + if (!parsed) throw new Error("handler did not run") + expect(parsed.project).toBe("my-project") + expect(parsed.port).toBe(1234) + expect(parsed.hostname).toBe("0.0.0.0") + expect(parsed.mdns).toBe(true) + expect(parsed.mdnsDomain).toBe("custom.local") + expect(parsed.cors).toEqual(["example.com"]) + expect(parsed.model).toBe("anthropic/claude") + expect(parsed.continue).toBe(true) + expect(parsed.session).toBe("abc") + expect(parsed.fork).toBe(true) + expect(parsed.prompt).toBe("hi") + expect(parsed.agent).toBe("default") + }) +})