cli: extract run from mini package (#37737)
This commit is contained in:
parent
cf6e5b3604
commit
3f5ad8441f
15 changed files with 500 additions and 50 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,8 +1 @@
|
|||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
parseRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
|
|
|
|||
2
packages/cli/src/run/index.ts
Normal file
2
packages/cli/src/run/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { runNonInteractive, type RunCommandInput } from "./run"
|
||||
export { runV1Bridge, type V1RunCommandInput } from "./v1"
|
||||
|
|
@ -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")
|
||||
|
|
@ -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(
|
||||
85
packages/cli/src/run/v1.ts
Normal file
85
packages/cli/src/run/v1.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue