refactor(cli): centralize server resolution
This commit is contained in:
parent
be7a684791
commit
cccd013801
13 changed files with 152 additions and 292 deletions
|
|
@ -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", {
|
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||||
description: "OpenCode 2.0 preview command line interface",
|
description: "OpenCode 2.0 preview command line interface",
|
||||||
params: {
|
params: {
|
||||||
|
...ServerParams,
|
||||||
directory: Argument.string("directory").pipe(
|
directory: Argument.string("directory").pipe(
|
||||||
Argument.withDescription("Directory to start OpenCode in"),
|
Argument.withDescription("Directory to start OpenCode in"),
|
||||||
Argument.optional,
|
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(
|
continue: Flag.boolean("continue").pipe(
|
||||||
Flag.withAlias("c"),
|
Flag.withAlias("c"),
|
||||||
Flag.withDescription("Continue the last session"),
|
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",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
...MiniParams,
|
...MiniParams,
|
||||||
|
...ServerParams,
|
||||||
project: Argument.string("project").pipe(
|
project: Argument.string("project").pipe(
|
||||||
Argument.withDescription("Path to start OpenCode in"),
|
Argument.withDescription("Path to start OpenCode in"),
|
||||||
Argument.optional,
|
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),
|
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt 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),
|
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Spec.make("run", {
|
Spec.make("run", {
|
||||||
description: "Run OpenCode with a message",
|
description: "Run OpenCode with a message",
|
||||||
params: {
|
params: {
|
||||||
|
...ServerParams,
|
||||||
message: Argument.string("message").pipe(
|
message: Argument.string("message").pipe(
|
||||||
Argument.withDescription("Message to send"),
|
Argument.withDescription("Message to send"),
|
||||||
Argument.variadic({ min: 0 }),
|
Argument.variadic({ min: 0 }),
|
||||||
|
|
@ -183,11 +185,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
Flag.atMost(100),
|
Flag.atMost(100),
|
||||||
),
|
),
|
||||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
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),
|
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
|
||||||
thinking: Flag.boolean("thinking").pipe(
|
thinking: Flag.boolean("thinking").pipe(
|
||||||
Flag.withDescription("Show thinking blocks"),
|
Flag.withDescription("Show thinking blocks"),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { run } from "@opencode-ai/tui"
|
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 { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Effect, Option, Redacted } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Server } from "../../services/server"
|
||||||
import { Env } from "../../env"
|
|
||||||
import { ServiceConfig } from "../../services/service-config"
|
|
||||||
import { Standalone } from "../../services/standalone"
|
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
|
|
@ -19,82 +15,15 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const server = Option.getOrUndefined(input.server)
|
const server = yield* Server.resolve({
|
||||||
if (server !== undefined && input.standalone)
|
server: Option.getOrUndefined(input.server),
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
standalone: input.standalone,
|
||||||
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))
|
|
||||||
})
|
})
|
||||||
// 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 })
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||||
yield* run({
|
yield* run({
|
||||||
server: {
|
server,
|
||||||
endpoint,
|
|
||||||
discover,
|
|
||||||
reload,
|
|
||||||
},
|
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
config,
|
config,
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import { Effect, Option, Redacted } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Env } from "../../env"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
import { Server } from "../../services/server"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
Effect.gen(function* () {
|
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 project = Option.getOrUndefined(input.project)
|
||||||
const server = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const password = yield* Env.password
|
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
attach: server,
|
server,
|
||||||
password: password ? Redacted.value(password) : undefined,
|
|
||||||
directory:
|
directory:
|
||||||
server !== undefined
|
project === undefined ? process.cwd() : path.resolve(process.env.PWD ?? process.cwd(), project),
|
||||||
? project
|
|
||||||
: project === undefined
|
|
||||||
? process.cwd()
|
|
||||||
: path.resolve(process.env.PWD ?? process.cwd(), project),
|
|
||||||
continue: input.continue,
|
continue: input.continue,
|
||||||
session: Option.getOrUndefined(input.session),
|
session: Option.getOrUndefined(input.session),
|
||||||
fork: input.fork,
|
fork: input.fork,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { Effect, Option, Redacted } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Env } from "../../env"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
import { Server } from "../../services/server"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||||
const password = yield* Env.password
|
|
||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
|
const server = yield* Server.resolve({
|
||||||
|
server: Option.getOrUndefined(input.server),
|
||||||
|
standalone: input.standalone,
|
||||||
|
})
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runNonInteractive({
|
runNonInteractive({
|
||||||
|
server,
|
||||||
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
|
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
|
||||||
continue: input.continue,
|
continue: input.continue,
|
||||||
session: Option.getOrUndefined(input.session),
|
session: Option.getOrUndefined(input.session),
|
||||||
|
|
@ -19,9 +23,6 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
||||||
format: input.format,
|
format: input.format,
|
||||||
file: [...input.file],
|
file: [...input.file],
|
||||||
title: Option.getOrUndefined(input.title),
|
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),
|
variant: Option.getOrUndefined(input.variant),
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||||
export {
|
export {
|
||||||
runNonInteractive,
|
runNonInteractive,
|
||||||
mergeInput as mergeNonInteractiveInput,
|
mergeInput as mergeNonInteractiveInput,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
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 path from "node:path"
|
||||||
import { Daemon } from "../daemon"
|
import { Server } from "../services/server"
|
||||||
import { ServiceConfig } from "../services/service-config"
|
|
||||||
import { waitForCatalogReady } from "./catalog.shared"
|
import { waitForCatalogReady } from "./catalog.shared"
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
import type { RunInput, RunTuiConfig } from "./types"
|
import type { RunInput, RunTuiConfig } from "./types"
|
||||||
|
|
||||||
export type MiniCommandInput = {
|
export type MiniCommandInput = {
|
||||||
|
server: Server.Resolved
|
||||||
directory?: string
|
directory?: string
|
||||||
attach?: string
|
|
||||||
password?: string
|
|
||||||
username?: string
|
|
||||||
continue?: boolean
|
continue?: boolean
|
||||||
session?: string
|
session?: string
|
||||||
fork?: boolean
|
fork?: boolean
|
||||||
|
|
@ -24,7 +18,6 @@ export type MiniCommandInput = {
|
||||||
replay?: boolean
|
replay?: boolean
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
demo?: boolean
|
demo?: boolean
|
||||||
serverCommand?: ReadonlyArray<string>
|
|
||||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,47 +26,38 @@ export async function runMini(input: MiniCommandInput) {
|
||||||
validate(input)
|
validate(input)
|
||||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
||||||
const runtimeTask = import("./runtime")
|
const runtimeTask = import("./runtime")
|
||||||
const directory = input.attach ? input.directory : localDirectory(input.directory)
|
const directory = localDirectory(input.directory)
|
||||||
const endpointTask = startEndpoint(input)
|
|
||||||
void endpointTask.catch(() => {})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (input.attach) await endpointTask
|
|
||||||
const sdk = OpenCode.make({
|
const sdk = OpenCode.make({
|
||||||
baseUrl: "http://opencode.pending",
|
baseUrl: input.server.endpoint.url,
|
||||||
fetch: deferredFetch(endpointTask),
|
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)
|
const model = parseModel(input.model)
|
||||||
let agentTask: Promise<string | undefined> | undefined
|
let agentTask: Promise<string | undefined> | undefined
|
||||||
const resolveAgent = () => {
|
const resolveAgent = () => {
|
||||||
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
|
agentTask ??= validateAgent(sdk, directory, input.agent)
|
||||||
return agentTask
|
return agentTask
|
||||||
}
|
}
|
||||||
const resolveSession = async () => {
|
const resolveSession = async () => {
|
||||||
const [agent, selected] = await Promise.all([
|
const [agent, selected] = await Promise.all([
|
||||||
resolveAgent(),
|
resolveAgent(),
|
||||||
selectSession(sdk, resolvedDirectory, input, attachedSession),
|
selectSession(sdk, directory, input),
|
||||||
])
|
])
|
||||||
const readyModel =
|
const readyModel =
|
||||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||||
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
|
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
||||||
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
|
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
||||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
return { id: session.id, title: session.title, resume: selected !== undefined }
|
||||||
}
|
}
|
||||||
const create = (
|
const create = (
|
||||||
_sdk: OpenCodeClient,
|
_sdk: OpenCodeClient,
|
||||||
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
|
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
|
const runtime = await runtimeTask
|
||||||
await runtime.runInteractiveDeferredMode({
|
await runtime.runInteractiveDeferredMode({
|
||||||
sdk,
|
sdk,
|
||||||
directory: resolvedDirectory,
|
directory,
|
||||||
resolveAgent,
|
resolveAgent,
|
||||||
session: resolveSession,
|
session: resolveSession,
|
||||||
createSession: create,
|
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. */
|
/** @internal Exported for testing. */
|
||||||
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
||||||
if (!prompt) return piped || undefined
|
if (!prompt) return piped || undefined
|
||||||
|
|
@ -102,7 +90,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate(input: MiniCommandInput) {
|
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)) {
|
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
||||||
fail("--replay-limit must be a positive integer")
|
fail("--replay-limit must be a positive integer")
|
||||||
}
|
}
|
||||||
|
|
@ -120,50 +108,6 @@ function localDirectory(directory?: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEndpoint(input: MiniCommandInput): Promise<Service.Endpoint> {
|
|
||||||
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<Service.Endpoint>): 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<string> {
|
|
||||||
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"] {
|
function parseModel(value?: string): RunInput["model"] {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const [providerID, ...rest] = value.split("/")
|
const [providerID, ...rest] = value.split("/")
|
||||||
|
|
@ -172,7 +116,7 @@ function parseModel(value?: string): RunInput["model"] {
|
||||||
return { providerID, modelID }
|
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
|
if (!name) return
|
||||||
const deadline = Date.now() + 5_000
|
const deadline = Date.now() + 5_000
|
||||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||||
|
|
@ -187,7 +131,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
|
||||||
await Bun.sleep(25)
|
await Bun.sleep(25)
|
||||||
}
|
}
|
||||||
if (!agents) {
|
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
|
return
|
||||||
}
|
}
|
||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
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 { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { open } from "node:fs/promises"
|
import { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Daemon } from "../daemon"
|
import { Server } from "../services/server"
|
||||||
import { ServiceConfig } from "../services/service-config"
|
|
||||||
import { Standalone } from "../services/standalone"
|
|
||||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
import { toolInlineInfo } from "./tool"
|
import { toolInlineInfo } from "./tool"
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
export type RunCommandInput = {
|
export type RunCommandInput = {
|
||||||
|
server: Server.Resolved
|
||||||
message: string[]
|
message: string[]
|
||||||
continue?: boolean
|
continue?: boolean
|
||||||
session?: string
|
session?: string
|
||||||
|
|
@ -25,14 +21,9 @@ export type RunCommandInput = {
|
||||||
format: "default" | "json"
|
format: "default" | "json"
|
||||||
file: string[]
|
file: string[]
|
||||||
title?: string
|
title?: string
|
||||||
server?: string
|
|
||||||
password?: string
|
|
||||||
username?: string
|
|
||||||
directory?: string
|
|
||||||
variant?: string
|
variant?: string
|
||||||
thinking?: boolean
|
thinking?: boolean
|
||||||
dangerouslySkipPermissions?: boolean
|
dangerouslySkipPermissions?: boolean
|
||||||
standaloneCommand?: ReadonlyArray<string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilePart = {
|
type FilePart = {
|
||||||
|
|
@ -56,24 +47,12 @@ export function runNonInteractive(input: RunCommandInput) {
|
||||||
async function run(input: RunCommandInput) {
|
async function run(input: RunCommandInput) {
|
||||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||||
const root = process.env.PWD ?? process.cwd()
|
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())
|
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
|
||||||
if (!message?.trim()) fail("You must provide a message")
|
if (!message?.trim()) fail("You must provide a message")
|
||||||
const files = await Promise.all(
|
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
|
||||||
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
|
|
||||||
)
|
|
||||||
const prepared = { directory, message, files }
|
const prepared = { directory, message, files }
|
||||||
if (input.standaloneCommand)
|
return execute(input, prepared, input.server.endpoint)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.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))
|
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
||||||
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
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 =
|
const selected =
|
||||||
session ??
|
session ??
|
||||||
(await client.session.create({
|
(await client.session.create({
|
||||||
|
|
@ -126,7 +105,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
|
||||||
thinking: input.thinking ?? false,
|
thinking: input.thinking ?? false,
|
||||||
format: input.format,
|
format: input.format,
|
||||||
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
|
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
|
||||||
attached: !input.standaloneCommand,
|
attached: true,
|
||||||
renderTool,
|
renderTool,
|
||||||
renderToolError,
|
renderToolError,
|
||||||
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
||||||
|
|
@ -154,35 +133,15 @@ function formatMessage(message: string[]) {
|
||||||
return value || undefined
|
return value || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDirectory(directory: string | undefined, root: string) {
|
function localDirectory(root: string) {
|
||||||
try {
|
try {
|
||||||
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
|
process.chdir(root)
|
||||||
return process.cwd()
|
return process.cwd()
|
||||||
} catch {
|
} 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) {
|
function parseModel(value?: string) {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const [providerID, ...rest] = value.split("/")
|
const [providerID, ...rest] = value.split("/")
|
||||||
|
|
@ -191,11 +150,11 @@ function parseModel(value?: string) {
|
||||||
return { providerID, modelID }
|
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
|
if (!name) return
|
||||||
const agents = await loadRunAgents(client, directory).catch(() => undefined)
|
const agents = await loadRunAgents(client, directory).catch(() => undefined)
|
||||||
if (!agents) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const agent = agents.find((item) => item.id === name)
|
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 })
|
return client.session.fork({ sessionID: selected.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
|
async function prepareFile(input: string, directory: string): Promise<FilePart> {
|
||||||
const file = path.resolve(directory, input)
|
const file = path.resolve(directory, input)
|
||||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||||
try {
|
try {
|
||||||
const stat = await handle.stat()
|
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)
|
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
||||||
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
||||||
const content = Buffer.alloc(Number(stat.size))
|
const content = Buffer.alloc(Number(stat.size))
|
||||||
|
|
|
||||||
77
packages/cli/src/services/server.ts
Normal file
77
packages/cli/src/services/server.ts
Normal file
|
|
@ -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<Service.Endpoint>
|
||||||
|
readonly reload?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
@ -71,6 +71,8 @@ describe("mini command", () => {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.pathname === "/api/health")
|
if (url.pathname === "/api/health")
|
||||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
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") {
|
if (url.pathname === "/api/model") {
|
||||||
modelRequests++
|
modelRequests++
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
|
@ -87,8 +89,6 @@ describe("mini command", () => {
|
||||||
"run",
|
"run",
|
||||||
"--server",
|
"--server",
|
||||||
server.url.toString(),
|
server.url.toString(),
|
||||||
"--dir",
|
|
||||||
process.cwd(),
|
|
||||||
"--model",
|
"--model",
|
||||||
"definitely/missing",
|
"definitely/missing",
|
||||||
"hi",
|
"hi",
|
||||||
|
|
@ -104,7 +104,8 @@ describe("mini command", () => {
|
||||||
test("reports pre-admission errors as JSON", async () => {
|
test("reports pre-admission errors as JSON", async () => {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
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 })
|
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -116,7 +117,7 @@ describe("mini command", () => {
|
||||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||||
type: "error",
|
type: "error",
|
||||||
sessionID: "",
|
sessionID: "",
|
||||||
error: { type: "unknown", message: "Failed to resolve server directory" },
|
error: { type: "unknown", message: "UnexpectedStatus" },
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export const AttachCommand = cmd({
|
||||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
|
// @ts-expect-error V1 does not consume the V2-only server input.
|
||||||
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
|
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
|
||||||
api: OpenCode.make({ baseUrl: args.url, headers }),
|
api: OpenCode.make({ baseUrl: args.url, headers }),
|
||||||
config,
|
config,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export const RunCommand = effectCmd({
|
||||||
file: args.file ?? [],
|
file: args.file ?? [],
|
||||||
title: args.title,
|
title: args.title,
|
||||||
server: args.server ?? args.attach,
|
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,
|
password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||||
username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||||
directory: args.dir,
|
directory: args.dir,
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ export const TuiThreadCommand = cmd({
|
||||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
|
// @ts-expect-error V1 does not consume the V2-only server input.
|
||||||
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
|
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
|
||||||
api: OpenCode.make({ baseUrl: url, headers }),
|
api: OpenCode.make({ baseUrl: url, headers }),
|
||||||
async onSnapshot() {
|
async onSnapshot() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue