Command Source Code Analysis
Module Overview
The Command module is OpenCode’s configuration-driven command system. Unlike the Go version’s command dialog architecture (CommandDialog, MultiArgumentsDialog, etc., spanning 6 files), the TS version simplifies commands into just 3 files. The core idea: commands are not executable functions, but configuration items with templates.
Commands come from four sources: two built-in commands (init, review), Config custom commands, MCP Prompts, and Skills. Command execution is consumed by SessionPrompt.command() — which performs template variable substitution and injects the result into the Session as a user message.
Key Files
| File | Responsibility |
|---|---|
src/command/index.ts | Module main file: Command.Info Schema, command discovery and merging, get / list query interfaces |
src/command/template/initialize.txt | init command template: instructs the Agent to analyze the codebase and generate AGENTS.md |
src/command/template/review.txt | review command template: instructs the Agent to perform a code review |
src/session/prompt.ts | SessionPrompt.command() function: template variable substitution, subtask dispatch, Session injection |
Type System
Command.Info Schema
The core data model for commands is defined using a Zod Schema:
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({ ref: "Command" })
export type Info = Omit<z.infer<typeof Info>, "template"> & {
template: Promise<string> | string
}
| Field | Type | Description |
|---|---|---|
name | string | Unique command identifier |
description | string? | Description of the command’s function |
agent | string? | Specifies the executing Agent (overrides default) |
model | string? | Specifies the model (overrides default) |
source | "command" | "mcp" | "skill"? | Command source tag |
template | Promise<string> | string | Command template, supports async loading |
subtask | boolean? | Whether to execute as a subtask (independent Agent instance) |
hints | string[] | Parameter placeholder list, e.g. ["$1", "$ARGUMENTS"] |
template field design: Uses a getter instead of a plain string because MCP prompts require async retrieval. Zod has a type inference bug with z.promise().or(z.string()), so the type is manually overridden with Omit + intersection type.
hints() helper function
Extracts parameter placeholders from template text — $1, $2… deduplicated and sorted, with $ARGUMENTS appended at the end:
export function hints(template: string): string[] {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
for (const match of [...new Set(numbered)].sort()) result.push(match)
}
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
return result
}
Command.Event
Event published after command execution, carrying the command name, Session ID, arguments, and message ID:
export const Event = {
Executed: BusEvent.define("command.executed", z.object({
name: z.string(),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: MessageID.zod,
})),
}
Command Discovery and Registration
InstanceState Lazy Initialization
The command list is managed via Instance.state() — initialized once per project instance, with the cache reused thereafter. get() and list() wait for initialization to complete before reading:
const state = Instance.state(async () => {
const cfg = await Config.get()
const result: Record<string, Info> = { /* ... */ }
// Four-phase merge ...
return result
})
export async function get(name: string) {
return state().then((x) => x[name])
}
export async function list() {
return state().then((x) => Object.values(x))
}
Four-Phase Merge Logic
Phase 1: Built-in Commands
Registers init (create/update AGENTS.md) and review (code review). Templates use a getter to dynamically replace ${path} with Instance.worktree:
const result: Record<string, Info> = {
[Default.INIT]: {
name: "init",
description: "create/update AGENTS.md",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
Phase 2: Config Custom Commands
Iterates over the command field in the configuration file, mapping each entry to a Command.Info. Config commands can override built-in commands of the same name.
Phase 3: MCP Prompts
MCP prompt templates require async retrieval. A getter cannot be async, so a Promise is returned manually. MCP prompt argument names are mapped positionally to $1, $2, …:
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return new Promise(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client, prompt.name,
prompt.arguments
? Object.fromEntries(
prompt.arguments.map((arg, i) => [arg.name, `$${i + 1}`])
)
: {},
).catch(reject)
resolve(
template?.messages
.map((m) => (m.content.type === "text" ? m.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
Phase 4: Skills
Skills have the lowest priority. If a command with the same name already exists, it is skipped (if (result[skill.name]) continue), unlike Config/MCP which do override.
Priority Summary:
| Priority | Source | Override Rule |
|---|---|---|
| 1 (highest) | Built-in commands (init, review) | — |
| 2 | Config custom commands | Can override built-in commands |
| 3 | MCP Prompts | Can override Config commands |
| 4 (lowest) | Skills | Skipped if same name exists; does not override any existing command |
This four-layer priority design ensures:
- Built-in commands can always be customized and overridden via Config
- MCP-provided commands can override user configuration (useful when teams share an MCP Server)
- Skills, as the lowest priority, never accidentally override existing commands
MCP Prompt Argument Mapping
MCP prompt argument mapping is automatic — the prompt’s arguments definition (name + description) in the MCP protocol is mapped to positional parameters:
prompt.arguments
? Object.fromEntries(
prompt.arguments.map((arg, i) => [arg.name, `$${i + 1}`])
)
: {}
For example, an MCP prompt defined with arguments: [{ name: "language" }, { name: "code" }] would be mapped to { language: "$1", code: "$2" }. When a user invokes the command, arguments are filled positionally: the first argument goes to $1, the second to $2`.
The template itself is retrieved asynchronously — MCP.getPrompt() returns a Promise, the getter returns this Promise, and consumers handle it uniformly via await command.template.
Built-in Commands in Detail
INIT — Project Initialization
The template instructs the Agent to analyze the codebase and generate AGENTS.md, including build commands, code style, error handling conventions, etc. It uses ${path} to specify the output path and $ARGUMENTS to allow users to append custom instructions. The template is approximately 15 lines and aims to produce a project memory file of about 150 lines.
REVIEW — Code Review
The template is a structured code review prompt (approximately 150 lines) that automatically selects the review scope based on the argument type:
- No argument →
git diffto review uncommitted changes - Commit hash →
git showto review that commit - Branch name →
git diff branch...HEAD - PR URL/number →
gh pr diffto review the PR
Review priorities are ranked: Bugs > Structure > Performance > Behavior Changes. The template emphasizes “don’t flag if unsure”, “only review changed parts”, and “don’t over-focus on style”.
review sets subtask: true, executing in an independent sub-Agent without affecting the main session context.
Command Execution: SessionPrompt.command()
The Command module is only responsible for command discovery and querying; the execution logic lives in SessionPrompt.command().
Call Chain
User selects command + enters arguments
→ SessionPrompt.command(input)
→ Command.get(input.command) // Get command definition
→ Template variable substitution ($1, $2, $ARGUMENTS)
→ Shell command substitution (!`cmd` syntax)
→ resolvePromptParts(template) // Parse @file references
→ Determine subtask mode
├─ subtask=true → Inject SubtaskPart → TaskTool execution
└─ subtask=false → Inject user message → SessionPrompt.loop() → Agent execution
→ Publish Command.Event.Executed
Template Variable Substitution
// 1. Parse user arguments (supports quotes and [Image N] markers)
const args = (input.arguments.match(argsRegex) ?? [])
.map((arg) => arg.replace(quoteTrimRegex, ""))
// 2. Positional argument substitution: $1→args[0], $2→args[1]...
// The last placeholder absorbs remaining arguments
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
// 3. $ARGUMENTS replaced with the full argument text
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
// 4. Fallback: no placeholders but arguments present → append to template end
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
}
Last placeholder absorption behavior: If a template has $1 $2 $3 and the user provides 5 arguments, $3 absorbs arguments 3 through 5. This makes user input feel more natural.
The template also supports !`command` syntax to execute Shell commands and inline the results.
resolvePromptParts — File Reference Resolution
@file references in templates are resolved by resolvePromptParts(). The resolution strategy has three layers of fallback:
- File system path: First attempts to resolve the reference as a file path, reading and replacing with file contents
- Agent name: If the file does not exist, attempts to resolve the reference as an Agent name, fetching that Agent’s configuration
- Home directory: Supports paths starting with
~/, automatically expanding to the user’s home directory
// Simplified resolution logic
for (const ref of references) {
if (existsSync(ref)) → readFileContent(ref)
else if (isAgentName(ref)) → getAgentInfo(ref)
else if (ref.startsWith("~/")) → readFileContent(expandHome(ref))
}
This allows templates to reference project files (@src/main.ts) or Agent configurations (@explore), dynamically resolving them to actual content at execution time.
Shell Command Substitution (!cmd syntax)
Templates support inline Shell commands, matched and executed via ConfigMarkdown.shell():
// Match !`cmd` pattern
const result = ConfigMarkdown.shell(template)
// For each match:
// Process.text(cmd, { nothrow: true }) executes the command
// Replaces !`cmd` with the command output
nothrow: true ensures that even if the Shell command fails, template processing is not interrupted — failed command output includes error information, but the template continues parsing. This allows dynamic information (such as the current git branch, date, environment variables) to be injected into the template at command execution time.
Subtask Dispatch
const isSubtask =
(agent.mode === "subagent" && command.subtask !== false) ||
command.subtask === true
- Subtask mode: Creates a
SubtaskPart, executed by TaskTool in an independent Agent - Normal mode: Template text is injected directly into the Session as a user message
Structured Output Special Handling
When a command requires the LLM to output in a structured format (e.g., JSON Schema), the Session execution loop injects special Structured Output handling:
- Inject StructuredOutput tool: Adds a hidden JSON output tool to the tool list
- Inject system prompt: Instructs the model to use this tool for structured data output
- Force tool call: Sets
toolChoice: "required"to ensure the model must call the StructuredOutput tool - Error handling: If the model responds with plain text instead of calling the StructuredOutput tool, throws
StructuredOutputError
// Simplified logic
if (needsStructuredOutput) {
tools["structured_output"] = structuredOutputTool(schema)
params.toolChoice = "required" // Force tool call
// If LLM doesn't call the tool → StructuredOutputError
}
This design disguises Structured Output as a tool call, leveraging the LLM’s existing tool-calling capability to guarantee output format reliability.
Design Tradeoffs
Why are commands configurations rather than executable functions?
The Go version’s Handler is func(cmd Command) tea.Cmd, capable of executing arbitrary logic. The TS version simplifies this to configuration + template:
- Security: Templates are just text and don’t execute code, eliminating arbitrary code execution risks
- Serializability: Pure data configurations can be validated by JSON Schema and transmitted over the MCP protocol
- Unified interface: Config commands, MCP prompts, and Skills have different structures but are all reduced to
Command.Info
Why template substitution instead of callbacks?
- Cross-protocol compatibility: MCP prompt argument mapping uses
$Ndirectly, requiring no additional abstraction layer - Predictability: Users can understand parameter positions by reading the raw template
- Lazy binding: The getter design allows MCP prompts to be loaded asynchronously; consumers don’t need to worry about sync/async
Why does the MCP prompt use Promise instead of async getter?
JavaScript getters cannot be async (they must return a value synchronously). new Promise(async ...) is a workaround — the getter synchronously returns a Promise, and consumers handle it uniformly via await command.template.
Relationships with Other Modules
Command Module
├── Config → Reads cfg.command custom command configuration
├── MCP → MCP.prompts() discovers MCP prompt commands
├── Skill → Skill.all() discovers skill commands
├── Instance → Instance.state() manages command list lifecycle
├── BusEvent → Command.Event.Executed event definition
└── Session/Schema → SessionID, MessageID type dependencies
Depended on by:
├── SessionPrompt → command() function consumes Command.get() to execute commands
└── TUI / API → Command.list() displays available command list