UNPKG

@deep-assistant/agent

Version:

A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.

360 lines (321 loc) 12 kB
import type { Argv } from "yargs" import path from "path" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { Command } from "../../command" import { EOL } from "os" import { select } from "@clack/prompts" import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk" import { Provider } from "../../provider/provider" const TOOL: Record<string, [string, string]> = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], list: ["List", UI.Style.TEXT_INFO_BOLD], read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], websearch: ["Search", UI.Style.TEXT_DIM_BOLD], } export const RunCommand = cmd({ command: "run [message..]", describe: "run opencode with a message", builder: (yargs: Argv) => { return yargs .positional("message", { describe: "message to send", type: "string", array: true, default: [], }) .option("command", { describe: "the command to run, use message for args", type: "string", }) .option("continue", { alias: ["c"], describe: "continue the last session", type: "boolean", }) .option("session", { alias: ["s"], describe: "session id to continue", type: "string", }) .option("model", { type: "string", alias: ["m"], describe: "model to use in the format of provider/model", }) .option("agent", { type: "string", describe: "agent to use", }) .option("format", { type: "string", choices: ["default", "json"], default: "default", describe: "format: default (formatted) or json (raw JSON events)", }) .option("file", { alias: ["f"], type: "string", array: true, describe: "file(s) to attach to message", }) .option("title", { 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)", }) .option("system-message", { type: "string", describe: "full override of the system message", }) .option("system-message-file", { type: "string", describe: "full override of the system message from file", }) .option("append-system-message", { type: "string", describe: "append to the default system message", }) .option("append-system-message-file", { type: "string", describe: "append to the default system message from file", }) }, handler: async (args) => { let message = args.message.join(" ") const fileParts: any[] = [] if (args.file) { const files = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of files) { const resolvedPath = path.resolve(process.cwd(), filePath) const file = Bun.file(resolvedPath) const stats = await file.stat().catch(() => {}) if (!stats) { UI.error(`File not found: ${filePath}`) process.exit(1) } if (!(await file.exists())) { UI.error(`File not found: ${filePath}`) process.exit(1) } const stat = await file.stat() const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" fileParts.push({ type: "file", url: `file://${resolvedPath}`, filename: path.basename(resolvedPath), mime, }) } } // Read system message files if (args["system-message-file"]) { const resolvedPath = path.resolve(process.cwd(), args["system-message-file"]) const file = Bun.file(resolvedPath) if (!(await file.exists())) { UI.error(`System message file not found: ${args["system-message-file"]}`) process.exit(1) } args["system-message"] = await file.text() } if (args["append-system-message-file"]) { const resolvedPath = path.resolve(process.cwd(), args["append-system-message-file"]) const file = Bun.file(resolvedPath) if (!(await file.exists())) { UI.error(`Append system message file not found: ${args["append-system-message-file"]}`) process.exit(1) } args["append-system-message"] = await file.text() } if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) if (message.trim().length === 0 && !args.command) { UI.error("You must provide a message or a command") process.exit(1) } 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, " ")}`, "", UI.Style.TEXT_NORMAL + title, ) } const outputJsonEvent = (type: string, data: any) => { if (args.format === "json") { process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) return true } return false } const events = await sdk.event.subscribe() let errorMsg: string | undefined 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 }, }) } } })() if (args.command) { await sdk.session.command({ path: { id: sessionID }, body: { agent: args.agent || "build", model: args.model, system: args["system-message"], appendSystem: args["append-system-message"], command: args.command, arguments: message, }, }) } else { const modelParam = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ path: { id: sessionID }, body: { agent: args.agent || "build", model: modelParam, system: args["system-message"], appendSystem: args["append-system-message"], 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) } // Share not supported - removed auto-share logic return await execute(sdk, sessionID) } await bootstrap(process.cwd(), async () => { // Server not supported - this code path should not be reached throw new Error("Server mode not supported in agent-cli") 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) } // Share not supported - removed auto-share logic await execute(sdk, sessionID) server.stop() }) }, })