Skip to main content

agent_commander/tools/
opencode.rs

1//! OpenCode CLI tool configuration
2//! Based on hive-mind's opencode.lib.mjs implementation
3
4use crate::streaming::parse_ndjson;
5use crate::tools::shell::{build_command_head, escape_arg, escape_single_quotes};
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// OpenCode permission policy used for read-only planning mode.
10pub const READ_ONLY_PERMISSION: &str = r#"{"edit":"deny","bash":"deny","task":"deny"}"#;
11
12/// Get the OpenCode model map
13pub fn get_model_map() -> HashMap<&'static str, &'static str> {
14    let mut map = HashMap::new();
15    map.insert("gpt4", "openai/gpt-4");
16    map.insert("gpt4o", "openai/gpt-4o");
17    map.insert("claude", "anthropic/claude-3-5-sonnet");
18    map.insert("sonnet", "anthropic/claude-3-5-sonnet");
19    map.insert("opus", "anthropic/claude-3-opus");
20    map.insert("gemini", "google/gemini-pro");
21    map.insert("grok", "opencode/grok-code");
22    map.insert("grok-code", "opencode/grok-code");
23    map.insert("grok-code-fast-1", "opencode/grok-code");
24    map
25}
26
27/// Map model alias to full model ID
28///
29/// # Arguments
30/// * `model` - Model alias or full ID
31///
32/// # Returns
33/// Full model ID
34pub fn map_model_to_id(model: &str) -> String {
35    let model_map = get_model_map();
36    model_map
37        .get(model)
38        .map(|s| s.to_string())
39        .unwrap_or_else(|| model.to_string())
40}
41
42/// OpenCode command build options
43#[derive(Debug, Clone, Default)]
44pub struct OpencodeBuildOptions {
45    pub prompt: Option<String>,
46    pub prompt_file: Option<String>,
47    pub system_prompt: Option<String>,
48    pub model: Option<String>,
49    pub json: bool,
50    pub resume: Option<String>,
51    pub read_only: bool,
52    pub executable: Option<String>,
53    pub extra_env: Vec<(String, String)>,
54    pub extra_args: Vec<String>,
55}
56
57/// Build command line arguments for OpenCode
58///
59/// # Arguments
60/// * `options` - Build options
61///
62/// # Returns
63/// Vector of CLI arguments
64pub fn build_args(options: &OpencodeBuildOptions) -> Vec<String> {
65    let mut args = vec!["run".to_string()];
66
67    if let Some(ref model) = options.model {
68        let mapped_model = map_model_to_id(model);
69        args.push("--model".to_string());
70        args.push(mapped_model);
71    }
72
73    // Default to json=true like JavaScript version
74    if options.json {
75        args.push("--format".to_string());
76        args.push("json".to_string());
77    }
78
79    if let Some(ref resume) = options.resume {
80        args.push("--resume".to_string());
81        args.push(resume.clone());
82    }
83
84    args.extend(options.extra_args.clone());
85
86    args
87}
88
89/// Build complete command string for OpenCode
90/// OpenCode uses stdin for prompt input
91///
92/// # Arguments
93/// * `options` - Build options
94///
95/// # Returns
96/// Complete command string
97pub fn build_command(options: &OpencodeBuildOptions) -> String {
98    let args = build_args(options);
99    let args_str: Vec<String> = args.iter().map(|a| escape_arg(a)).collect();
100
101    // OpenCode expects prompt via stdin, combine system and user prompts
102    let combined_prompt = match (&options.system_prompt, &options.prompt) {
103        (Some(sys), Some(prompt)) => format!("{}\n\n{}", sys, prompt),
104        (Some(sys), None) => sys.clone(),
105        (None, Some(prompt)) => prompt.clone(),
106        (None, None) => String::new(),
107    };
108
109    // Build command with stdin piping
110    let input_command = options.prompt_file.as_ref().map_or_else(
111        || format!("printf '%s' '{}'", escape_single_quotes(&combined_prompt)),
112        |prompt_file| format!("cat {}", escape_arg(prompt_file)),
113    );
114    let executable = options.executable.as_deref().unwrap_or("opencode");
115    let mut extra_env = Vec::new();
116    if options.read_only {
117        extra_env.push((
118            "OPENCODE_PERMISSION".to_string(),
119            READ_ONLY_PERMISSION.to_string(),
120        ));
121    }
122    extra_env.extend(options.extra_env.clone());
123
124    format!(
125        "{} | {} {}",
126        input_command,
127        build_command_head(executable, &extra_env, &[]),
128        args_str.join(" ")
129    )
130    .trim()
131    .to_string()
132}
133
134/// Parse JSON messages from OpenCode output
135/// OpenCode outputs NDJSON format
136///
137/// # Arguments
138/// * `output` - Raw output string
139///
140/// # Returns
141/// Vector of parsed JSON messages
142pub fn parse_output(output: &str) -> Vec<Value> {
143    parse_ndjson(output)
144}
145
146/// Extract session ID from OpenCode output
147///
148/// # Arguments
149/// * `output` - Raw output string
150///
151/// # Returns
152/// Session ID or None
153pub fn extract_session_id(output: &str) -> Option<String> {
154    let messages = parse_output(output);
155
156    for msg in messages {
157        if let Some(session_id) = msg.get("session_id").and_then(|v| v.as_str()) {
158            return Some(session_id.to_string());
159        }
160    }
161
162    None
163}
164
165/// Usage statistics
166#[derive(Debug, Clone, Default)]
167pub struct OpencodeUsage {
168    pub input_tokens: u64,
169    pub output_tokens: u64,
170}
171
172/// Extract usage statistics from OpenCode output
173///
174/// # Arguments
175/// * `output` - Raw output string
176///
177/// # Returns
178/// Usage statistics
179pub fn extract_usage(output: &str) -> OpencodeUsage {
180    let messages = parse_output(output);
181    let mut usage = OpencodeUsage::default();
182
183    for msg in messages {
184        if let Some(msg_usage) = msg.get("usage") {
185            if let Some(input) = msg_usage.get("input_tokens").and_then(|v| v.as_u64()) {
186                usage.input_tokens += input;
187            }
188            if let Some(output) = msg_usage.get("output_tokens").and_then(|v| v.as_u64()) {
189                usage.output_tokens += output;
190            }
191        }
192    }
193
194    usage
195}
196
197/// OpenCode tool configuration
198#[derive(Debug, Clone)]
199pub struct OpencodeTool {
200    pub name: &'static str,
201    pub display_name: &'static str,
202    pub executable: &'static str,
203    pub supports_json_output: bool,
204    pub supports_json_input: bool,
205    pub supports_system_prompt: bool,
206    pub supports_resume: bool,
207    pub supports_read_only: bool,
208    pub supports_ask: bool,
209    pub default_model: &'static str,
210}
211
212impl Default for OpencodeTool {
213    fn default() -> Self {
214        Self {
215            name: "opencode",
216            display_name: "OpenCode CLI",
217            executable: "opencode",
218            supports_json_output: true,
219            supports_json_input: true, // OpenCode can accept JSON input via stdin
220            supports_system_prompt: false, // System prompt is combined with user prompt
221            supports_resume: true,
222            supports_read_only: true, // Supports OPENCODE_PERMISSION
223            supports_ask: false, // OPENCODE_PERMISSION is env-based policy, not a relayable JSON approval stream
224            default_model: "grok-code-fast-1",
225        }
226    }
227}
228
229// Tests are in rust/tests/opencode_tests.rs