1use std::io::{BufRead, BufReader, Read, Write};
20use std::os::unix::process::CommandExt;
21use std::path::{Path, PathBuf};
22use std::process::{Child, Command, ExitStatus, Stdio};
23use std::sync::{Arc, atomic::AtomicBool, mpsc};
24use std::time::{Duration, Instant};
25
26use anyhow::{Context, Result, anyhow};
27use serde_json::Value;
28
29use crate::color::Style;
30use crate::config::{Backend, ModelConfig};
31use crate::interrupt;
32use crate::model_timeout::ModelTurnDeadline;
33use crate::usage::Usage;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Mode {
38 Plan,
41 Edit,
43}
44
45struct StreamPrinter {
51 enabled: bool,
52 color: Style,
53 started: bool,
54 last_was_newline: bool,
55}
56
57impl StreamPrinter {
58 fn new(enabled: bool, color: Style) -> Self {
59 Self {
60 enabled,
61 color,
62 started: false,
63 last_was_newline: true,
64 }
65 }
66
67 fn emit(&mut self, to_print: &str, raw: &str) -> Result<()> {
68 if raw.is_empty() {
69 return Ok(());
70 }
71 if !self.enabled {
72 return Ok(());
73 }
74 print!("{to_print}");
75 let _ = std::io::stdout().flush();
76 self.started = true;
77 self.last_was_newline = raw.ends_with('\n');
78 Ok(())
79 }
80
81 fn text(&mut self, s: &str) -> Result<()> {
83 self.emit(s, s)
84 }
85
86 fn reasoning(&mut self, s: &str) -> Result<()> {
89 if s.is_empty() {
90 return Ok(());
91 }
92 let styled = self.color.dim(s);
93 self.emit(&styled, s)
94 }
95
96 fn newline(&mut self) {
100 if self.enabled && self.started && !self.last_was_newline {
101 println!();
102 self.last_was_newline = true;
103 }
104 }
105
106 fn finish(&mut self) {
107 self.newline();
108 }
109}
110
111#[allow(clippy::too_many_arguments)]
119pub async fn run(
120 model: &ModelConfig,
121 mode: Mode,
122 stream_stdout: bool,
123 system: &str,
124 user: &str,
125 cwd: &Path,
126 verbose: bool,
127 color: Style,
128 usage: &mut Usage,
129 turn_timeout: Duration,
130 interrupted: Arc<AtomicBool>,
131) -> Result<String> {
132 let backend = model.backend;
133 let model_id = model.model_id.clone();
134 let system = system.to_string();
135 let user = user.to_string();
136 let cwd = cwd.to_path_buf();
137
138 let join = tokio::task::spawn_blocking(move || match backend {
139 Backend::Claude => invoke_claude(
140 &model_id,
141 mode,
142 stream_stdout,
143 &system,
144 &user,
145 &cwd,
146 verbose,
147 color,
148 turn_timeout,
149 interrupted,
150 ),
151 Backend::Opencode => invoke_opencode(
152 &model_id,
153 mode,
154 stream_stdout,
155 &system,
156 &user,
157 &cwd,
158 verbose,
159 color,
160 turn_timeout,
161 interrupted,
162 ),
163 Backend::Codex => invoke_codex(
164 &model_id,
165 mode,
166 stream_stdout,
167 &system,
168 &user,
169 &cwd,
170 verbose,
171 color,
172 turn_timeout,
173 interrupted,
174 ),
175 Backend::Cursor => invoke_cursor(
176 &model_id,
177 mode,
178 stream_stdout,
179 &system,
180 &user,
181 &cwd,
182 verbose,
183 color,
184 turn_timeout,
185 interrupted,
186 ),
187 Backend::OpenAi => unreachable!("openai backend does not use agent_cli"),
188 })
189 .await;
190
191 match join {
192 Ok(Ok((text, u))) => {
193 usage.add(&u);
194 Ok(text)
195 }
196 Ok(Err(e)) => Err(e),
197 Err(e) => Err(anyhow!("{} worker task failed: {e}", backend.as_str())),
198 }
199}
200
201fn first_line(s: &str) -> &str {
202 s.lines()
203 .find(|l| !l.trim().is_empty())
204 .unwrap_or("(no stderr)")
205}
206
207fn terminate_process_group(child: &mut Child, pid: i32) {
208 unsafe {
209 libc::kill(-pid, libc::SIGINT);
210 }
211 let deadline = Instant::now() + Duration::from_secs(2);
212 loop {
213 match child.try_wait() {
214 Ok(Some(_)) | Err(_) => return,
215 Ok(None) if Instant::now() >= deadline => break,
216 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
217 }
218 }
219 unsafe {
220 libc::kill(-pid, libc::SIGKILL);
221 }
222 let _ = child.wait();
223}
224
225#[allow(clippy::too_many_arguments)]
229fn run_streaming(
230 mut cmd: Command,
231 label: &str,
232 stdin_payload: &str,
233 verbose: bool,
234 color: Style,
235 turn_timeout: Duration,
236 interrupted: Arc<AtomicBool>,
237 mut on_event: impl FnMut(&Value) -> Result<()>,
238) -> Result<(ExitStatus, String)> {
239 let deadline = ModelTurnDeadline::new(turn_timeout);
240 cmd.stdin(Stdio::piped())
241 .stdout(Stdio::piped())
242 .stderr(Stdio::piped());
243 unsafe {
244 cmd.pre_exec(|| {
245 libc::setsid();
246 Ok(())
247 });
248 }
249 let mut child = cmd
250 .spawn()
251 .with_context(|| format!("spawn `{label}` CLI"))?;
252 let pid = child.id() as i32;
253 {
254 let stdin = child
255 .stdin
256 .as_mut()
257 .with_context(|| format!("{label} CLI: stdin not captured"))?;
258 stdin
259 .write_all(stdin_payload.as_bytes())
260 .with_context(|| format!("{label} CLI: write prompt to stdin"))?;
261 }
262 drop(child.stdin.take());
263
264 let stdout = child
265 .stdout
266 .take()
267 .with_context(|| format!("{label} CLI: stdout not captured"))?;
268 let mut stderr_pipe = child
269 .stderr
270 .take()
271 .with_context(|| format!("{label} CLI: stderr not captured"))?;
272 let stderr_thread = std::thread::spawn(move || {
275 let mut buf = String::new();
276 let _ = stderr_pipe.read_to_string(&mut buf);
277 buf
278 });
279
280 let (tx, rx) = mpsc::channel();
281 let stdout_thread = std::thread::spawn(move || {
282 let reader = BufReader::new(stdout);
283 for line_res in reader.lines() {
284 if tx.send(line_res).is_err() {
285 break;
286 }
287 }
288 });
289
290 let mut handle_line = |line_res: std::io::Result<String>| -> Result<()> {
291 let line = line_res.with_context(|| format!("{label} CLI: read stdout line"))?;
292 if line.trim().is_empty() {
293 return Ok(());
294 }
295 if verbose {
296 eprintln!("{} {line}", color.dim(format!("[{label}] <-")));
297 }
298 if let Ok(v) = serde_json::from_str::<Value>(&line) {
299 on_event(&v)?;
300 }
301 Ok(())
302 };
303
304 let cleanup_after_abort =
305 |child: &mut Child,
306 pid: i32,
307 stdout_thread: std::thread::JoinHandle<()>,
308 stderr_thread: std::thread::JoinHandle<String>| {
309 terminate_process_group(child, pid);
310 let _ = stdout_thread.join();
311 let _ = stderr_thread.join();
312 };
313
314 let status = loop {
315 if interrupt::requested(&interrupted) {
316 terminate_process_group(&mut child, pid);
317 let _ = stdout_thread.join();
318 let _ = stderr_thread.join();
319 return Err(interrupt::err());
320 }
321
322 while let Ok(line_res) = rx.try_recv() {
323 if deadline.expired() {
324 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
325 return Err(deadline.timeout().into());
326 }
327 if let Err(e) = handle_line(line_res) {
328 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
329 return Err(e);
330 }
331 }
332
333 if let Some(status) = child
334 .try_wait()
335 .with_context(|| format!("{label} CLI: poll for completion"))?
336 {
337 break status;
338 }
339
340 if deadline.expired() {
341 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
342 return Err(deadline.timeout().into());
343 }
344
345 match rx.recv_timeout(deadline.remaining().min(Duration::from_millis(100))) {
346 Ok(line_res) => {
347 if let Err(e) = handle_line(line_res) {
348 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
349 return Err(e);
350 }
351 }
352 Err(mpsc::RecvTimeoutError::Timeout) => {}
353 Err(mpsc::RecvTimeoutError::Disconnected) => {
354 if let Some(status) = child
355 .try_wait()
356 .with_context(|| format!("{label} CLI: poll for completion"))?
357 {
358 break status;
359 }
360 }
361 }
362 };
363
364 let _ = stdout_thread.join();
365 while let Ok(line_res) = rx.try_recv() {
366 handle_line(line_res)?;
367 }
368 let stderr_buf = stderr_thread.join().unwrap_or_default();
369 Ok((status, stderr_buf))
370}
371
372fn print_tool(name: &str, color: Style) {
374 eprintln!("{} {}", color.dim(" ->"), color.cyan(name));
375}
376
377fn arg_hint(input: Option<&Value>) -> Option<String> {
380 let input = input?;
381 if let Some(c) = input.get("command") {
382 let c = format_command(c);
383 if !c.is_empty() {
384 return Some(truncate(&c, 100));
385 }
386 }
387 for key in [
388 "file_path",
389 "filePath",
390 "path",
391 "relativePath",
392 "pattern",
393 "query",
394 ] {
395 if let Some(s) = input.get(key).and_then(|v| v.as_str())
396 && !s.is_empty()
397 {
398 return Some(truncate(s, 100));
399 }
400 }
401 None
402}
403
404fn tool_label(name: &str, input: Option<&Value>) -> String {
406 match arg_hint(input) {
407 Some(h) => format!("{name}({h})"),
408 None => name.to_string(),
409 }
410}
411
412fn claude_supports_partial_messages() -> bool {
419 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
420 *SUPPORTED.get_or_init(|| {
421 Command::new("claude")
422 .arg("--help")
423 .output()
424 .ok()
425 .map(|o| {
426 let out = String::from_utf8_lossy(&o.stdout);
427 let err = String::from_utf8_lossy(&o.stderr);
428 out.contains("--include-partial-messages")
429 || err.contains("--include-partial-messages")
430 })
431 .unwrap_or(false)
432 })
433}
434
435#[derive(Default)]
436struct ClaudePlanCapture {
437 exit_plan: Option<String>,
438 write_plan: Option<String>,
439 plan_path: Option<PathBuf>,
440}
441
442fn nonempty_string(v: Option<&Value>) -> Option<String> {
443 v.and_then(|v| v.as_str())
444 .filter(|s| !s.trim().is_empty())
445 .map(ToString::to_string)
446}
447
448fn claude_plan_path(input: &Value) -> Option<PathBuf> {
449 for key in ["planFilePath", "plan_file_path", "file_path", "path"] {
450 if let Some(path) = input.get(key).and_then(|v| v.as_str()) {
451 let path = PathBuf::from(path);
452 if is_claude_plan_path(&path) {
453 return Some(path);
454 }
455 }
456 }
457 None
458}
459
460fn is_claude_plan_path(path: &Path) -> bool {
461 if path.extension().and_then(|e| e.to_str()) != Some("md") {
462 return false;
463 }
464
465 let mut prev_was_claude = false;
466 for component in path.components() {
467 let Some(component) = component.as_os_str().to_str() else {
468 prev_was_claude = false;
469 continue;
470 };
471 if prev_was_claude && component == "plans" {
472 return true;
473 }
474 prev_was_claude = component == ".claude";
475 }
476 false
477}
478
479impl ClaudePlanCapture {
480 fn observe_tool_use(&mut self, name: &str, input: Option<&Value>) {
481 let Some(input) = input else {
482 return;
483 };
484
485 if name.eq_ignore_ascii_case("ExitPlanMode") {
486 if let Some(plan) = nonempty_string(input.get("plan")) {
487 self.exit_plan = Some(plan);
488 }
489 if let Some(path) = claude_plan_path(input) {
490 self.plan_path = Some(path);
491 }
492 } else if name.eq_ignore_ascii_case("Write")
493 && let Some(path) = claude_plan_path(input)
494 {
495 self.plan_path = Some(path);
496 if let Some(plan) = nonempty_string(input.get("content")) {
497 self.write_plan = Some(plan);
498 }
499 }
500 }
501
502 fn resolve(&self) -> Result<Option<String>> {
503 if let Some(plan) = self.exit_plan.as_deref().filter(|s| !s.trim().is_empty()) {
504 return Ok(Some(plan.to_string()));
505 }
506 if let Some(plan) = self.write_plan.as_deref().filter(|s| !s.trim().is_empty()) {
507 return Ok(Some(plan.to_string()));
508 }
509 if let Some(path) = &self.plan_path {
510 let plan = std::fs::read_to_string(path)
511 .with_context(|| format!("read claude plan file {}", path.display()))?;
512 if !plan.trim().is_empty() {
513 return Ok(Some(plan));
514 }
515 }
516 Ok(None)
517 }
518}
519
520fn print_recovered_claude_plan(
521 printer: &mut StreamPrinter,
522 stream_stdout: bool,
523 plan: &str,
524) -> Result<()> {
525 if stream_stdout {
526 printer.text(plan)?;
527 printer.newline();
528 }
529 Ok(())
530}
531
532#[allow(clippy::too_many_arguments)]
533fn invoke_claude(
534 model_id: &str,
535 mode: Mode,
536 stream_stdout: bool,
537 system: &str,
538 user: &str,
539 cwd: &PathBuf,
540 verbose: bool,
541 color: Style,
542 turn_timeout: Duration,
543 interrupted: Arc<AtomicBool>,
544) -> Result<(String, Usage)> {
545 let mut cmd = Command::new("claude");
546 cmd.current_dir(cwd)
547 .arg("--print")
548 .arg("--output-format")
549 .arg("stream-json")
550 .arg("--verbose");
552 if stream_stdout && claude_supports_partial_messages() {
558 cmd.arg("--include-partial-messages");
559 }
560 match mode {
561 Mode::Plan => {
564 cmd.arg("--permission-mode").arg("plan");
565 }
566 Mode::Edit => {
567 cmd.arg("--dangerously-skip-permissions");
568 }
569 }
570 if !model_id.is_empty() {
571 cmd.arg("--model").arg(model_id);
572 }
573 let payload = if system.is_empty() {
579 user.to_string()
580 } else {
581 format!("{system}\n\n{user}")
582 };
583
584 let mut final_text: Option<String> = None;
585 let mut error: Option<String> = None;
586 let mut usage = Usage::default();
587 let mut plan_capture = ClaudePlanCapture::default();
588 let mut printer = StreamPrinter::new(stream_stdout, color);
589 let (status, stderr_buf) = run_streaming(
590 cmd,
591 "claude",
592 &payload,
593 verbose,
594 color,
595 turn_timeout,
596 interrupted,
597 |v| -> Result<()> {
598 match v.get("type").and_then(|t| t.as_str()) {
599 Some("stream_event") => {
602 let event = v.get("event");
603 let etype = event.and_then(|e| e.get("type")).and_then(|t| t.as_str());
604 match etype {
605 Some("content_block_delta") => {
606 if let Some(delta) = event.and_then(|e| e.get("delta")) {
607 if let Some(t) = delta.get("text").and_then(|t| t.as_str()) {
608 printer.text(t)?;
609 } else if let Some(t) =
610 delta.get("thinking").and_then(|t| t.as_str())
611 {
612 printer.reasoning(t)?;
613 }
614 }
615 }
616 Some("content_block_stop") => printer.newline(),
619 _ => {}
620 }
621 }
622 Some("assistant") => {
625 if let Some(blocks) = v
626 .get("message")
627 .and_then(|m| m.get("content"))
628 .and_then(|c| c.as_array())
629 {
630 for b in blocks {
631 if b.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
632 let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
633 let input = b.get("input");
634 print_tool(&tool_label(name, input), color);
635 if mode == Mode::Plan {
636 plan_capture.observe_tool_use(name, input);
637 }
638 }
639 }
640 }
641 }
642 Some("result") => {
643 if let Some(u) = v.get("usage") {
645 usage = Usage::from_anthropic(u);
646 }
647 if v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false) {
648 error = Some(
649 v.get("result")
650 .and_then(|x| x.as_str())
651 .unwrap_or("(no message)")
652 .to_string(),
653 );
654 } else {
655 let result = v
656 .get("result")
657 .and_then(|x| x.as_str())
658 .unwrap_or("")
659 .to_string();
660 if !printer.started {
664 printer.text(&result)?;
665 printer.newline();
666 }
667 final_text = Some(result);
668 }
669 }
670 _ => {}
671 }
672 Ok(())
673 },
674 )?;
675 printer.finish();
676 printer.finish();
677
678 if let Some(e) = error {
679 anyhow::bail!("claude CLI reported error: {e}");
680 }
681 let recovered_plan = if mode == Mode::Plan {
682 plan_capture.resolve()?
683 } else {
684 None
685 };
686 let Some(mut text) = final_text else {
687 if let Some(plan) = recovered_plan {
688 print_recovered_claude_plan(&mut printer, stream_stdout, &plan)?;
689 return Ok((plan, usage));
690 }
691 anyhow::bail!(
692 "claude CLI exited with {status} without a result event; stderr: {}",
693 first_line(&stderr_buf)
694 );
695 };
696 if text.trim().is_empty()
697 && let Some(plan) = recovered_plan
698 {
699 print_recovered_claude_plan(&mut printer, stream_stdout, &plan)?;
700 text = plan;
701 }
702 Ok((text, usage))
703}
704
705#[allow(clippy::too_many_arguments)]
708fn invoke_opencode(
709 model_id: &str,
710 mode: Mode,
711 stream_stdout: bool,
712 system: &str,
713 user: &str,
714 cwd: &PathBuf,
715 verbose: bool,
716 color: Style,
717 turn_timeout: Duration,
718 interrupted: Arc<AtomicBool>,
719) -> Result<(String, Usage)> {
720 let mut cmd = Command::new("opencode");
721 cmd.current_dir(cwd).arg("run").arg("--format").arg("json");
722 if mode == Mode::Plan {
723 cmd.arg("--agent").arg("plan");
727 }
728 if !model_id.is_empty() {
729 cmd.arg("-m").arg(model_id);
730 }
731 let payload = if system.is_empty() {
733 user.to_string()
734 } else {
735 format!("{system}\n\n{user}")
736 };
737
738 let mut text_parts: Vec<String> = Vec::new();
739 let mut saw_event = false;
740 let mut usage = Usage::default();
741 let mut printer = StreamPrinter::new(stream_stdout, color);
742 let (status, stderr_buf) = run_streaming(
743 cmd,
744 "opencode",
745 &payload,
746 verbose,
747 color,
748 turn_timeout,
749 interrupted,
750 |v| -> Result<()> {
751 saw_event = true;
752 if let Some(u) = v.get("usage") {
754 usage = Usage::from_any(u);
755 }
756 match v.get("type").and_then(|t| t.as_str()) {
757 Some("tool_use") => {
758 let part = v.get("part");
759 let name = part
760 .and_then(|p| p.get("tool"))
761 .and_then(|t| t.as_str())
762 .unwrap_or("tool");
763 let input = part
766 .and_then(|p| p.get("state").and_then(|s| s.get("input")))
767 .or_else(|| part.and_then(|p| p.get("input")));
768 print_tool(&tool_label(name, input), color);
769 }
770 Some("text") => {
771 if let Some(part) = v.get("part") {
772 let synthetic = part
773 .get("synthetic")
774 .and_then(|x| x.as_bool())
775 .unwrap_or(false)
776 || part
777 .get("metadata")
778 .and_then(|m| m.get("compaction_continue"))
779 .and_then(|x| x.as_bool())
780 .unwrap_or(false);
781 if !synthetic
782 && let Some(t) = part.get("text").and_then(|t| t.as_str())
783 && !t.is_empty()
784 {
785 text_parts.push(t.to_string());
786 printer.text(t)?;
787 printer.newline();
788 }
789 }
790 }
791 Some("reasoning") => {
792 if let Some(t) = v
793 .get("part")
794 .and_then(|p| p.get("text"))
795 .and_then(|t| t.as_str())
796 {
797 printer.reasoning(t)?;
798 printer.newline();
799 }
800 }
801 _ => {}
802 }
803 Ok(())
804 },
805 )?;
806 printer.finish();
807
808 if !saw_event {
809 anyhow::bail!(
810 "opencode CLI exited with {status} without any events; stderr: {}",
811 first_line(&stderr_buf)
812 );
813 }
814 Ok((text_parts.join("\n\n"), usage))
815}
816
817fn truncate(s: &str, max: usize) -> String {
820 let s = s.trim();
821 if s.chars().count() > max {
822 let head: String = s.chars().take(max).collect();
823 format!("{head}...")
824 } else {
825 s.to_string()
826 }
827}
828
829fn format_command(v: &Value) -> String {
831 match v {
832 Value::String(s) => s.replace('\n', " "),
833 Value::Array(parts) => parts
834 .iter()
835 .filter_map(|p| p.as_str())
836 .collect::<Vec<_>>()
837 .join(" "),
838 _ => String::new(),
839 }
840}
841
842fn codex_file_paths(item: &Value) -> Vec<String> {
844 if let Some(p) = item.get("path").and_then(|p| p.as_str()) {
845 return vec![p.to_string()];
846 }
847 for key in ["changes", "files"] {
848 if let Some(arr) = item.get(key).and_then(|c| c.as_array()) {
849 let paths: Vec<String> = arr
850 .iter()
851 .filter_map(|c| {
852 c.get("path")
853 .and_then(|p| p.as_str())
854 .or_else(|| c.as_str())
855 .map(|s| s.to_string())
856 })
857 .collect();
858 if !paths.is_empty() {
859 return paths;
860 }
861 }
862 }
863 Vec::new()
864}
865
866fn codex_item_label(item: &Value) -> Option<String> {
869 let ty = item
870 .get("type")
871 .or_else(|| item.get("item_type"))
872 .and_then(|t| t.as_str())?;
873 match ty {
874 "agent_message" | "reasoning" => None,
875 "command_execution" => {
876 let cmd = item.get("command").map(format_command).unwrap_or_default();
877 Some(if cmd.is_empty() {
878 ty.to_string()
879 } else {
880 format!("{ty}: {}", truncate(&cmd, 120))
881 })
882 }
883 "file_change" => {
884 let paths = codex_file_paths(item);
885 Some(if paths.is_empty() {
886 ty.to_string()
887 } else {
888 format!("{ty}: {}", paths.join(", "))
889 })
890 }
891 other => {
893 let name = item
894 .get("name")
895 .or_else(|| item.get("tool"))
896 .and_then(|n| n.as_str());
897 Some(match name {
898 Some(n) if !n.is_empty() => format!("{other}: {n}"),
899 _ => other.to_string(),
900 })
901 }
902 }
903}
904
905#[allow(clippy::too_many_arguments)]
906fn invoke_codex(
907 model_id: &str,
908 mode: Mode,
909 stream_stdout: bool,
910 system: &str,
911 user: &str,
912 cwd: &PathBuf,
913 verbose: bool,
914 color: Style,
915 turn_timeout: Duration,
916 interrupted: Arc<AtomicBool>,
917) -> Result<(String, Usage)> {
918 let out_path = std::env::temp_dir().join(format!("scx-forge-codex-{}.txt", std::process::id()));
920 let _ = std::fs::remove_file(&out_path);
921
922 let mut cmd = Command::new("codex");
923 cmd.current_dir(cwd)
924 .arg("--ask-for-approval")
925 .arg("never")
926 .arg("exec")
927 .arg("--json")
928 .arg("--color")
929 .arg("never")
930 .arg("--output-last-message")
931 .arg(&out_path)
932 .arg("-C")
933 .arg(cwd);
934 match mode {
935 Mode::Plan => {
938 cmd.arg("--sandbox").arg("read-only");
939 }
940 Mode::Edit => {
941 cmd.arg("--dangerously-bypass-approvals-and-sandbox");
942 }
943 }
944 cmd.arg("--skip-git-repo-check");
945 if !model_id.is_empty() {
946 cmd.arg("--model").arg(model_id);
947 }
948 cmd.arg("-");
950
951 let payload = if system.is_empty() {
952 user.to_string()
953 } else {
954 format!("{system}\n\n{user}")
955 };
956
957 let mut usage = Usage::default();
958 let mut printer = StreamPrinter::new(stream_stdout, color);
959 let (status, stderr_buf) = run_streaming(
960 cmd,
961 "codex",
962 &payload,
963 verbose,
964 color,
965 turn_timeout,
966 interrupted,
967 |v| -> Result<()> {
968 let u = v
972 .get("usage")
973 .or_else(|| v.get("info").and_then(|i| i.get("total_token_usage")))
974 .or_else(|| v.get("info").and_then(|i| i.get("usage")));
975 if let Some(u) = u {
976 usage = Usage::from_any(u);
977 }
978 if v.get("type").and_then(|t| t.as_str()) == Some("item.completed")
982 && let Some(item) = v.get("item")
983 {
984 let ty = item
985 .get("type")
986 .or_else(|| item.get("item_type"))
987 .and_then(|t| t.as_str());
988 match ty {
989 Some("agent_message") => {
990 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
991 printer.text(t)?;
992 printer.newline();
993 }
994 }
995 Some("reasoning") => {
996 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
997 printer.reasoning(t)?;
998 printer.newline();
999 }
1000 }
1001 _ => {
1002 if let Some(label) = codex_item_label(item) {
1003 print_tool(&label, color);
1004 }
1005 }
1006 }
1007 }
1008 Ok(())
1009 },
1010 )?;
1011 printer.finish();
1012
1013 let final_text = std::fs::read_to_string(&out_path).unwrap_or_default();
1014 let _ = std::fs::remove_file(&out_path);
1015
1016 if !status.success() {
1017 anyhow::bail!(
1018 "codex CLI exited with {status}; stderr: {}",
1019 first_line(&stderr_buf)
1020 );
1021 }
1022 if final_text.trim().is_empty() {
1023 anyhow::bail!(
1024 "codex CLI produced no final message; stderr: {}",
1025 first_line(&stderr_buf)
1026 );
1027 }
1028 Ok((final_text, usage))
1029}
1030
1031fn cursor_tool_label(tc: &Value) -> String {
1040 if let Some((key, body)) = tc
1041 .as_object()
1042 .and_then(|m| m.iter().find(|(k, _)| k.ends_with("ToolCall")))
1043 {
1044 let name = key.strip_suffix("ToolCall").unwrap_or(key);
1045 return tool_label(name, body.get("args"));
1046 }
1047 "tool".to_string()
1048}
1049
1050fn cursor_is_new_fragment(segment: &mut String, text: &str) -> bool {
1057 if !segment.is_empty() && text == *segment {
1058 segment.clear();
1059 false
1060 } else {
1061 segment.push_str(text);
1062 true
1063 }
1064}
1065
1066#[allow(clippy::too_many_arguments)]
1067fn invoke_cursor(
1068 model_id: &str,
1069 mode: Mode,
1070 stream_stdout: bool,
1071 system: &str,
1072 user: &str,
1073 cwd: &PathBuf,
1074 verbose: bool,
1075 color: Style,
1076 turn_timeout: Duration,
1077 interrupted: Arc<AtomicBool>,
1078) -> Result<(String, Usage)> {
1079 let mut cmd = Command::new("cursor-agent");
1080 cmd.current_dir(cwd)
1081 .arg("--print")
1082 .arg("--output-format")
1083 .arg("stream-json")
1084 .arg("--workspace")
1087 .arg(cwd)
1088 .arg("--trust")
1089 .arg("--approve-mcps");
1092 if stream_stdout {
1095 cmd.arg("--stream-partial-output");
1096 }
1097 match mode {
1098 Mode::Plan => {
1101 cmd.arg("--mode").arg("plan");
1102 }
1103 Mode::Edit => {
1105 cmd.arg("--force");
1106 }
1107 }
1108 if !model_id.is_empty() {
1109 cmd.arg("--model").arg(model_id);
1110 }
1111 let payload = if system.is_empty() {
1114 user.to_string()
1115 } else {
1116 format!("{system}\n\n{user}")
1117 };
1118
1119 let mut final_text: Option<String> = None;
1120 let mut error: Option<String> = None;
1121 let mut usage = Usage::default();
1122 let mut printer = StreamPrinter::new(stream_stdout, color);
1123 let mut text_seg = String::new();
1126 let mut think_seg = String::new();
1127 let (status, stderr_buf) = run_streaming(
1128 cmd,
1129 "cursor-agent",
1130 &payload,
1131 verbose,
1132 color,
1133 turn_timeout,
1134 interrupted,
1135 |v| -> Result<()> {
1136 match v.get("type").and_then(|t| t.as_str()) {
1137 Some("assistant") => {
1142 if let Some(blocks) = v
1143 .get("message")
1144 .and_then(|m| m.get("content"))
1145 .and_then(|c| c.as_array())
1146 {
1147 for b in blocks {
1148 let text = b.get("text").and_then(|t| t.as_str()).unwrap_or("");
1149 if text.is_empty() {
1150 continue;
1151 }
1152 match b.get("type").and_then(|t| t.as_str()) {
1153 Some("text") => {
1154 if cursor_is_new_fragment(&mut text_seg, text) {
1155 printer.text(text)?;
1156 }
1157 }
1158 Some("thinking") | Some("reasoning")
1159 if cursor_is_new_fragment(&mut think_seg, text) =>
1160 {
1161 printer.reasoning(text)?;
1162 }
1163 _ => {}
1164 }
1165 }
1166 }
1167 }
1168 Some("tool_call") => {
1170 if v.get("subtype").and_then(|s| s.as_str()) == Some("started")
1171 && let Some(tc) = v.get("tool_call")
1172 {
1173 print_tool(&cursor_tool_label(tc), color);
1174 }
1175 }
1176 Some("result") => {
1177 if let Some(u) = v.get("usage") {
1178 usage = Usage::from_cursor(u);
1179 }
1180 if v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false) {
1181 error = Some(
1182 v.get("result")
1183 .and_then(|x| x.as_str())
1184 .unwrap_or("(no message)")
1185 .to_string(),
1186 );
1187 } else {
1188 let result = v
1189 .get("result")
1190 .and_then(|x| x.as_str())
1191 .unwrap_or("")
1192 .to_string();
1193 if !printer.started {
1196 printer.text(&result)?;
1197 printer.newline();
1198 }
1199 final_text = Some(result);
1200 }
1201 }
1202 _ => {}
1203 }
1204 Ok(())
1205 },
1206 )?;
1207 printer.finish();
1208
1209 if let Some(e) = error {
1210 anyhow::bail!("cursor-agent CLI reported error: {e}");
1211 }
1212 if !status.success() {
1213 anyhow::bail!(
1214 "cursor-agent CLI exited with {status}; stderr: {}",
1215 first_line(&stderr_buf)
1216 );
1217 }
1218 let text = final_text.ok_or_else(|| {
1219 anyhow!(
1220 "cursor-agent CLI exited with {status} without a result event; stderr: {}",
1221 first_line(&stderr_buf)
1222 )
1223 })?;
1224 Ok((text, usage))
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use serde_json::json;
1231
1232 #[test]
1233 fn codex_command_string_label() {
1234 let item = json!({"type": "command_execution", "command": "cargo build -p scx_forge"});
1235 assert_eq!(
1236 codex_item_label(&item).unwrap(),
1237 "command_execution: cargo build -p scx_forge"
1238 );
1239 }
1240
1241 #[test]
1242 fn codex_command_argv_label() {
1243 let item = json!({"type": "command_execution", "command": ["grep", "-n", "foo", "x.c"]});
1244 assert_eq!(
1245 codex_item_label(&item).unwrap(),
1246 "command_execution: grep -n foo x.c"
1247 );
1248 }
1249
1250 #[test]
1251 fn codex_file_change_path_label() {
1252 let item = json!({"type": "file_change", "path": "src/bpf/main.bpf.c"});
1253 assert_eq!(
1254 codex_item_label(&item).unwrap(),
1255 "file_change: src/bpf/main.bpf.c"
1256 );
1257 }
1258
1259 #[test]
1260 fn codex_file_change_changes_array_label() {
1261 let item = json!({"type": "file_change",
1262 "changes": [{"path": "a.c", "kind": "modify"}, {"path": "b.h"}]});
1263 assert_eq!(codex_item_label(&item).unwrap(), "file_change: a.c, b.h");
1264 }
1265
1266 #[test]
1267 fn codex_agent_message_has_no_label() {
1268 let item = json!({"type": "agent_message", "text": "done"});
1269 assert_eq!(codex_item_label(&item), None);
1270 }
1271
1272 #[test]
1273 fn codex_long_command_truncated() {
1274 let item = json!({"type": "command_execution", "command": "x".repeat(200)});
1275 let label = codex_item_label(&item).unwrap();
1276 assert!(label.ends_with("..."));
1277 assert!(label.len() < 160);
1278 }
1279
1280 #[test]
1281 fn tool_label_uses_file_path() {
1282 let input = json!({"file_path": "src/bpf/main.bpf.c", "old_string": "a"});
1283 assert_eq!(tool_label("Edit", Some(&input)), "Edit(src/bpf/main.bpf.c)");
1284 }
1285
1286 #[test]
1287 fn tool_label_uses_command_then_pattern() {
1288 assert_eq!(
1289 tool_label("Bash", Some(&json!({"command": "cargo build"}))),
1290 "Bash(cargo build)"
1291 );
1292 assert_eq!(
1293 tool_label("Grep", Some(&json!({"pattern": "budget_slice"}))),
1294 "Grep(budget_slice)"
1295 );
1296 }
1297
1298 #[test]
1299 fn tool_label_without_args_is_bare_name() {
1300 assert_eq!(tool_label("read", None), "read");
1301 assert_eq!(tool_label("read", Some(&json!({}))), "read");
1302 }
1303
1304 #[test]
1305 fn claude_plan_capture_prefers_exit_plan_text() {
1306 let mut capture = ClaudePlanCapture::default();
1307 capture.observe_tool_use(
1308 "Write",
1309 Some(&json!({
1310 "file_path": "/home/me/.claude/plans/example.md",
1311 "content": "plan from write"
1312 })),
1313 );
1314 capture.observe_tool_use(
1315 "ExitPlanMode",
1316 Some(&json!({
1317 "plan": "plan from exit",
1318 "planFilePath": "/home/me/.claude/plans/example.md"
1319 })),
1320 );
1321
1322 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from exit");
1323 }
1324
1325 #[test]
1326 fn claude_plan_capture_uses_write_content_for_plan_file() {
1327 let mut capture = ClaudePlanCapture::default();
1328 capture.observe_tool_use(
1329 "Write",
1330 Some(&json!({
1331 "file_path": "/home/me/.claude/plans/example.md",
1332 "content": "plan from write"
1333 })),
1334 );
1335
1336 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from write");
1337 }
1338
1339 #[test]
1340 fn claude_plan_capture_reads_plan_file_when_content_missing() {
1341 let unique = std::time::SystemTime::now()
1342 .duration_since(std::time::UNIX_EPOCH)
1343 .unwrap()
1344 .as_nanos();
1345 let root = std::env::temp_dir().join(format!(
1346 "scx-forge-agent-claude-plan-test-{}-{unique}",
1347 std::process::id()
1348 ));
1349 let plan_dir = root.join(".claude").join("plans");
1350 let plan_path = plan_dir.join("example.md");
1351 std::fs::create_dir_all(&plan_dir).unwrap();
1352 std::fs::write(&plan_path, "plan from file").unwrap();
1353
1354 let mut capture = ClaudePlanCapture::default();
1355 capture.observe_tool_use(
1356 "ExitPlanMode",
1357 Some(&json!({
1358 "planFilePath": plan_path
1359 })),
1360 );
1361
1362 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from file");
1363 let _ = std::fs::remove_dir_all(root);
1364 }
1365
1366 #[test]
1367 fn claude_plan_capture_ignores_non_plan_writes() {
1368 let mut capture = ClaudePlanCapture::default();
1369 capture.observe_tool_use(
1370 "Write",
1371 Some(&json!({
1372 "file_path": "/tmp/not-a-claude-plan.md",
1373 "content": "not a plan file"
1374 })),
1375 );
1376
1377 assert_eq!(capture.resolve().unwrap(), None);
1378 }
1379
1380 #[test]
1381 fn cursor_shell_tool_label() {
1382 let tc = json!({"shellToolCall": {"args": {"command": "cargo build -p scx_forge"}}});
1383 assert_eq!(cursor_tool_label(&tc), "shell(cargo build -p scx_forge)");
1384 }
1385
1386 #[test]
1387 fn cursor_edit_tool_label_uses_path() {
1388 let tc = json!({"editToolCall": {"args": {"relativePath": "src/bpf/main.bpf.c"}}});
1389 assert_eq!(cursor_tool_label(&tc), "edit(src/bpf/main.bpf.c)");
1390 }
1391
1392 #[test]
1393 fn cursor_tool_label_without_args_is_bare_name() {
1394 let tc = json!({"lsToolCall": {}});
1395 assert_eq!(cursor_tool_label(&tc), "ls");
1396 }
1397
1398 #[test]
1399 fn cursor_tool_label_ignores_bookkeeping_keys() {
1400 let tc = json!({
1404 "hookAdditionalContexts": [],
1405 "startedAtMs": "123",
1406 "toolCallId": "abc",
1407 "readToolCall": {"args": {"path": "src/main.rs"}},
1408 });
1409 assert_eq!(cursor_tool_label(&tc), "read(src/main.rs)");
1410
1411 let tc = json!({
1412 "hookAdditionalContexts": [],
1413 "shellToolCall": {"args": {"command": "cargo test"}},
1414 "toolCallId": "x",
1415 });
1416 assert_eq!(cursor_tool_label(&tc), "shell(cargo test)");
1417 }
1418
1419 #[test]
1420 fn cursor_fragment_dedup_drops_consolidation() {
1421 let mut seg = String::new();
1422 assert!(cursor_is_new_fragment(&mut seg, "Hello"));
1424 assert!(cursor_is_new_fragment(&mut seg, " world"));
1425 assert!(!cursor_is_new_fragment(&mut seg, "Hello world"));
1427 assert!(seg.is_empty());
1428 assert!(cursor_is_new_fragment(&mut seg, "Hello world"));
1430 assert!(cursor_is_new_fragment(&mut seg, "!"));
1431 assert!(!cursor_is_new_fragment(&mut seg, "Hello world!"));
1432 }
1433}