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
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"
|
||||
531
packages/cli/src/run/noninteractive.ts
Normal file
531
packages/cli/src/run/noninteractive.ts
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { toolOutputText } from "../mini/tool"
|
||||
import type { MiniToolPart } from "../mini/types"
|
||||
import { UI } from "./ui"
|
||||
|
||||
type Model = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type File = {
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Input = {
|
||||
client: OpenCodeClient
|
||||
sessionID: string
|
||||
message: string
|
||||
files: File[]
|
||||
agent?: string
|
||||
model?: Model
|
||||
variant?: string
|
||||
thinking: boolean
|
||||
format: "default" | "json"
|
||||
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>
|
||||
}
|
||||
|
||||
type StartedPart = {
|
||||
id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type ToolState = StartedPart & {
|
||||
assistantMessageID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
raw?: string
|
||||
provider?: unknown
|
||||
}
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
|
||||
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
|
||||
// session. An exclusive local process may treat them as this run's blockers; an
|
||||
// attached client must not cancel input that may belong to another session.
|
||||
const GLOBAL_FORM_SESSION_ID = "global"
|
||||
|
||||
export async function runNonInteractivePrompt(input: Input) {
|
||||
const controller = new AbortController()
|
||||
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
|
||||
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const starts = new Map<string, StartedPart>()
|
||||
const tools = new Map<string, ToolState>()
|
||||
let submitted = false
|
||||
let promoted = false
|
||||
let emittedError = false
|
||||
let questionRejected = false
|
||||
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
|
||||
process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
|
||||
const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
|
||||
if (emit("text", timestamp, { part })) return
|
||||
const text = part.text.trim()
|
||||
if (!text) return
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
return
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
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
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
}
|
||||
await input.client.permission
|
||||
.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: request.id,
|
||||
reply: input.auto ? "once" : "reject",
|
||||
})
|
||||
.catch(() => {})
|
||||
if (!input.auto) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const rejectQuestion = async (request: { id: string }) => {
|
||||
questionRejected = true
|
||||
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
|
||||
formCancelled = true
|
||||
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
const next = await stream.next().catch((error) => {
|
||||
if (!emittedError) throw error
|
||||
return { done: true as const, value: undefined }
|
||||
})
|
||||
if (next.done) {
|
||||
if (emittedError) return
|
||||
throw new Error("Event stream disconnected during prompt execution")
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await rejectQuestion(event.data)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "form.created" &&
|
||||
submitted &&
|
||||
(event.data.form.sessionID === input.sessionID ||
|
||||
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
|
||||
) {
|
||||
await cancelForm(event.data.form)
|
||||
continue
|
||||
}
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
const time = toMillis("created" in event ? event.created : undefined)
|
||||
|
||||
if (event.type === "session.input.promoted") {
|
||||
if (event.data.inputID === messageID) {
|
||||
promoted = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.type === "session.execution.interrupted" &&
|
||||
event.data.reason === "user" &&
|
||||
(interrupted || permissionRejected || questionRejected || formCancelled)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!promoted) continue
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
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}`)
|
||||
UI.empty()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.text.started") {
|
||||
flushStep()
|
||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
const started = starts.get("text")
|
||||
starts.delete("text")
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "text",
|
||||
text: event.data.text,
|
||||
time: { start: started?.timestamp ?? time, end: time },
|
||||
}
|
||||
writeText(part, time)
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.reasoning.started") {
|
||||
flushStep()
|
||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get("reasoning")
|
||||
starts.delete("reasoning")
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "reasoning",
|
||||
text: event.data.text,
|
||||
metadata: event.data.state,
|
||||
time: { start: started?.timestamp ?? time, end: time },
|
||||
}
|
||||
if (emit("reasoning", time, { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(line + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.tool.input.started") {
|
||||
flushStep()
|
||||
tools.set(event.data.callID, {
|
||||
id: partID(event.id),
|
||||
timestamp: time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.name,
|
||||
input: {},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.ended") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (current) current.raw = event.data.text
|
||||
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),
|
||||
timestamp: current?.timestamp ?? time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: current?.tool ?? "tool",
|
||||
input: event.data.input,
|
||||
raw: current?.raw,
|
||||
provider: { executed: event.data.executed, state: event.data.state },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
output: toolOutputText(current.tool, event.data.content),
|
||||
title: current.tool,
|
||||
metadata: {
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
},
|
||||
}
|
||||
tools.delete(event.data.callID)
|
||||
if (!emit("tool_use", time, { part })) await input.renderTool(part)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: current.input,
|
||||
error,
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.step.ended") {
|
||||
flushStep()
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "step-finish",
|
||||
reason: event.data.finish,
|
||||
snapshot: event.data.snapshot,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
}
|
||||
emit("step_finish", time, { part })
|
||||
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
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
}
|
||||
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
|
||||
process.exitCode = 1
|
||||
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
|
||||
if (!emit("error", time, { error })) UI.error(error.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") return
|
||||
}
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
if (interrupted) process.exit(130)
|
||||
interrupted = true
|
||||
process.exitCode = 130
|
||||
admission?.abort()
|
||||
void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
process.on("SIGINT", interrupt)
|
||||
|
||||
let completed: Promise<void> | undefined
|
||||
try {
|
||||
if (input.agent) {
|
||||
await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent })
|
||||
}
|
||||
const selected = input.model
|
||||
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
|
||||
: input.variant
|
||||
? await input.client.session
|
||||
.get({ sessionID: input.sessionID })
|
||||
.then((result) => result.model)
|
||||
.then(async (model) => {
|
||||
if (model) return { ...model, variant: input.variant }
|
||||
const result = await input.client.model.default()
|
||||
const fallback = result.data
|
||||
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
|
||||
})
|
||||
: undefined
|
||||
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected) {
|
||||
await input.client.session.switchModel({ sessionID: input.sessionID, model: selected })
|
||||
}
|
||||
|
||||
const prepared = await Promise.all(input.files.map(prepareFile))
|
||||
if (interrupted) return
|
||||
submitted = true
|
||||
completed = consume()
|
||||
admission = new AbortController()
|
||||
const response = await input.client.session
|
||||
.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: admission.signal },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
if (interrupted) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
controller.abort()
|
||||
await completed?.catch(() => {})
|
||||
if (interrupted || emittedError) return undefined
|
||||
throw error
|
||||
})
|
||||
admission = undefined
|
||||
if (!response) return
|
||||
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
|
||||
const [permissions, questions, forms] = await Promise.all([
|
||||
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
Promise.all(
|
||||
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
|
||||
input.client.form.list({ sessionID }).catch(() => undefined),
|
||||
),
|
||||
),
|
||||
])
|
||||
await Promise.all([
|
||||
...(permissions ?? []).map(replyPermission),
|
||||
...(questions ?? []).map(rejectQuestion),
|
||||
...forms.flatMap((response) => response ?? []).map(cancelForm),
|
||||
])
|
||||
await completed
|
||||
} finally {
|
||||
process.off("SIGINT", interrupt)
|
||||
controller.abort()
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function partID(eventID: string) {
|
||||
return `prt_${eventID.replace(/^evt_/, "")}`
|
||||
}
|
||||
|
||||
function fallbackTool(event: {
|
||||
id: string
|
||||
created: number
|
||||
data: { assistantMessageID: string; callID: string }
|
||||
}): ToolState {
|
||||
return {
|
||||
id: partID(event.id),
|
||||
timestamp: toMillis(event.created),
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
|
||||
function toMillis(value: unknown) {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string") return new Date(value).getTime()
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
async function prepareFile(file: File) {
|
||||
if (file.mime !== "text/plain") {
|
||||
const uri = file.url.startsWith("data:")
|
||||
? file.url
|
||||
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
||||
return { attachment: { uri, name: file.filename } }
|
||||
}
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
: await readFile(new URL(file.url), "utf8")
|
||||
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
296
packages/cli/src/run/run.ts
Normal file
296
packages/cli/src/run/run.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
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 "../mini/catalog.shared"
|
||||
import { toolInlineInfo } from "../mini/tool"
|
||||
import type { MiniToolPart } from "../mini/types"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
server: ServerConnection.Resolved
|
||||
message: string[]
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
model?: string
|
||||
agent?: string
|
||||
format: "default" | "json"
|
||||
file: string[]
|
||||
title?: string
|
||||
thinking?: boolean
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Prepared = {
|
||||
directory?: string
|
||||
message: string
|
||||
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 runNonInteractiveWithOptions(input, {})
|
||||
}
|
||||
|
||||
/** @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 = 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, options)))
|
||||
const prepared = { directory, message, files }
|
||||
return execute(input, prepared, input.server.endpoint, options)
|
||||
}
|
||||
|
||||
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")
|
||||
const session = await selectSession(client, requestedDirectory, input)
|
||||
const cwd = session?.location.directory ?? requestedDirectory
|
||||
const workspace = session?.location.workspaceID
|
||||
const explicit = parseRunModel(input.model)
|
||||
const explicitModel = explicit?.model
|
||||
const variant = options.variant ?? explicit?.variant
|
||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||
const defaultModel =
|
||||
!explicitModel && !sessionModel
|
||||
? await client.model
|
||||
.default({ location: { directory: cwd, workspace } })
|
||||
.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 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 reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||
}
|
||||
const agent = await validateAgent(client, cwd, input.agent)
|
||||
const selected =
|
||||
session ??
|
||||
(await client.session.create({
|
||||
agent,
|
||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
||||
location: { directory: cwd },
|
||||
}))
|
||||
if (!session && input.title !== undefined) {
|
||||
await client.session.rename({
|
||||
sessionID: selected.id,
|
||||
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||
})
|
||||
}
|
||||
|
||||
await runNonInteractivePrompt({
|
||||
client,
|
||||
sessionID: selected.id,
|
||||
message: prepared.message,
|
||||
files: prepared.files,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
thinking: input.thinking ?? false,
|
||||
format: input.format,
|
||||
auto: input.auto ?? false,
|
||||
attached: options.attached ?? true,
|
||||
compatibility: options.compatibility,
|
||||
renderTool,
|
||||
renderToolError,
|
||||
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
|
||||
}
|
||||
|
||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||
if (!message) return piped || undefined
|
||||
if (!piped) return message
|
||||
return message + "\n" + piped
|
||||
}
|
||||
|
||||
export function pickRunModel(
|
||||
explicit: { providerID: string; modelID: string } | undefined,
|
||||
variant: string | undefined,
|
||||
session: { providerID: string; modelID: string } | undefined,
|
||||
fallback: { providerID: string; modelID: string } | undefined,
|
||||
) {
|
||||
if (explicit) return explicit
|
||||
if (!variant) return
|
||||
return session ?? fallback
|
||||
}
|
||||
|
||||
function formatMessage(message: string[]) {
|
||||
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function localDirectory(root: string) {
|
||||
try {
|
||||
process.chdir(root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
fail(`Failed to change directory to ${root}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRunModel(value?: string) {
|
||||
if (!value) return
|
||||
const ref = Model.Ref.parse(value)
|
||||
return {
|
||||
model: { providerID: ref.providerID, modelID: ref.id },
|
||||
variant: ref.variant,
|
||||
}
|
||||
}
|
||||
|
||||
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. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
const agent = agents.find((item) => item.id === name)
|
||||
if (!agent) {
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
|
||||
const selected = input.session
|
||||
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
|
||||
: input.continue
|
||||
? await client.session
|
||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
||||
.then((result) => result.data[0])
|
||||
: undefined
|
||||
if (input.session && !selected) fail("Session not found")
|
||||
if (!selected || !input.fork) return selected
|
||||
return client.session.fork({ sessionID: selected.id })
|
||||
}
|
||||
|
||||
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))
|
||||
let offset = 0
|
||||
while (offset < content.length) {
|
||||
const read = await handle.read(content, offset, content.length - offset, offset)
|
||||
if (read.bytesRead === 0) break
|
||||
offset += read.bytesRead
|
||||
}
|
||||
const bytes = content.subarray(0, offset)
|
||||
const detected = FSUtil.mimeType(file)
|
||||
const text = bytes.toString("utf8")
|
||||
const mime =
|
||||
detected.startsWith("image/") || detected === "application/pdf"
|
||||
? detected
|
||||
: !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
|
||||
? "text/plain"
|
||||
: detected
|
||||
return {
|
||||
url: `data:${mime};base64,${bytes.toString("base64")}`,
|
||||
filename: path.basename(file),
|
||||
mime,
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isBinaryContent(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return false
|
||||
if (bytes.includes(0)) return true
|
||||
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||
}
|
||||
|
||||
async function renderTool(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
if (info.mode === "block") {
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
|
||||
if (info.body?.trim()) UI.println(info.body)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL + info.icon,
|
||||
UI.Style.TEXT_NORMAL + info.title,
|
||||
info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "",
|
||||
)
|
||||
}
|
||||
|
||||
async function renderToolError(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
timestamp: Date.now(),
|
||||
sessionID: sessionID ?? "",
|
||||
error: { type: "unknown", message },
|
||||
}) + "\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
UI.error(message)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message)
|
||||
}
|
||||
27
packages/cli/src/run/ui.ts
Normal file
27
packages/cli/src/run/ui.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { EOL } from "node:os"
|
||||
|
||||
export const Style = {
|
||||
TEXT_DIM: "\x1b[90m",
|
||||
TEXT_NORMAL: "\x1b[0m",
|
||||
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
|
||||
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
|
||||
}
|
||||
|
||||
export function println(...message: string[]) {
|
||||
process.stderr.write(message.join(" ") + EOL)
|
||||
}
|
||||
|
||||
let blank = false
|
||||
|
||||
export function empty() {
|
||||
if (blank) return
|
||||
println(Style.TEXT_NORMAL)
|
||||
blank = true
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) message = message.slice("Error: ".length)
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export * as UI from "./ui"
|
||||
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