cli: extract run from mini package (#37737)

This commit is contained in:
Simon Klee 2026-07-19 11:11:03 +02:00 committed by GitHub
commit 3f5ad8441f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 500 additions and 50 deletions

View file

@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini/mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })

View file

@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
const separator = process.argv.indexOf("--", 2)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),

View file

@ -1,8 +1 @@
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export {
runNonInteractive,
mergeInput as mergeNonInteractiveInput,
pickRunModel,
parseRunModel,
type RunCommandInput,
} from "./run"

View file

@ -0,0 +1,2 @@
export { runNonInteractive, type RunCommandInput } from "./run"
export { runV1Bridge, type V1RunCommandInput } from "./v1"

View file

@ -2,9 +2,9 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { toolOutputText } from "./tool"
import { toolOutputText } from "../mini/tool"
import type { MiniToolPart } from "../mini/types"
import { UI } from "./ui"
import type { MiniToolPart } from "./types"
type Model = {
providerID: string
@ -30,6 +30,7 @@ type Input = {
auto: boolean
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean
compatibility?: "v1"
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
}
@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) {
let permissionRejected = false
let formCancelled = false
let interrupted = false
let v1InvalidOutput = false
let admission: AbortController | undefined
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
if (input.format !== "json") return false
@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const flushStep = () => {
if (!pendingStep) return
const value = pendingStep
pendingStep = undefined
if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") {
UI.empty()
UI.println(value.label)
UI.empty()
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) {
type: "step-start",
snapshot: event.data.snapshot,
}
if (input.compatibility === "v1") {
pendingStep = {
timestamp: time,
part,
label: `> ${event.data.agent} · ${event.data.model.id}`,
}
continue
}
if (!emit("step_start", time, { part }) && input.format !== "json") {
UI.empty()
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
@ -187,6 +209,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.text.started") {
flushStep()
starts.set("text", { id: partID(event.id), timestamp: time })
continue
}
@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.reasoning.started") {
flushStep()
starts.set("reasoning", { id: partID(event.id), timestamp: time })
continue
}
@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.called") {
flushStep()
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(event.data.callID)
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
UI.error(error)
@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.step.ended") {
flushStep()
const part = {
id: partID(event.id),
sessionID: input.sessionID,
@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.step.failed") {
if (
input.compatibility === "v1" &&
event.data.error.message === "Provider stream ended without a terminal finish event"
) {
pendingStep = undefined
v1InvalidOutput = true
continue
}
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.execution.failed") {
if (
input.compatibility === "v1" &&
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
)
return
flushStep()
if (!emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) {
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
@ -478,7 +522,7 @@ async function prepareFile(file: File) {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
return { attachment: { uri, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")

View file

@ -6,10 +6,10 @@ import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { loadRunAgents, waitForCatalogReady } from "../mini/catalog.shared"
import { toolInlineInfo } from "../mini/tool"
import type { MiniToolPart } from "../mini/types"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui"
export type RunCommandInput = {
@ -39,24 +39,39 @@ type Prepared = {
files: FilePart[]
}
type ExecutionOptions = {
root?: string
directory?: string
useServerDirectory?: boolean
variant?: string
attached?: boolean
compatibility?: "v1"
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) {
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
return runNonInteractiveWithOptions(input, {})
}
async function run(input: RunCommandInput) {
/** @internal Used only by the V1 command boundary. */
export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) {
return run(input, options).catch((error) => reportRunError(input, errorMessage(error)))
}
async function run(input: RunCommandInput, options: ExecutionOptions) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = localDirectory(root)
const root = options.root ?? process.env.PWD ?? process.cwd()
const local = localDirectory(root)
const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options)))
const prepared = { directory, message, files }
return execute(input, prepared, input.server.endpoint)
return execute(input, prepared, input.server.endpoint, options)
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
@ -65,7 +80,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
const workspace = session?.location.workspaceID
const explicit = parseRunModel(input.model)
const explicitModel = explicit?.model
const variant = explicit?.variant
const variant = options.variant ?? explicit?.variant
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
!explicitModel && !sessionModel
@ -74,12 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
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 reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent)
const selected =
@ -107,10 +122,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
thinking: input.thinking ?? false,
format: input.format,
auto: input.auto ?? false,
attached: true,
attached: options.attached ?? true,
compatibility: options.compatibility,
renderTool,
renderToolError,
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
@ -185,11 +201,13 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string): Promise<FilePart> {
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
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 (options.compatibility === "v1" && options.attached && 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))
@ -249,7 +267,15 @@ function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
return error.message
return String(error)
}
/** @internal Used by the V1 command boundary before a Session exists. */
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
process.exitCode = 1
if (input.format === "json") {
process.stdout.write(

View file

@ -0,0 +1,85 @@
import type { Endpoint } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../services/standalone"
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
export type V1RunCommandInput = {
message: string[]
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
format: "default" | "json"
file: string[]
title?: string
server?: string
password?: string
username?: string
directory?: string
variant?: string
thinking?: boolean
dangerouslySkipPermissions?: boolean
standaloneCommand?: ReadonlyArray<string>
}
export function runV1Bridge(input: V1RunCommandInput) {
const root = process.env.PWD ?? process.cwd()
const attached = input.server !== undefined
const local = !attached && input.directory ? path.resolve(root, input.directory) : root
try {
process.chdir(local)
} catch {
reportRunError(input, `Failed to change directory to ${local}`)
return Promise.resolve()
}
return Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = attached
? explicitEndpoint(input)
: yield* Standalone.start({ command: input.standaloneCommand })
yield* Effect.promise(() =>
runNonInteractiveWithOptions(nativeInput(input, endpoint), {
root: local,
directory: attached ? input.directory : local,
useServerDirectory: attached && input.directory === undefined,
variant: input.variant,
attached,
compatibility: "v1",
}),
)
}),
),
).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
}
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
return {
server: { endpoint },
message: input.message,
continue: input.continue,
session: input.session,
fork: input.fork,
model: input.model,
agent: input.agent,
format: input.format,
file: input.file,
title: input.title,
thinking: input.thinking,
auto: input.dangerouslySkipPermissions,
}
}
function explicitEndpoint(input: V1RunCommandInput): Endpoint {
const url = input.server
if (!url) throw new Error("Missing V1 server URL")
return {
url,
auth: input.password
? { type: "basic", username: input.username ?? "opencode", password: input.password }
: undefined,
}
}