summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-02-02 23:35:23 -0500
committerGitHub <[email protected]>2026-02-02 23:35:23 -0500
commit1275c71a63c2ef34f46734b0802dd34435fcb5f6 (patch)
tree924aa37a3a41b6c96c3b8ff72093c249e75d8cd7
parentca8c23dd717e36880d736a1df726880d38dd4366 (diff)
downloadopencode-1275c71a63c2ef34f46734b0802dd34435fcb5f6.tar.gz
opencode-1275c71a63c2ef34f46734b0802dd34435fcb5f6.zip
cli: make run non-interactive (#11814)
-rw-r--r--.opencode/opencode.jsonc7
-rw-r--r--packages/opencode/package.json1
-rw-r--r--packages/opencode/src/cli/cmd/run.ts546
-rw-r--r--packages/opencode/src/tool/registry.ts2
4 files changed, 368 insertions, 188 deletions
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index c3f0b7070..52fd00432 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -9,12 +9,7 @@
"options": {},
},
},
- "mcp": {
- "context7": {
- "type": "remote",
- "url": "https://mcp.context7.com/mcp",
- },
- },
+ "mcp": {},
"tools": {
"github-triage": false,
"github-pr-search": false,
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index a993494a5..08eaae49d 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -20,6 +20,7 @@
"bin": {
"opencode": "./bin/opencode"
},
+ "randomField": "this-is-a-random-value-12345",
"exports": {
"./*": "./src/*.ts"
},
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 54248f96f..59d0e01a7 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -4,25 +4,211 @@ 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/v2"
+import { createOpencodeClient, type Message, 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"
+import { Locale } from "../../util/locale"
+
+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()) return
+ 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 = Locale.titlecase(info.input.subagent_type)
+ const desc = info.input.description
+ const started = info.part.state.status === "running"
+ const name = desc ?? `${agent} Task`
+ inline({
+ icon: started ? "•" : "✓",
+ title: name,
+ description: desc ? `${agent} Agent` : undefined,
+ })
+}
+
+function skill(info: ToolProps<typeof SkillTool>) {
+ inline({
+ icon: "→",
+ title: `Skill "${info.input.name}"`,
+ })
+}
-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],
+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 +283,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 +303,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 +319,75 @@ 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: "*",
+ },
+ {
+ permission: "plan_enter",
+ action: "deny",
+ pattern: "*",
+ },
+ {
+ permission: "plan_exit",
+ 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({ title: name, permission: rules })
+ return result.data?.id
+ }
- const outputJsonEvent = (type: string, data: any) => {
+ 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)
+ }
+
+ 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 +396,77 @@ export const RunCommand = cmd({
}
const events = await sdk.event.subscribe()
- let errorMsg: string | undefined
+ let error: string | undefined
+
+ async function loop() {
+ const toggles = new Map<string, boolean>()
- const eventProcessor = (async () => {
for await (const event of events.stream) {
+ if (
+ event.type === "message.updated" &&
+ event.properties.info.role === "assistant" &&
+ args.format !== "json" &&
+ toggles.get("start") !== true
+ ) {
+ UI.empty()
+ UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
+ UI.empty()
+ toggles.set("start", true)
+ }
+
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 === "tool" &&
+ part.tool === "task" &&
+ part.state.status === "running" &&
+ args.format !== "json"
+ ) {
+ if (toggles.get(part.id) === true) continue
+ task(props<typeof TaskTool>(part))
+ toggles.set(part.id, true)
}
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
+ const text = part.text.trim()
+ if (!text) continue
+ if (!process.stdout.isTTY) {
+ process.stdout.write(text + EOL)
+ continue
+ }
+ UI.empty()
+ UI.println(text)
+ UI.empty()
+ }
+
+ if (part.type === "reasoning" && part.time?.end) {
+ if (emit("reasoning", { part })) continue
+ const text = part.text.trim()
+ if (!text) continue
+ const line = `Thinking: ${text}`
+ if (process.stdout.isTTY) {
+ UI.empty()
+ UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
+ UI.empty()
+ continue
+ }
+ process.stdout.write(line + EOL)
}
}
@@ -197,42 +477,40 @@ 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
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
- const result = await select({
- message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
- options: [
- { value: "once", label: "Allow once" },
- { value: "always", label: "Always allow: " + permission.always.join(", ") },
- { value: "reject", label: "Reject" },
- ],
- initialValue: "once",
- }).catch(() => "reject")
- const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
- await sdk.permission.respond({
- sessionID,
- permissionID: permission.id,
- response,
+ UI.println(
+ UI.Style.TEXT_WARNING_BOLD + "!",
+ UI.Style.TEXT_NORMAL +
+ `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
+ )
+ 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,
@@ -240,7 +518,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,
@@ -251,91 +529,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 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: [
- {
- permission: "question",
- action: "deny",
- pattern: "*",
- },
- ],
- }
- : {
- permission: [
- {
- permission: "question",
- action: "deny",
- pattern: "*",
- },
- ],
- },
- )
- 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 () => {
@@ -344,52 +573,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
-
- 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 } : {})
- 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)
})
},
})
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 7b3a45889..3cb771594 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -110,7 +110,7 @@ export namespace ToolRegistry {
TaskTool,
WebFetchTool,
TodoWriteTool,
- TodoReadTool,
+ // TodoReadTool,
WebSearchTool,
CodeSearchTool,
SkillTool,