refactor(cli): centralize server resolution

This commit is contained in:
Dax Raad 2026-07-08 22:09:59 -04:00
commit cccd013801
13 changed files with 152 additions and 292 deletions

View file

@ -28,21 +28,25 @@ const MiniParams = {
),
}
const ServerParams = {
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
@ -128,6 +132,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Start the minimal interactive interface",
params: {
...MiniParams,
...ServerParams,
project: Argument.string("project").pipe(
Argument.withDescription("Path to start OpenCode in"),
Argument.optional,
@ -139,16 +144,13 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("run", {
description: "Run OpenCode with a message",
params: {
...ServerParams,
message: Argument.string("message").pipe(
Argument.withDescription("Message to send"),
Argument.variadic({ min: 0 }),
@ -183,11 +185,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.atMost(100),
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"),

View file

@ -1,4 +1,3 @@
import { NodeFileSystem } from "@effect/platform-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
@ -6,11 +5,8 @@ import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect, Option, Redacted } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) =>
@ -19,82 +15,15 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const server = Option.getOrUndefined(input.server)
if (server !== undefined && input.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
const endpoint = yield* Effect.gen(function* () {
if (server !== undefined) {
const password = yield* Env.password
const explicit = {
url: server,
auth: password
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined,
} satisfies Service.Endpoint
// Fail loudly before entering the TUI: an explicit server that is
// unreachable or rejects auth should not present as reconnect churn.
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", server), {
headers: Service.headers(explicit),
signal: AbortSignal.timeout(5_000),
}),
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
if (response.status === 401)
return yield* Effect.fail(
new Error(
password
? `Server at ${server} rejected the password`
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
),
)
if (!response.ok)
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
return explicit
}
if (input.standalone) return yield* Standalone.start()
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
return found ?? (yield* Service.start(options))
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
// The TUI re-runs discover whenever its event stream drops. For an explicit
// --server or a standalone child the endpoint is fixed, so reconnects
// retry the same address; for the managed service discovery re-reads the
// registration and may start a replacement.
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
// Only startup enforces the CLI version. A reconnect must accept a server
// replaced by another client or the two clients will restart it forever.
const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined
const discover = reconnectOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
const found = yield* Service.discover(reconnectOptions)
return found ?? (yield* Service.start(reconnectOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
// Restart the managed service in place; start() resolves once the
// replacement is healthy and the reconnect loop reattaches on its own.
// Only meaningful in service mode: --server is not ours to restart and a
// standalone child cannot be respawned.
const reload = serviceOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(serviceOptions)
yield* Service.start(serviceOptions)
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server: {
endpoint,
discover,
reload,
},
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
log: (level, message, tags) => {

View file

@ -1,25 +1,21 @@
import { Effect, Option, Redacted } from "effect"
import { Effect, Option } from "effect"
import path from "node:path"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini } = yield* Effect.promise(() => import("../../mini"))
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const project = Option.getOrUndefined(input.project)
const server = Option.getOrUndefined(input.server)
const password = yield* Env.password
const serverURL = Option.getOrUndefined(input.server)
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
yield* Effect.promise(() =>
runMini({
attach: server,
password: password ? Redacted.value(password) : undefined,
server,
directory:
server !== undefined
? project
: project === undefined
? process.cwd()
: path.resolve(process.env.PWD ?? process.cwd(), project),
project === undefined ? process.cwd() : path.resolve(process.env.PWD ?? process.cwd(), project),
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,

View file

@ -1,15 +1,19 @@
import { Effect, Option, Redacted } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const password = yield* Env.password
const separator = process.argv.indexOf("--", 2)
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
yield* Effect.promise(() =>
runNonInteractive({
server,
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
continue: input.continue,
session: Option.getOrUndefined(input.session),
@ -19,9 +23,6 @@ export default Runtime.handler(Commands.commands.run, (input) =>
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
server: Option.getOrUndefined(input.server),
password: password ? Redacted.value(password) : undefined,
directory: Option.getOrUndefined(input.dir),
variant: Option.getOrUndefined(input.variant),
thinking: input.thinking,
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,