cli: extract run from mini package (#37737)
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
||||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
||||||
"./mini/*": "./src/mini/*.ts",
|
"./mini/*": "./src/mini/*.ts",
|
||||||
|
"./run": "./src/run/index.ts",
|
||||||
"./server-process": "./src/server-process.ts"
|
"./server-process": "./src/server-process.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
|||||||
|
|
||||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
Effect.gen(function* () {
|
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())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
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) =>
|
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("../../run/run"))
|
||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
|||||||
@@ -1,8 +1 @@
|
|||||||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||||
export {
|
|
||||||
runNonInteractive,
|
|
||||||
mergeInput as mergeNonInteractiveInput,
|
|
||||||
pickRunModel,
|
|
||||||
parseRunModel,
|
|
||||||
type RunCommandInput,
|
|
||||||
} from "./run"
|
|
||||||
|
|||||||
@@ -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 { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { readFile } from "node:fs/promises"
|
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 { UI } from "./ui"
|
||||||
import type { MiniToolPart } from "./types"
|
|
||||||
|
|
||||||
type Model = {
|
type Model = {
|
||||||
providerID: string
|
providerID: string
|
||||||
@@ -30,6 +30,7 @@ type Input = {
|
|||||||
auto: boolean
|
auto: boolean
|
||||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||||
attached: boolean
|
attached: boolean
|
||||||
|
compatibility?: "v1"
|
||||||
renderTool: (part: MiniToolPart) => Promise<void>
|
renderTool: (part: MiniToolPart) => Promise<void>
|
||||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
renderToolError: (part: MiniToolPart) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
let permissionRejected = false
|
let permissionRejected = false
|
||||||
let formCancelled = false
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
|
let v1InvalidOutput = false
|
||||||
let admission: AbortController | undefined
|
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>) => {
|
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
|
||||||
if (input.format !== "json") return false
|
if (input.format !== "json") return false
|
||||||
@@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
UI.empty()
|
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> }) => {
|
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||||
if (!input.auto) {
|
if (!input.auto) {
|
||||||
permissionRejected = true
|
permissionRejected = true
|
||||||
@@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
type: "step-start",
|
type: "step-start",
|
||||||
snapshot: event.data.snapshot,
|
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") {
|
if (!emit("step_start", time, { part }) && input.format !== "json") {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
|
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") {
|
if (event.type === "session.text.started") {
|
||||||
|
flushStep()
|
||||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
|
flushStep()
|
||||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
|
flushStep()
|
||||||
tools.set(event.data.callID, {
|
tools.set(event.data.callID, {
|
||||||
id: partID(event.id),
|
id: partID(event.id),
|
||||||
timestamp: time,
|
timestamp: time,
|
||||||
@@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
|
flushStep()
|
||||||
const current = tools.get(event.data.callID)
|
const current = tools.get(event.data.callID)
|
||||||
tools.set(event.data.callID, {
|
tools.set(event.data.callID, {
|
||||||
id: current?.id ?? partID(event.id),
|
id: current?.id ?? partID(event.id),
|
||||||
@@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(event.data.callID)
|
tools.delete(event.data.callID)
|
||||||
|
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
await input.renderToolError(part)
|
await input.renderToolError(part)
|
||||||
UI.error(error)
|
UI.error(error)
|
||||||
@@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.step.ended") {
|
if (event.type === "session.step.ended") {
|
||||||
|
flushStep()
|
||||||
const part = {
|
const part = {
|
||||||
id: partID(event.id),
|
id: partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.step.failed") {
|
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
|
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
||||||
|
flushStep()
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
|
if (
|
||||||
|
input.compatibility === "v1" &&
|
||||||
|
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
flushStep()
|
||||||
if (!emittedError && !questionRejected && !formCancelled) {
|
if (!emittedError && !questionRejected && !formCancelled) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
@@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.interrupted") {
|
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" && interrupted) process.exitCode = 130
|
||||||
if (event.data.reason !== "user" && !emittedError) {
|
if (event.data.reason !== "user" && !emittedError) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
@@ -478,7 +522,7 @@ async function prepareFile(file: File) {
|
|||||||
const uri = file.url.startsWith("data:")
|
const uri = file.url.startsWith("data:")
|
||||||
? file.url
|
? file.url
|
||||||
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
: `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:")
|
const content = file.url.startsWith("data:")
|
||||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
? 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 path from "node:path"
|
||||||
import { readStdin } from "../util/io"
|
import { readStdin } from "../util/io"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
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 { runNonInteractivePrompt } from "./noninteractive"
|
||||||
import { toolInlineInfo } from "./tool"
|
|
||||||
import type { MiniToolPart } from "./types"
|
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
export type RunCommandInput = {
|
export type RunCommandInput = {
|
||||||
@@ -39,24 +39,39 @@ type Prepared = {
|
|||||||
files: FilePart[]
|
files: FilePart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecutionOptions = {
|
||||||
|
root?: string
|
||||||
|
directory?: string
|
||||||
|
useServerDirectory?: boolean
|
||||||
|
variant?: string
|
||||||
|
attached?: boolean
|
||||||
|
compatibility?: "v1"
|
||||||
|
}
|
||||||
|
|
||||||
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
export function runNonInteractive(input: RunCommandInput) {
|
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")
|
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||||
const root = process.env.PWD ?? process.cwd()
|
const root = options.root ?? process.env.PWD ?? process.cwd()
|
||||||
const directory = localDirectory(root)
|
const local = localDirectory(root)
|
||||||
|
const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
|
||||||
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
|
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
|
||||||
if (!message?.trim()) fail("You must provide a message")
|
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 }
|
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 client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||||
if (!requestedDirectory) fail("Failed to resolve server 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 workspace = session?.location.workspaceID
|
||||||
const explicit = parseRunModel(input.model)
|
const explicit = parseRunModel(input.model)
|
||||||
const explicitModel = explicit?.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 sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||||
const defaultModel =
|
const defaultModel =
|
||||||
!explicitModel && !sessionModel
|
!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))
|
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||||
: undefined
|
: undefined
|
||||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
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) {
|
if (model) {
|
||||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||||
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 reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||||
}
|
}
|
||||||
const agent = await validateAgent(client, cwd, input.agent)
|
const agent = await validateAgent(client, cwd, input.agent)
|
||||||
const selected =
|
const selected =
|
||||||
@@ -107,10 +122,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
thinking: input.thinking ?? false,
|
thinking: input.thinking ?? false,
|
||||||
format: input.format,
|
format: input.format,
|
||||||
auto: input.auto ?? false,
|
auto: input.auto ?? false,
|
||||||
attached: true,
|
attached: options.attached ?? true,
|
||||||
|
compatibility: options.compatibility,
|
||||||
renderTool,
|
renderTool,
|
||||||
renderToolError,
|
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) {
|
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 })
|
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 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 (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)
|
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))
|
||||||
@@ -249,7 +267,15 @@ function warning(message: string) {
|
|||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
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
|
process.exitCode = 1
|
||||||
if (input.format === "json") {
|
if (input.format === "json") {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineScript } from "opencode-drive"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
export default defineScript({
|
||||||
|
launch: "manual",
|
||||||
|
setup({ config }) {
|
||||||
|
config.autoupdate = false
|
||||||
|
},
|
||||||
|
async run({ artifacts, llm, server }) {
|
||||||
|
await configureServicePort(artifacts)
|
||||||
|
llm.queue(llm.text("drive noninteractive smoke ok"))
|
||||||
|
await server.launch()
|
||||||
|
|
||||||
|
const registration = await serviceRegistration(artifacts)
|
||||||
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
|
const directory = path.join(artifacts, "files")
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
path.join(root, "packages/cli/src/index.ts"),
|
||||||
|
"run",
|
||||||
|
"--server",
|
||||||
|
registration.url,
|
||||||
|
"drive smoke",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: path.join(root, "packages/cli"),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PWD: directory,
|
||||||
|
OPENCODE_PASSWORD: registration.password,
|
||||||
|
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
|
||||||
|
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function configureServicePort(artifacts) {
|
||||||
|
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||||
|
const port = probe.port
|
||||||
|
await probe.stop(true)
|
||||||
|
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||||
|
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||||
|
await mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, JSON.stringify({ port }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function serviceRegistration(artifacts) {
|
||||||
|
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||||
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
|
for (const name of ["service-local.json", "service.json"]) {
|
||||||
|
const value = await Bun.file(path.join(directory, name))
|
||||||
|
.json()
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (isRegistration(value)) return value
|
||||||
|
}
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("Drive service registration was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} value */
|
||||||
|
function isRegistration(value) {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"url" in value &&
|
||||||
|
typeof value.url === "string" &&
|
||||||
|
"password" in value &&
|
||||||
|
typeof value.password === "string"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dir, "..")
|
||||||
|
|
||||||
|
describe("CLI frontend import boundaries", () => {
|
||||||
|
test("exposes only the run entrypoints from the run package export", async () => {
|
||||||
|
const entrypoint = await import("@opencode-ai/cli/run")
|
||||||
|
const mini = await import("@opencode-ai/cli/mini")
|
||||||
|
|
||||||
|
expect(Object.keys(entrypoint).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||||
|
expect(Object.keys(mini).sort()).toEqual(["mergeInteractiveInput", "runMini", "validateMiniTerminal"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps run and Mini handlers on separate leaf graphs", async () => {
|
||||||
|
const run = await bundleInputs("src/commands/handlers/run.ts")
|
||||||
|
expect(run).toContain("src/run/run.ts")
|
||||||
|
expect(run).not.toContain("src/mini/mini.ts")
|
||||||
|
expect(run).not.toContain("src/mini/runtime.ts")
|
||||||
|
|
||||||
|
const mini = await bundleInputs("src/commands/handlers/mini.ts")
|
||||||
|
expect(mini).toContain("src/mini/mini.ts")
|
||||||
|
expect(mini).not.toContain("src/run/run.ts")
|
||||||
|
expect(mini).not.toContain("src/run/noninteractive.ts")
|
||||||
|
expect(mini).not.toContain("src/run/ui.ts")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function bundleInputs(entrypoint: string) {
|
||||||
|
const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
|
||||||
|
const metafile = path.join(temporary, "meta.json")
|
||||||
|
try {
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
"build",
|
||||||
|
entrypoint,
|
||||||
|
"--target=bun",
|
||||||
|
"--format=esm",
|
||||||
|
"--packages=external",
|
||||||
|
`--metafile=${metafile}`,
|
||||||
|
`--outdir=${path.join(temporary, "out")}`,
|
||||||
|
],
|
||||||
|
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||||
|
)
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||||
|
const metadata = await Bun.file(metafile).json()
|
||||||
|
return Object.keys(metadata.inputs).map((input) =>
|
||||||
|
path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await rm(temporary, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
|
import { mergeInput as mergeInteractiveInput } from "../src/mini/mini"
|
||||||
import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
|
import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
|
||||||
|
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||||
|
|
||||||
async function cli(args: string[]) {
|
async function cli(args: string[]) {
|
||||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||||
@@ -59,7 +60,7 @@ describe("mini command", () => {
|
|||||||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps run as mini's non-interactive input mode", () => {
|
test("merges non-interactive argument and stdin input", () => {
|
||||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||||
})
|
})
|
||||||
@@ -128,14 +129,7 @@ describe("mini command", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await cli([
|
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||||
"run",
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
"--model",
|
|
||||||
"definitely/missing",
|
|
||||||
"hi",
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1)
|
expect(result.exitCode).toBe(1)
|
||||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||||
|
|||||||
+128
-3
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
||||||
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||||
@@ -50,9 +50,57 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepStarted(): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_step_started",
|
||||||
|
created: 1,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", id: "test-model" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepFailed(message: string): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_step_failed",
|
||||||
|
created: 2,
|
||||||
|
type: "session.step.failed",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
error: { type: "provider.transport", message },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionFailed(message: string): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_execution_failed",
|
||||||
|
created: 3,
|
||||||
|
type: "session.execution.failed",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
error: { type: "provider.transport", message },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||||
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
|
async function run(input: {
|
||||||
|
turn: (inputID: string) => V2Event[]
|
||||||
|
pendingForms?: FormInfo[]
|
||||||
|
attached?: boolean
|
||||||
|
format?: "default" | "json"
|
||||||
|
compatibility?: "v1"
|
||||||
|
}) {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
@@ -88,15 +136,38 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
|
|||||||
message: "hello",
|
message: "hello",
|
||||||
files: [],
|
files: [],
|
||||||
thinking: false,
|
thinking: false,
|
||||||
format: "default",
|
format: input.format ?? "default",
|
||||||
auto: false,
|
auto: false,
|
||||||
attached: input.attached ?? false,
|
attached: input.attached ?? false,
|
||||||
|
compatibility: input.compatibility,
|
||||||
renderTool: () => Promise.resolve(),
|
renderTool: () => Promise.resolve(),
|
||||||
renderToolError: () => Promise.resolve(),
|
renderToolError: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function capture(input: Parameters<typeof run>[0]) {
|
||||||
|
const stdout: string[] = []
|
||||||
|
const stderr: string[] = []
|
||||||
|
const exitCode = process.exitCode
|
||||||
|
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||||
|
stdout.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||||
|
stderr.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await run(input)
|
||||||
|
return { stdout: stdout.join(""), stderr: stderr.join("") }
|
||||||
|
} finally {
|
||||||
|
process.exitCode = exitCode ?? 0
|
||||||
|
stdoutWrite.mockRestore()
|
||||||
|
stderrWrite.mockRestore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
@@ -125,4 +196,58 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [
|
||||||
|
prompted(messageID),
|
||||||
|
stepStarted(),
|
||||||
|
stepFailed("Provider request failed"),
|
||||||
|
executionFailed("Provider request failed"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "error",
|
||||||
|
error: { type: "provider.transport", message: "Provider request failed" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(output.stderr).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output.stdout).toBe("")
|
||||||
|
expect(output.stderr).toContain("> build · test-model")
|
||||||
|
expect(output.stderr).toContain("Error: \u001b[0mExecution failed")
|
||||||
|
expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("V1 preserves terminal-finish failure suppression before content", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [
|
||||||
|
prompted(messageID),
|
||||||
|
stepStarted(),
|
||||||
|
stepFailed("Provider stream ended without a terminal finish event"),
|
||||||
|
executionFailed("Provider stream ended without a terminal finish event"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -99,9 +99,9 @@ export const RunCommand = effectCmd({
|
|||||||
default: false,
|
default: false,
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.run")(function* (args) {
|
handler: Effect.fn("Cli.run")(function* (args) {
|
||||||
const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini"))
|
const { runV1Bridge } = yield* Effect.promise(() => import("@opencode-ai/cli/run"))
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runNonInteractive({
|
runV1Bridge({
|
||||||
message: [...args.message, ...(args["--"] || [])],
|
message: [...args.message, ...(args["--"] || [])],
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
session: args.session,
|
session: args.session,
|
||||||
@@ -112,7 +112,6 @@ 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,
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
// an isolated test provider config under the fixture's temp home.
|
// an isolated test provider config under the fixture's temp home.
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import path from "node:path"
|
||||||
import { reply } from "../../lib/llm-server"
|
import { reply } from "../../lib/llm-server"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
import { testProviderConfig } from "../../lib/test-provider"
|
import { testProviderConfig } from "../../lib/test-provider"
|
||||||
|
|
||||||
|
const opencodeRoot = path.resolve(import.meta.dir, "../../..")
|
||||||
|
|
||||||
describe("opencode run (non-interactive subprocess)", () => {
|
describe("opencode run (non-interactive subprocess)", () => {
|
||||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||||
// If this fails, all the others likely will too — debug here first.
|
// If this fails, all the others likely will too — debug here first.
|
||||||
@@ -367,9 +370,12 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* llm.text("variant response")
|
yield* llm.text("variant response")
|
||||||
const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
|
const result = yield* opencode.spawn(
|
||||||
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
["run", "--model", "test/test-model", "--variant", "default", "use the model"],
|
||||||
})
|
{
|
||||||
|
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
opencode.expectExit(result, 0)
|
opencode.expectExit(result, 0)
|
||||||
expect(result.stdout).toBe("variant response\n")
|
expect(result.stdout).toBe("variant response\n")
|
||||||
@@ -382,7 +388,15 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ home, llm, opencode }) =>
|
({ home, llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = `${home}/image.png`
|
const source = `${home}/image.png`
|
||||||
yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64")))
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
source,
|
||||||
|
Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
yield* llm.text("attachment received")
|
yield* llm.text("attachment received")
|
||||||
const config = testProviderConfig(llm.url)
|
const config = testProviderConfig(llm.url)
|
||||||
config.provider.test.models["test-model"].attachment = true
|
config.provider.test.models["test-model"].attachment = true
|
||||||
@@ -395,7 +409,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
opencode.expectExit(result, 0)
|
opencode.expectExit(result, 0)
|
||||||
const input = JSON.stringify(yield* llm.inputs)
|
const input = JSON.stringify(yield* llm.inputs)
|
||||||
expect(input).toContain("image/png")
|
expect(input).toContain("image/png")
|
||||||
expect(input).not.toContain("<file name=\\\"image.png\\\">")
|
expect(input).not.toContain('<file name=\\"image.png\\">')
|
||||||
}),
|
}),
|
||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
@@ -422,6 +436,26 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cliIt.live(
|
||||||
|
"attach mode without --dir uses the remote server location",
|
||||||
|
({ home, llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(home).not.toBe(opencodeRoot)
|
||||||
|
yield* llm.text("remote location used")
|
||||||
|
const server = yield* opencode.serve()
|
||||||
|
|
||||||
|
const result = yield* opencode.run("use the server location", {
|
||||||
|
extraArgs: ["--attach", server.url],
|
||||||
|
})
|
||||||
|
|
||||||
|
opencode.expectExit(result, 0)
|
||||||
|
const input = JSON.stringify(yield* llm.inputs)
|
||||||
|
expect(input).toContain(`Working directory: ${opencodeRoot}`)
|
||||||
|
expect(input).not.toContain(`Working directory: ${home}`)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
cliIt.concurrent(
|
cliIt.concurrent(
|
||||||
"attach mode rejects local directories before prompt admission",
|
"attach mode rejects local directories before prompt admission",
|
||||||
({ home, opencode }) =>
|
({ home, opencode }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user