diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index f4a9b452bda..2d87705a595 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -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"), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 6f8ab62b089..78e50369f7c 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -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) => { diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 410757967f5..6480d93fe8c 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -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, diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index aa09a32ea99..280a50002cd 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -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, diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts deleted file mode 100644 index 71d4b34b8db..00000000000 --- a/packages/cli/src/daemon.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Service } from "@opencode-ai/client/effect" -import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { Effect } from "effect" - -type ConnectOptions = { - readonly url: string - readonly username?: string - readonly password?: string -} - -export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) { - const endpoint = { - url: options.url, - auth: - options.password === undefined - ? undefined - : { type: "basic" as const, username: options.username ?? "opencode", password: options.password }, - } satisfies Service.Endpoint - const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const health = yield* Effect.tryPromise({ - try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), - catch: (cause) => connectError(options, cause), - }) - if (health.version !== InstallationVersion) - process.stderr.write( - `Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, - ) - return endpoint -}) - -function connectError(options: ConnectOptions, cause: unknown) { - if (isUnauthorizedError(cause)) { - return new Error( - options.password === undefined - ? `Server at ${options.url} requires authentication; provide a password` - : `Server at ${options.url} rejected the supplied credentials`, - { cause }, - ) - } - if (cause instanceof ClientError && cause.reason === "Transport") - return new Error(`Could not reach server at ${options.url}`, { cause }) - return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause }) -} - -export * as Daemon from "./daemon" diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts index 62decb77a56..ce411906de5 100644 --- a/packages/cli/src/mini/index.ts +++ b/packages/cli/src/mini/index.ts @@ -1,4 +1,4 @@ -export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" +export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" export { runNonInteractive, mergeInput as mergeNonInteractiveInput, diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 8024f7b138f..8b289299298 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,20 +1,14 @@ -import { NodeFileSystem } from "@effect/platform-node" import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Global } from "@opencode-ai/core/global" -import { Effect } from "effect" import path from "node:path" -import { Daemon } from "../daemon" -import { ServiceConfig } from "../services/service-config" +import { Server } from "../services/server" import { waitForCatalogReady } from "./catalog.shared" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import type { RunInput, RunTuiConfig } from "./types" export type MiniCommandInput = { + server: Server.Resolved directory?: string - attach?: string - password?: string - username?: string continue?: boolean session?: string fork?: boolean @@ -24,7 +18,6 @@ export type MiniCommandInput = { replay?: boolean replayLimit?: number demo?: boolean - serverCommand?: ReadonlyArray tuiConfig?: RunTuiConfig | Promise } @@ -33,47 +26,38 @@ export async function runMini(input: MiniCommandInput) { validate(input) const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) const runtimeTask = import("./runtime") - const directory = input.attach ? input.directory : localDirectory(input.directory) - const endpointTask = startEndpoint(input) - void endpointTask.catch(() => {}) + const directory = localDirectory(input.directory) try { - if (input.attach) await endpointTask const sdk = OpenCode.make({ - baseUrl: "http://opencode.pending", - fetch: deferredFetch(endpointTask), + baseUrl: input.server.endpoint.url, + headers: Service.headers(input.server.endpoint), }) - const attachedSession = - input.attach && input.session && !input.directory - ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) - : undefined - const resolvedDirectory = - directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await endpointTask, sdk)) const model = parseModel(input.model) let agentTask: Promise | undefined const resolveAgent = () => { - agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach) + agentTask ??= validateAgent(sdk, directory, input.agent) return agentTask } const resolveSession = async () => { const [agent, selected] = await Promise.all([ resolveAgent(), - selectSession(sdk, resolvedDirectory, input, attachedSession), + selectSession(sdk, directory, input), ]) const readyModel = model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) - if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel }) - const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model)) + if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) + const session = selected ?? (await createSession(sdk, directory, agent, model)) return { id: session.id, title: session.title, resume: selected !== undefined } } const create = ( _sdk: OpenCodeClient, next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined }, - ) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant) + ) => createSession(sdk, directory, next.agent, next.model, next.variant) const runtime = await runtimeTask await runtime.runInteractiveDeferredMode({ sdk, - directory: resolvedDirectory, + directory, resolveAgent, session: resolveSession, createSession: create, @@ -94,6 +78,10 @@ export async function runMini(input: MiniCommandInput) { } } +export function validateMiniTerminal() { + if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") +} + /** @internal Exported for testing. */ export function mergeInput(piped: string | undefined, prompt: string | undefined) { if (!prompt) return piped || undefined @@ -102,7 +90,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined } function validate(input: MiniCommandInput) { - if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") + validateMiniTerminal() if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) { fail("--replay-limit must be a positive integer") } @@ -120,50 +108,6 @@ function localDirectory(directory?: string): string { } } -function startEndpoint(input: MiniCommandInput): Promise { - if (input.attach) { - return Effect.runPromise( - Daemon.connect({ - url: input.attach, - password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, - username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, - }), - ) - } - return Effect.runPromise( - Effect.gen(function* () { - const options = yield* ServiceConfig.options() - return yield* Service.start( - input.serverCommand === undefined ? options : { ...options, command: input.serverCommand }, - ) - }).pipe( - Effect.provide(NodeFileSystem.layer), - Effect.provide(Global.layerWith({})), - ), - ) -} - -function deferredFetch(endpointTask: Promise): typeof globalThis.fetch { - const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const endpoint = await endpointTask - const request = new Request(input, init) - const source = new URL(request.url) - const headers = new Headers(request.headers) - for (const [key, value] of new Headers(Service.headers(endpoint))) headers.set(key, value) - return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers }) - } - return fetch as typeof globalThis.fetch -} - -async function remoteDirectory( - endpoint: Service.Endpoint, - sdk: OpenCodeClient, -): Promise { - const location = await sdk.location.get() - if (!location.directory) throw new Error(`Failed to resolve remote directory from ${endpoint.url}`) - return location.directory -} - function parseModel(value?: string): RunInput["model"] { if (!value) return const [providerID, ...rest] = value.split("/") @@ -172,7 +116,7 @@ function parseModel(value?: string): RunInput["model"] { return { providerID, modelID } } -async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) { +async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) { if (!name) return const deadline = Date.now() + 5_000 let agents: Awaited> | undefined @@ -187,7 +131,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri await Bun.sleep(25) } if (!agents) { - warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`) + warning("failed to list agents. Falling back to default agent") return } warning(`agent "${name}" not found. Falling back to default agent`) diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index a35f03be728..3780c201c87 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -1,21 +1,17 @@ -import { NodeFileSystem } from "@effect/platform-node" import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { Effect } from "effect" import { open } from "node:fs/promises" import path from "node:path" -import { Daemon } from "../daemon" -import { ServiceConfig } from "../services/service-config" -import { Standalone } from "../services/standalone" +import { Server } from "../services/server" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" import { UI } from "./ui" export type RunCommandInput = { + server: Server.Resolved message: string[] continue?: boolean session?: string @@ -25,14 +21,9 @@ export type RunCommandInput = { format: "default" | "json" file: string[] title?: string - server?: string - password?: string - username?: string - directory?: string variant?: string thinking?: boolean dangerouslySkipPermissions?: boolean - standaloneCommand?: ReadonlyArray } type FilePart = { @@ -56,24 +47,12 @@ export function runNonInteractive(input: RunCommandInput) { async function run(input: RunCommandInput) { if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") const root = process.env.PWD ?? process.cwd() - const directory = input.server ? input.directory : localDirectory(input.directory, root) + const directory = localDirectory(root) const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text()) if (!message?.trim()) fail("You must provide a message") - const files = await Promise.all( - input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)), - ) + const files = await Promise.all(input.file.map((file) => prepareFile(file, root))) const prepared = { directory, message, files } - if (input.standaloneCommand) - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Standalone.start({ command: input.standaloneCommand }) - yield* Effect.promise(() => execute(input, prepared, endpoint)) - }), - ), - ) - const endpoint = await startEndpoint(input) - return execute(input, prepared, endpoint) + return execute(input, prepared, input.server.endpoint) } async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) { @@ -100,7 +79,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID)) return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) } - const agent = await validateAgent(client, cwd, input.agent, input.server) + const agent = await validateAgent(client, cwd, input.agent) const selected = session ?? (await client.session.create({ @@ -126,7 +105,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser thinking: input.thinking ?? false, format: input.format, dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, - attached: !input.standaloneCommand, + attached: true, renderTool, renderToolError, }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id)) @@ -154,35 +133,15 @@ function formatMessage(message: string[]) { return value || undefined } -function localDirectory(directory: string | undefined, root: string) { +function localDirectory(root: string) { try { - process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + process.chdir(root) return process.cwd() } catch { - fail(`Failed to change directory to ${directory}`) + fail(`Failed to change directory to ${root}`) } } -function startEndpoint(input: RunCommandInput) { - if (input.server) { - return Effect.runPromise( - Daemon.connect({ - url: input.server, - password: input.password, - username: input.username, - }), - ) - } - return Effect.runPromise( - Effect.gen(function* () { - return yield* Service.start(yield* ServiceConfig.options()) - }).pipe( - Effect.provide(NodeFileSystem.layer), - Effect.provide(Global.layerWith({})), - ), - ) -} - function parseModel(value?: string) { if (!value) return const [providerID, ...rest] = value.split("/") @@ -191,11 +150,11 @@ function parseModel(value?: string) { return { providerID, modelID } } -async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) { +async function validateAgent(client: OpenCodeClient, directory: string, name?: string) { if (!name) return const agents = await loadRunAgents(client, directory).catch(() => undefined) if (!agents) { - warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) + warning("failed to list agents. Falling back to default agent") return } const agent = agents.find((item) => item.id === name) @@ -223,12 +182,11 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R return client.session.fork({ sessionID: selected.id }) } -async function prepareFile(input: string, directory: string, remote: boolean): Promise { +async function prepareFile(input: string, directory: string): Promise { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) try { const stat = await handle.stat() - if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`) if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES) fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`) const content = Buffer.alloc(Number(stat.size)) diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server.ts new file mode 100644 index 00000000000..6423d4708ac --- /dev/null +++ b/packages/cli/src/services/server.ts @@ -0,0 +1,77 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" +import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Effect, Redacted } from "effect" +import { Env } from "../env" +import { ServiceConfig } from "./service-config" +import { Standalone } from "./standalone" + +export type Args = { + readonly server?: string + readonly standalone?: boolean +} + +export type Resolved = { + readonly endpoint: Service.Endpoint + readonly discover?: () => Promise + readonly reload?: () => Promise +} + +export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { + if (args.server !== undefined && args.standalone) + return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) + if (args.server !== undefined) { + const password = yield* Env.password + const endpoint = { + url: args.server, + auth: password + ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } + : undefined, + } satisfies Service.Endpoint + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const health = yield* Effect.tryPromise({ + try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), + catch: (cause) => connectError(endpoint, cause), + }) + if (health.version !== InstallationVersion) + process.stderr.write( + `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, + ) + return { endpoint } satisfies Resolved + } + if (args.standalone) { + return { endpoint: yield* Standalone.start() } satisfies Resolved + } + + const options = yield* ServiceConfig.options() + const endpoint = yield* Service.start(options) + const reconnectOptions = { ...options, version: undefined } + return { + endpoint, + discover: () => Effect.runPromise(Service.start(reconnectOptions).pipe(Effect.provide(NodeFileSystem.layer))), + reload: () => + Effect.runPromise( + Effect.gen(function* () { + yield* Service.stop(options) + yield* Service.start(options) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ), + } satisfies Resolved +}) + +function connectError(endpoint: Service.Endpoint, cause: unknown) { + if (isUnauthorizedError(cause)) { + return new Error( + endpoint.auth === undefined + ? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD` + : `Server at ${endpoint.url} rejected the password`, + { cause }, + ) + } + if (cause instanceof ClientError && cause.reason === "Transport") + return new Error(`Could not reach server at ${endpoint.url}`, { cause }) + return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause }) +} + +export * as Server from "./server" diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 5673ff14a7f..3c5ddfe1467 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -71,6 +71,8 @@ describe("mini command", () => { const url = new URL(request.url) if (url.pathname === "/api/health") return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + if (url.pathname === "/api/location") + return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) if (url.pathname === "/api/model") { modelRequests++ return Response.json({ @@ -87,8 +89,6 @@ describe("mini command", () => { "run", "--server", server.url.toString(), - "--dir", - process.cwd(), "--model", "definitely/missing", "hi", @@ -104,7 +104,8 @@ describe("mini command", () => { test("reports pre-admission errors as JSON", async () => { const server = Bun.serve({ port: 0, - fetch() { + fetch(request) { + if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 }) return Response.json({ healthy: true, version: "incompatible", pid: process.pid }) }, }) @@ -116,7 +117,7 @@ describe("mini command", () => { expect(JSON.parse(result.stdout)).toMatchObject({ type: "error", sessionID: "", - error: { type: "unknown", message: "Failed to resolve server directory" }, + error: { type: "unknown", message: "UnexpectedStatus" }, }) } finally { server.stop(true) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 4339892cf96..0d06bd7c417 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -84,6 +84,7 @@ export const AttachCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ + // @ts-expect-error V1 does not consume the V2-only server input. client: createOpencodeClient({ baseUrl: args.url, headers, directory }), api: OpenCode.make({ baseUrl: args.url, headers }), config, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2021aadadd5..9478357a2d3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -112,6 +112,7 @@ export const RunCommand = effectCmd({ file: args.file ?? [], title: args.title, server: args.server ?? args.attach, + // @ts-expect-error V1 does not consume the V2-only resolved server input. password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD, username: args.username ?? process.env.OPENCODE_SERVER_USERNAME, directory: args.dir, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index f4d1649a148..79e21ed6bfe 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -159,6 +159,7 @@ export const TuiThreadCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ + // @ts-expect-error V1 does not consume the V2-only server input. client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }), api: OpenCode.make({ baseUrl: url, headers }), async onSnapshot() {