sqlite_graphrag/commands/
opencode_runner.rs1use crate::errors::AppError;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use tokio::process::Command;
11
12const DEFAULT_OPENCODE_TIMEOUT_SECS: u64 = 300;
14
15const MIN_OPENCODE_VERSION: (u64, u64, u64) = (1, 17, 0);
17
18pub fn find_opencode_binary_with_override(explicit: Option<&Path>) -> Result<PathBuf, AppError> {
22 if let Some(p) = explicit {
23 if p.exists() {
24 return Ok(p.to_path_buf());
25 }
26 return Err(AppError::Validation(format!(
27 "opencode binary not found at explicit path: {}",
28 p.display()
29 )));
30 }
31 if let Some(path) = crate::runtime_config::opencode_binary() {
32 let p = PathBuf::from(path);
33 if p.exists() {
34 return Ok(p);
35 }
36 tracing::warn!(
37 target: "opencode_runner",
38 path = %p.display(),
39 "llm.opencode_binary is set but file does not exist; falling back to PATH"
40 );
41 }
42 which::which("opencode").map_err(|_| {
43 AppError::Validation(
44 "`opencode` not found on PATH. Install opencode (>= 1.17) or set \
45 via `config set llm.opencode_binary <path>` or `--opencode-binary`."
46 .into(),
47 )
48 })
49}
50
51pub fn find_opencode_binary() -> Result<PathBuf, AppError> {
52 find_opencode_binary_with_override(None)
53}
54
55pub fn resolve_opencode_model(model_override: Option<&str>) -> String {
65 if let Some(m) = model_override {
66 return m.to_string();
67 }
68 crate::runtime_config::resolve_string(None, "llm.opencode_model", "opencode/big-pickle")
69}
70
71pub fn resolve_opencode_timeout(timeout_override: Option<u64>) -> u64 {
75 if let Some(t) = timeout_override {
76 return t;
77 }
78 crate::runtime_config::resolve_u64(None, "llm.opencode_timeout", DEFAULT_OPENCODE_TIMEOUT_SECS)
79}
80
81pub fn validate_opencode_version(binary: &Path) -> Result<(u64, u64, u64), AppError> {
83 let output = std::process::Command::new(binary)
84 .arg("--version")
85 .output()
86 .map_err(|e| AppError::Validation(format!("failed to run opencode --version: {e}")))?;
87
88 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
89 let raw = if raw.is_empty() {
90 String::from_utf8_lossy(&output.stderr).trim().to_string()
91 } else {
92 raw
93 };
94
95 parse_version(&raw).and_then(|v| {
96 if v >= MIN_OPENCODE_VERSION {
97 Ok(v)
98 } else {
99 Err(AppError::Validation(format!(
100 "opencode version {}.{}.{} is below minimum {}.{}.{}",
101 v.0,
102 v.1,
103 v.2,
104 MIN_OPENCODE_VERSION.0,
105 MIN_OPENCODE_VERSION.1,
106 MIN_OPENCODE_VERSION.2,
107 )))
108 }
109 })
110}
111
112fn parse_version(raw: &str) -> Result<(u64, u64, u64), AppError> {
113 let digits: String = raw
115 .chars()
116 .filter(|c| c.is_ascii_digit() || *c == '.')
117 .collect();
118 let parts: Vec<&str> = digits.split('.').collect();
119 if parts.len() >= 3 {
120 if let (Ok(major), Ok(minor), Ok(patch)) = (
121 parts[0].parse::<u64>(),
122 parts[1].parse::<u64>(),
123 parts[2].parse::<u64>(),
124 ) {
125 return Ok((major, minor, patch));
126 }
127 }
128 Err(AppError::Validation(format!(
129 "could not parse opencode version from: {raw}"
130 )))
131}
132
133pub fn propagate_opencode_env(cmd: &mut Command) {
141 const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
142 const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
143 for (key, val) in std::env::vars() {
144 if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
145 cmd.env(&key, &val);
146 }
147 }
148}
149
150pub fn build_opencode_command(
155 binary: &Path,
156 model: &str,
157 prompt: &str,
158) -> Result<Command, AppError> {
159 let mut cmd = Command::new(binary);
160 cmd.arg("run")
161 .arg("--format")
162 .arg("json")
163 .arg("-m")
164 .arg(model)
165 .arg("--dangerously-skip-permissions")
166 .arg(prompt)
167 .env_clear()
168 .env("PATH", std::env::var("PATH").unwrap_or_default())
169 .env("HOME", std::env::var("HOME").unwrap_or_default())
170 .stdin(Stdio::null())
171 .stdout(Stdio::piped())
172 .stderr(Stdio::piped())
173 .kill_on_drop(true);
174 propagate_opencode_env(&mut cmd);
175 crate::spawn::apply_cwd_isolation_tokio(&mut cmd)?;
176 Ok(cmd)
177}
178
179pub fn parse_opencode_output(stdout: &str) -> Result<(String, f64, u64), AppError> {
188 let mut texts: Vec<String> = Vec::new();
189 let mut cost: f64 = 0.0;
190 let mut tokens: u64 = 0;
191
192 for line in stdout.lines() {
193 let trimmed = line.trim();
194 if trimmed.is_empty() {
195 continue;
196 }
197 let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) else {
198 continue;
199 };
200 let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
201 match event_type {
202 "text" => {
203 if let Some(text) = event
204 .get("part")
205 .and_then(|p| p.get("text"))
206 .and_then(|t| t.as_str())
207 {
208 texts.push(text.to_string());
209 }
210 }
211 "step_finish" => {
212 if let Some(part) = event.get("part") {
213 if let Some(c) = part.get("cost").and_then(|c| c.as_f64()) {
214 cost = c;
215 }
216 if let Some(t) = part
217 .get("tokens")
218 .and_then(|t| t.get("total"))
219 .and_then(|t| t.as_u64())
220 {
221 tokens = t;
222 }
223 }
224 }
225 _ => {}
226 }
227 }
228
229 if texts.is_empty() {
230 return Err(AppError::Embedding(
231 "opencode returned no text events in NDJSON output".to_string(),
232 ));
233 }
234
235 Ok((texts.concat(), cost, tokens))
236}
237
238pub fn parse_json_from_opencode_text<T: serde::de::DeserializeOwned>(
246 text: &str,
247) -> Result<T, String> {
248 if let Ok(parsed) = serde_json::from_str::<T>(text) {
250 return Ok(parsed);
251 }
252
253 if let Some(start) = text.find("```json") {
255 let after_fence = &text[start + 7..];
256 if let Some(end) = after_fence.find("```") {
257 let json_str = after_fence[..end].trim();
258 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
259 return Ok(parsed);
260 }
261 }
262 }
263 if let Some(start) = text.find("```") {
264 let after_fence = &text[start + 3..];
265 if let Some(end) = after_fence.find("```") {
266 let json_str = after_fence[..end].trim();
267 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
268 return Ok(parsed);
269 }
270 }
271 }
272
273 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
275 if start < end {
276 let json_str = &text[start..=end];
277 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
278 return Ok(parsed);
279 }
280 }
281 }
282
283 Err(format!(
284 "could not extract valid JSON from opencode response: {}",
285 &text[..text.len().min(200)]
286 ))
287}
288
289pub async fn call_opencode<T: serde::de::DeserializeOwned>(
294 binary: &Path,
295 model: &str,
296 prompt: &str,
297 timeout_secs: u64,
298) -> Result<(T, f64, u64), AppError> {
299 let mut cmd = build_opencode_command(binary, model, prompt)?;
300 let timeout = std::time::Duration::from_secs(timeout_secs);
301
302 let output = match tokio::time::timeout(timeout, cmd.output()).await {
303 Err(_elapsed) => {
304 return Err(AppError::Embedding(format!(
305 "opencode timed out after {timeout_secs}s"
306 )));
307 }
308 Ok(Err(e)) => {
309 return Err(AppError::Embedding(format!(
310 "failed to spawn opencode: {e}"
311 )));
312 }
313 Ok(Ok(o)) => o,
314 };
315
316 if !output.status.success() {
317 let stderr = String::from_utf8_lossy(&output.stderr);
318 let stdout = String::from_utf8_lossy(&output.stdout);
319 return Err(AppError::Embedding(format!(
320 "opencode exited with {}: stderr={}, stdout={}",
321 output.status,
322 &stderr[..stderr.len().min(500)],
323 &stdout[..stdout.len().min(500)],
324 )));
325 }
326
327 let stdout_str = String::from_utf8_lossy(&output.stdout);
328 let (text, _cost, _tokens) = parse_opencode_output(&stdout_str)?;
329 let parsed: T = parse_json_from_opencode_text(&text)
330 .map_err(|e| AppError::Embedding(format!("opencode JSON parse failed: {e}")))?;
331
332 Ok((parsed, _cost, _tokens))
333}
334
335pub fn propagate_opencode_env_sync(cmd: &mut std::process::Command) {
339 const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
340 const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
341 for (key, val) in std::env::vars() {
342 if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
343 cmd.env(&key, &val);
344 }
345 }
346}
347
348pub fn build_opencode_command_sync(
353 binary: &Path,
354 model: &str,
355 prompt: &str,
356 input_text: &str,
357) -> Result<std::process::Command, AppError> {
358 let full_prompt = if input_text.is_empty() {
359 prompt.to_string()
360 } else {
361 format!("{prompt}\n\n{input_text}")
362 };
363 let mut cmd = std::process::Command::new(binary);
364 cmd.arg("run")
365 .arg("--format")
366 .arg("json")
367 .arg("-m")
368 .arg(model)
369 .arg("--dangerously-skip-permissions")
370 .arg(&full_prompt)
371 .env_clear()
372 .env("PATH", std::env::var("PATH").unwrap_or_default())
373 .env("HOME", std::env::var("HOME").unwrap_or_default())
374 .stdin(std::process::Stdio::null())
375 .stdout(std::process::Stdio::piped())
376 .stderr(std::process::Stdio::piped());
377 propagate_opencode_env_sync(&mut cmd);
378 crate::spawn::apply_cwd_isolation(&mut cmd)?;
379 Ok(cmd)
380}
381
382#[cfg(target_os = "linux")]
386pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
387 use std::os::unix::process::CommandExt;
388 unsafe {
389 cmd.pre_exec(|| {
390 let sid = libc::setsid();
391 if sid == -1 {
392 let err = std::io::Error::last_os_error();
393 if err.raw_os_error() != Some(libc::EPERM) {
394 return Err(err);
395 }
396 }
397 Ok(())
398 });
399 }
400 cmd.spawn()
401}
402
403#[cfg(not(target_os = "linux"))]
404pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
405 #[cfg(unix)]
406 {
407 use std::os::unix::process::CommandExt;
408 unsafe {
409 cmd.pre_exec(|| {
410 let _ = libc::setsid();
411 Ok(())
412 });
413 }
414 }
415 cmd.spawn()
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn parse_version_valid() {
424 assert_eq!(parse_version("1.17.7").unwrap(), (1, 17, 7));
425 assert_eq!(parse_version("2.0.0").unwrap(), (2, 0, 0));
426 }
427
428 #[test]
429 fn parse_version_with_prefix() {
430 assert_eq!(parse_version("v1.17.7").unwrap(), (1, 17, 7));
431 assert_eq!(parse_version("opencode 1.17.7").unwrap(), (1, 17, 7));
432 }
433
434 #[test]
435 fn parse_version_invalid() {
436 assert!(parse_version("unknown").is_err());
437 assert!(parse_version("").is_err());
438 }
439
440 #[test]
441 fn validate_version_rejects_old() {
442 let v = parse_version("1.16.0").unwrap();
444 assert!(v < MIN_OPENCODE_VERSION);
445 }
446
447 #[test]
448 fn validate_version_accepts_minimum() {
449 let v = parse_version("1.17.0").unwrap();
450 assert!(v >= MIN_OPENCODE_VERSION);
451 }
452
453 #[test]
454 fn resolve_model_uses_default() {
455 let model = resolve_opencode_model(None);
457 assert!(!model.is_empty());
459 }
460
461 #[test]
462 fn resolve_model_uses_override() {
463 let model = resolve_opencode_model(Some("opencode/test-model"));
464 assert_eq!(model, "opencode/test-model");
465 }
466
467 #[test]
468 fn resolve_timeout_uses_default() {
469 let t = resolve_opencode_timeout(None);
470 assert!(t > 0);
471 }
472
473 #[test]
474 fn resolve_timeout_uses_override() {
475 assert_eq!(resolve_opencode_timeout(Some(600)), 600);
476 }
477
478 #[test]
479 fn parse_opencode_output_extracts_text() {
480 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
481{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"entities\":[]}"}}
482{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0.0}}"#;
483
484 let (text, cost, tokens) = parse_opencode_output(stdout).unwrap();
485 assert_eq!(text, "{\"entities\":[]}");
486 assert_eq!(cost, 0.0);
487 assert_eq!(tokens, 100);
488 }
489
490 #[test]
491 fn parse_opencode_output_concatenates_multiple_text_events() {
492 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"s","part":{"type":"step-start"}}
493{"type":"text","timestamp":1235,"sessionID":"s","part":{"type":"text","text":"{\"ent"}}
494{"type":"text","timestamp":1236,"sessionID":"s","part":{"type":"text","text":"ities\":[]}"}}
495{"type":"step_finish","timestamp":1237,"sessionID":"s","part":{"type":"step-finish","tokens":{"total":50,"input":40,"output":10,"reasoning":0},"cost":0}}"#;
496
497 let (text, _, _) = parse_opencode_output(stdout).unwrap();
498 assert_eq!(text, "{\"entities\":[]}");
499 }
500
501 #[test]
502 fn parse_opencode_output_empty_fails() {
503 assert!(parse_opencode_output("").is_err());
504 assert!(parse_opencode_output("{\"type\":\"step_start\"}").is_err());
505 }
506
507 #[test]
508 fn parse_json_from_opencode_text_direct() {
509 let text = r#"{"entities":[],"relationships":[]}"#;
510 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
511 assert!(parsed.get("entities").is_some());
512 }
513
514 #[test]
515 fn parse_json_from_opencode_text_markdown_fence() {
516 let text = "Here is the result:\n```json\n{\"entities\":[]}\n```\nDone.";
517 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
518 assert!(parsed.get("entities").is_some());
519 }
520
521 #[test]
522 fn parse_json_from_opencode_text_extract_braces() {
523 let text = "The answer is {\"entities\":[]} and that's it.";
524 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
525 assert!(parsed.get("entities").is_some());
526 }
527
528 #[test]
529 fn parse_json_from_opencode_text_invalid() {
530 assert!(parse_json_from_opencode_text::<serde_json::Value>("no json here").is_err());
531 }
532
533 #[test]
534 fn build_command_has_correct_args() {
535 let cmd = build_opencode_command(
536 Path::new("/usr/bin/opencode"),
537 "opencode/big-pickle",
538 "test prompt",
539 )
540 .unwrap();
541 let argv: Vec<String> = cmd
542 .as_std()
543 .get_args()
544 .filter_map(|a| a.to_str().map(|s| s.to_string()))
545 .collect();
546
547 assert!(argv.contains(&"run".to_string()));
548 assert!(argv.contains(&"--format".to_string()));
549 assert!(argv.contains(&"json".to_string()));
550 assert!(argv.contains(&"-m".to_string()));
551 assert!(argv.contains(&"opencode/big-pickle".to_string()));
552 assert!(argv.contains(&"--dangerously-skip-permissions".to_string()));
553 assert!(argv.contains(&"test prompt".to_string()));
554 }
555}