Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"./mini/footer.view": "./src/mini/footer.view.tsx",
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
"./mini/*": "./src/mini/*.ts",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/mini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 0 additions & 7 deletions packages/cli/src/mini/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/cli/src/run/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { runNonInteractive, type RunCommandInput } from "./run"
export { runV1Bridge, type V1RunCommandInput } from "./v1"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}`)
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
60 changes: 43 additions & 17 deletions packages/cli/src/mini/run.ts → packages/cli/src/run/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down
File renamed without changes.
Loading
Loading