diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a979f8ba..39b0a55f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,21 +1,14 @@ import type { Argv } from "yargs" import path from "path" -import { Bus } from "../../bus" -import { Provider } from "../../provider/provider" -import { Session } from "../../session" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" -import { Config } from "../../config/config" import { bootstrap } from "../bootstrap" -import { MessageV2 } from "../../session/message-v2" -import { Identifier } from "../../id/id" -import { Agent } from "../../agent/agent" import { Command } from "../../command" -import { SessionPrompt } from "../../session/prompt" import { EOL } from "os" -import { Permission } from "@/permission" import { select } from "@clack/prompts" +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk" +import { Server } from "../../server/server" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -84,11 +77,19 @@ export const RunCommand = cmd({ type: "string", describe: "title for the session (uses truncated prompt if no value provided)", }) + .option("attach", { + type: "string", + describe: "attach to a running opencode server (e.g., http://localhost:4096)", + }) + .option("port", { + type: "number", + describe: "port for the local server (defaults to random port if no value provided)", + }) }, handler: async (args) => { let message = args.message.join(" ") - let fileParts: any[] = [] + const fileParts: any[] = [] if (args.file) { const files = Array.isArray(args.file) ? args.file : [args.file] @@ -124,79 +125,8 @@ export const RunCommand = cmd({ process.exit(1) } - await bootstrap(process.cwd(), async () => { - if (args.command) { - const exists = await Command.get(args.command) - if (!exists) { - UI.error(`Command "${args.command}" not found`) - process.exit(1) - } - } - const session = await (async () => { - if (args.continue) { - const it = Session.list() - try { - for await (const s of it) { - if (s.parentID === undefined) { - return s - } - } - return - } finally { - await it.return() - } - } - - if (args.session) return Session.get(args.session) - - const title = (() => { - if (args.title !== undefined) { - if (args.title === "") { - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - return args.title - } - return undefined - })() - - return Session.create({ - title, - }) - })() - - if (!session) { - UI.error("Session not found") - process.exit(1) - } - - const cfg = await Config.get() - if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) { - try { - await Session.share(session.id) - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8)) - } catch (error) { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } else { - throw error - } - } - } - - const agent = await (async () => { - if (args.agent) return Agent.get(args.agent) - const build = Agent.get("build") - if (build) return build - return Agent.list().then((x) => x[0]) - })() - - const { providerID, modelID } = await (async () => { - if (args.model) return Provider.parseModel(args.model) - if (agent.model) return agent.model - return await Provider.defaultModel() - })() - - function printEvent(color: string, type: string, title: string) { + const execute = async (sdk: OpencodeClient, sessionID: string) => { + const printEvent = (color: string, type: string, title: string) => { UI.println( color + `|`, UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, @@ -205,135 +135,218 @@ export const RunCommand = cmd({ ) } - function outputJsonEvent(type: string, data: any) { + const outputJsonEvent = (type: string, data: any) => { if (args.format === "json") { - const jsonEvent = { - type, - timestamp: Date.now(), - sessionID: session?.id, - ...data, - } - process.stdout.write(JSON.stringify(jsonEvent) + EOL) + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) return true } return false } - const messageID = Identifier.ascending("message") - - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.part.sessionID !== session.id) return - if (evt.properties.part.messageID === messageID) return - const part = evt.properties.part - - if (part.type === "tool" && part.state.status === "completed") { - if (outputJsonEvent("tool_use", { part })) return - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 - ? JSON.stringify(part.state.input) - : "Unknown") - - printEvent(color, tool, title) - - if (part.tool === "bash" && part.state.output && part.state.output.trim()) { - UI.println() - UI.println(part.state.output) - } - } - - if (part.type === "step-start") { - if (outputJsonEvent("step_start", { part })) return - } - - if (part.type === "step-finish") { - if (outputJsonEvent("step_finish", { part })) return - } - - if (part.type === "text") { - const text = part.text - const isPiped = !process.stdout.isTTY - - if (part.time?.end) { - if (outputJsonEvent("text", { part })) return - if (!isPiped) UI.println() - process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL) - if (!isPiped) UI.println() - } - } - }) - + const events = await sdk.event.subscribe() let errorMsg: string | undefined - Bus.subscribe(Session.Event.Error, async (evt) => { - const { sessionID, error } = evt.properties - if (sessionID !== session.id || !error) return - let err = String(error.name) - if ("data" in error && error.data && "message" in error.data) { - err = error.data.message + const eventProcessor = (async () => { + for await (const event of events.stream) { + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && part.state.status === "completed") { + if (outputJsonEvent("tool_use", { part })) continue + const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] + const title = + part.state.title || + (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") + printEvent(color, tool, title) + if (part.tool === "bash" && part.state.output?.trim()) { + UI.println() + UI.println(part.state.output) + } + } + + if (part.type === "step-start") { + if (outputJsonEvent("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (outputJsonEvent("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (outputJsonEvent("text", { part })) continue + const isPiped = !process.stdout.isTTY + if (!isPiped) UI.println() + process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) + if (!isPiped) UI.println() + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + errorMsg = errorMsg ? errorMsg + EOL + err : err + if (outputJsonEvent("error", { error: props.error })) continue + UI.error(err) + } + + if (event.type === "session.idle" && event.properties.sessionID === sessionID) { + break + } + + if (event.type === "permission.updated") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue + const result = await select({ + message: `Permission required to run: ${permission.title}`, + options: [ + { value: "once", label: "Allow once" }, + { value: "always", label: "Always allow" }, + { value: "reject", label: "Reject" }, + ], + initialValue: "once", + }).catch(() => "reject") + const response = (result.toString().includes("cancel") ? "reject" : result) as + | "once" + | "always" + | "reject" + await sdk.postSessionIdPermissionsPermissionId({ + path: { id: sessionID, permissionID: permission.id }, + body: { response }, + }) + } } - errorMsg = errorMsg ? errorMsg + EOL + err : err + })() - if (outputJsonEvent("error", { error })) return - UI.error(err) - }) - - Bus.subscribe(Permission.Event.Updated, async (evt) => { - const permission = evt.properties - const message = `Permission required to run: ${permission.title}` - - const result = await select({ - message, - options: [ - { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow" }, - { value: "reject", label: "Reject" }, - ], - initialValue: "once", - }).catch(() => "reject") - const response = (result.toString().includes("cancel") ? "reject" : result) as - | "once" - | "always" - | "reject" - - Permission.respond({ - sessionID: session.id, - permissionID: permission.id, - response, - }) - }) - - await (async () => { - if (args.command) { - return await SessionPrompt.command({ - messageID, - sessionID: session.id, - agent: agent.name, - model: providerID + "/" + modelID, + if (args.command) { + await sdk.session.command({ + path: { id: sessionID }, + body: { + agent: args.agent || "build", + model: args.model, command: args.command, arguments: message, - }) - } - return await SessionPrompt.prompt({ - sessionID: session.id, - messageID, - model: { - providerID, - modelID, }, - agent: agent.name, - parts: [ - ...fileParts, - { - id: Identifier.ascending("part"), - type: "text", - text: message, - }, - ], }) - })() + } else { + const modelParam = args.model + ? (() => { + const [providerID, modelID] = args.model.split("/") + return { providerID, modelID } + })() + : undefined + await sdk.session.prompt({ + path: { id: sessionID }, + body: { + agent: args.agent || "build", + model: modelParam, + parts: [...fileParts, { type: "text", text: message }], + }, + }) + } + + await eventProcessor if (errorMsg) process.exit(1) + } + + if (args.attach) { + const sdk = createOpencodeClient({ baseUrl: args.attach }) + + const sessionID = await (async () => { + if (args.continue) { + const result = await sdk.session.list() + return result.data?.find((s) => !s.parentID)?.id + } + if (args.session) return args.session + + const title = + args.title !== undefined + ? args.title === "" + ? message.slice(0, 50) + (message.length > 50 ? "..." : "") + : args.title + : undefined + + const result = await sdk.session.create({ body: title ? { title } : {} }) + return result.data?.id + })() + + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } + + const cfgResult = await sdk.config.get() + if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { + const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!shareResult.error) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + } + } + + return await execute(sdk, sessionID) + } + + await bootstrap(process.cwd(), async () => { + const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" }) + const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` }) + + if (args.command) { + const exists = await Command.get(args.command) + if (!exists) { + server.stop() + UI.error(`Command "${args.command}" not found`) + process.exit(1) + } + } + + const sessionID = await (async () => { + if (args.continue) { + const result = await sdk.session.list() + return result.data?.find((s) => !s.parentID)?.id + } + if (args.session) return args.session + + const title = + args.title !== undefined + ? args.title === "" + ? message.slice(0, 50) + (message.length > 50 ? "..." : "") + : args.title + : undefined + + const result = await sdk.session.create({ body: title ? { title } : {} }) + return result.data?.id + })() + + if (!sessionID) { + server.stop() + UI.error("Session not found") + process.exit(1) + } + + const cfgResult = await sdk.config.get() + if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { + const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!shareResult.error) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + } + } + + await execute(sdk, sessionID) + server.stop() }) }, }) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 1312442d..083db369 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -184,6 +184,16 @@ This is useful for scripting, automation, or when you want a quick answer withou opencode run Explain the use of context in Go ``` +You can also attach to a running `opencode serve` instance to avoid MCP server cold boot times on every run: + +```bash +# Start a headless server in one terminal +opencode serve + +# In another terminal, run commands that attach to it +opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" +``` + #### Flags | Flag | Short | Description | @@ -197,6 +207,8 @@ opencode run Explain the use of context in Go | `--file` | `-f` | File(s) to attach to message | | `--format` | | Format: default (formatted) or json (raw JSON events) | | `--title` | | Title for the session (uses truncated prompt if no value provided) | +| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) | +| `--port` | | Port for the local server (defaults to random port) | ---