cli: refactor run command

This commit is contained in:
Dax Raad
2026-02-02 12:37:52 -05:00
parent 390f90ef47
commit 666ffa2832
2 changed files with 305 additions and 164 deletions

View File

@@ -20,6 +20,7 @@
"bin": {
"opencode": "./bin/opencode"
},
"randomField": "this-is-a-random-value-12345",
"exports": {
"./*": "./src/*.ts"
},

View File

@@ -4,25 +4,207 @@ 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 { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
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],
type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
metadata: Tool.InferMetadata<T>
part: ToolPart
}
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
input: state.input as Tool.InferParameters<T>,
metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
}
}
type Inline = {
icon: string
title: string
description?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
}
function block(info: Inline, output?: string) {
UI.empty()
inline(info)
if (output?.trim()) UI.println(output)
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
function glob(info: ToolProps<typeof GlobTool>) {
const root = info.input.path ?? ""
const title = `Glob "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.count
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function grep(info: ToolProps<typeof GrepTool>) {
const root = info.input.path ?? ""
const title = `Grep "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.matches
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function list(info: ToolProps<typeof ListTool>) {
const dir = info.input.path ? normalizePath(info.input.path) : ""
inline({
icon: "→",
title: dir ? `List ${dir}` : "List",
})
}
function read(info: ToolProps<typeof ReadTool>) {
const file = normalizePath(info.input.filePath)
const pairs = Object.entries(info.input).filter(([key, value]) => {
if (key === "filePath") return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
inline({
icon: "→",
title: `Read ${file}`,
...(description && { description }),
})
}
function write(info: ToolProps<typeof WriteTool>) {
block(
{
icon: "←",
title: `Write ${normalizePath(info.input.filePath)}`,
},
info.part.state.status === "completed" ? info.part.state.output : undefined,
)
}
function webfetch(info: ToolProps<typeof WebFetchTool>) {
inline({
icon: "%",
title: `WebFetch ${info.input.url}`,
})
}
function edit(info: ToolProps<typeof EditTool>) {
const title = normalizePath(info.input.filePath)
const diff = info.metadata.diff
block(
{
icon: "←",
title: `Edit ${title}`,
},
diff,
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
title: `Exa Web Search "${info.input.query}"`,
})
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = info.input.subagent_type
const desc = info.input.description
inline({
icon: "#",
title: `${agent} Task`,
...(desc && { description: desc }),
})
}
function skill(info: ToolProps<typeof SkillTool>) {
inline({
icon: "→",
title: `Skill "${info.input.name}"`,
})
}
function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}
function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
icon: "#",
title: "Todos",
},
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
}
export const RunCommand = cmd({
@@ -97,11 +279,11 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const fileParts: any[] = []
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file]
const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of files) {
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {})
@@ -117,7 +299,7 @@ export const RunCommand = cmd({
const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
fileParts.push({
files.push({
type: "file",
url: `file://${resolvedPath}`,
filename: path.basename(resolvedPath),
@@ -133,17 +315,65 @@ export const RunCommand = cmd({
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 rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const name = title()
const result = await sdk.session.create(name ? { title: name, permission: rules } : { permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
}
const outputJsonEvent = (type: string, data: any) => {
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
@@ -152,41 +382,36 @@ export const RunCommand = cmd({
}
const events = await sdk.event.subscribe()
let errorMsg: string | undefined
let error: string | undefined
const eventProcessor = (async () => {
async function loop() {
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 (emit("tool_use", { part })) continue
tool(part)
}
if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) continue
if (emit("step_start", { part })) continue
}
if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) continue
if (emit("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 (emit("text", { part })) continue
if (!process.stdout.isTTY) {
process.stdout.write(part.text + EOL)
continue
}
UI.empty()
UI.println(part.text)
UI.empty()
}
}
@@ -197,12 +422,16 @@ export const RunCommand = cmd({
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
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
UI.error(err)
}
if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break
}
@@ -214,21 +443,19 @@ export const RunCommand = cmd({
UI.Style.TEXT_NORMAL,
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
const response = "reject" as const
await sdk.permission.respond({
sessionID,
permissionID: permission.id,
response,
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
}
}
})()
}
// Validate agent if specified
const resolvedAgent = await (async () => {
const agent = await (async () => {
if (!args.agent) return undefined
const agent = await Agent.get(args.agent)
if (!agent) {
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -236,7 +463,7 @@ export const RunCommand = cmd({
)
return undefined
}
if (agent.mode === "subagent") {
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -247,77 +474,42 @@ export const RunCommand = cmd({
return args.agent
})()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
sessionID,
agent: resolvedAgent,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent: resolvedAgent,
model: modelParam,
agent,
model,
variant: args.variant,
parts: [...fileParts, { type: "text", text: message }],
parts: [...files, { type: "text", text: message }],
})
}
await eventProcessor
if (errorMsg) process.exit(1)
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach })
const rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
]
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(title ? { title, permission: rules } : { permission: rules })
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({ 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 && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
return await execute(sdk, sessionID)
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
@@ -326,59 +518,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
]
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
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(title ? { title, permission: rules } : { permission: rules })
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({ 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 && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
await execute(sdk, sessionID)
await execute(sdk)
})
},
})