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