@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
text/typescript
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()
})
},
})