1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::config::{PermissionMode, TaskConfig};
6use crate::error::{Error, Result};
7use crate::event::*;
8use crate::process::{spawn_and_stream, StreamHandle};
9use crate::runner::AgentRunner;
10
11pub struct CursorRunner;
23
24#[async_trait]
25impl AgentRunner for CursorRunner {
26 fn name(&self) -> &str {
27 "cursor"
28 }
29
30 fn is_available(&self) -> bool {
31 crate::runner::is_any_binary_available(crate::config::AgentKind::Cursor)
32 }
33
34 fn binary_path(&self, config: &TaskConfig) -> Result<PathBuf> {
35 crate::runner::resolve_binary(crate::config::AgentKind::Cursor, config)
36 }
37
38 fn build_args(&self, config: &TaskConfig) -> Vec<String> {
39 let mut args = vec![
40 "-p".to_string(),
41 "--output-format".to_string(),
42 "stream-json".to_string(),
43 ];
44
45 if let Some(ref model) = config.model {
46 args.push("--model".to_string());
47 args.push(model.clone());
48 }
49
50 match config.permission_mode {
51 PermissionMode::FullAccess => {
52 args.push("--force".to_string());
53 }
54 PermissionMode::ReadOnly => {
55 args.push("--mode".to_string());
56 args.push("plan".to_string());
57 }
58 }
59
60 args.extend(config.extra_args.iter().cloned());
61
62 args.push(config.prompt.clone());
64 args
65 }
66
67 fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
68 vec![]
70 }
71
72 async fn run(
73 &self,
74 config: &TaskConfig,
75 cancel_token: Option<tokio_util::sync::CancellationToken>,
76 ) -> Result<StreamHandle> {
77 spawn_and_stream(self, config, parse_cursor_line, cancel_token).await
78 }
79
80 fn capabilities(&self) -> crate::runner::AgentCapabilities {
81 crate::runner::AgentCapabilities {
82 supports_system_prompt: false,
83 supports_budget: false,
84 supports_model: true,
85 supports_max_turns: false,
86 supports_append_system_prompt: false,
87 }
88 }
89}
90
91fn parse_cursor_line(line: &str) -> Vec<Result<Event>> {
92 let value: serde_json::Value = match serde_json::from_str(line) {
93 Ok(v) => v,
94 Err(e) => return vec![Err(Error::ParseError(format!("invalid JSON: {e}: {line}")))],
95 };
96
97 let event_type = match value.get("type").and_then(|v| v.as_str()) {
98 Some(t) => t,
99 None => return vec![],
100 };
101
102 match event_type {
103 "system" => {
104 let subtype = value.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
105 if subtype == "init" {
106 vec![Ok(Event::SessionStart(SessionStartEvent {
107 session_id: value
108 .get("session_id")
109 .and_then(|v| v.as_str())
110 .unwrap_or("")
111 .to_string(),
112 agent: "cursor".to_string(),
113 model: value
114 .get("model")
115 .and_then(|v| v.as_str())
116 .map(|s| s.to_string()),
117 cwd: value
118 .get("cwd")
119 .and_then(|v| v.as_str())
120 .map(|s| s.to_string()),
121 timestamp_ms: 0,
122 }))]
123 } else {
124 vec![]
125 }
126 }
127
128 "assistant" => {
129 let text = extract_message_text(&value);
130 if text.is_empty() {
131 return vec![];
132 }
133 vec![Ok(Event::Message(MessageEvent {
134 role: Role::Assistant,
135 text,
136 usage: None,
137 timestamp_ms: 0,
138 }))]
139 }
140
141 "user" => {
142 let text = extract_message_text(&value);
144 if text.is_empty() {
145 return vec![];
146 }
147 vec![Ok(Event::Message(MessageEvent {
148 role: Role::User,
149 text,
150 usage: None,
151 timestamp_ms: 0,
152 }))]
153 }
154
155 "tool_call" => {
156 let subtype = value.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
157 let call_id = value
158 .get("call_id")
159 .and_then(|v| v.as_str())
160 .unwrap_or("")
161 .to_string();
162
163 let tool_call = value.get("tool_call");
164 let (tool_name, input_or_output) = extract_tool_info(tool_call);
165
166 match subtype {
167 "started" => vec![Ok(Event::ToolStart(ToolStartEvent {
168 call_id,
169 tool_name,
170 input: input_or_output,
171 timestamp_ms: 0,
172 }))],
173 "completed" => vec![Ok(Event::ToolEnd(ToolEndEvent {
174 call_id,
175 tool_name,
176 success: true,
177 output: input_or_output.map(|v| v.to_string()),
178 usage: None,
179 timestamp_ms: 0,
180 }))],
181 _ => vec![],
182 }
183 }
184
185 "result" => {
186 let subtype = value
187 .get("subtype")
188 .and_then(|v| v.as_str())
189 .unwrap_or("success");
190 let is_error = value
191 .get("is_error")
192 .and_then(|v| v.as_bool())
193 .unwrap_or(false);
194 let success = subtype == "success" && !is_error;
195
196 vec![Ok(Event::Result(ResultEvent {
197 success,
198 text: value
199 .get("result")
200 .and_then(|v| v.as_str())
201 .unwrap_or("")
202 .to_string(),
203 session_id: value
204 .get("session_id")
205 .and_then(|v| v.as_str())
206 .unwrap_or("")
207 .to_string(),
208 duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()),
209 total_cost_usd: None,
210 usage: None,
211 timestamp_ms: 0,
212 }))]
213 }
214
215 _ => vec![],
216 }
217}
218
219fn extract_message_text(value: &serde_json::Value) -> String {
220 value
221 .pointer("/message/content")
222 .and_then(|v| v.as_array())
223 .map(|arr| {
224 arr.iter()
225 .filter_map(|item| {
226 if item.get("type")?.as_str()? == "text" {
227 item.get("text").and_then(|v| v.as_str())
228 } else {
229 None
230 }
231 })
232 .collect::<Vec<_>>()
233 .join("")
234 })
235 .unwrap_or_default()
236}
237
238fn extract_tool_info(
239 tool_call: Option<&serde_json::Value>,
240) -> (String, Option<serde_json::Value>) {
241 let Some(tc) = tool_call else {
242 return ("unknown".to_string(), None);
243 };
244
245 if let Some(obj) = tc.as_object() {
247 for (key, val) in obj {
248 if key.ends_with("ToolCall") || key.ends_with("_tool_call") {
249 let name = key
250 .trim_end_matches("ToolCall")
251 .trim_end_matches("_tool_call")
252 .to_string();
253 let data = val
254 .get("args")
255 .or_else(|| val.get("result"))
256 .cloned();
257 return (name, data);
258 }
259 }
260
261 if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
263 let args = obj.get("arguments").cloned();
264 return (name.to_string(), args);
265 }
266 }
267
268 ("unknown".to_string(), None)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn parse_init_event() {
277 let line = r#"{"type":"system","subtype":"init","session_id":"s-42","model":"gpt-5.2","cwd":"/home/user","apiKeySource":"login","permissionMode":"default"}"#;
278 let events = parse_cursor_line(line);
279 assert_eq!(events.len(), 1);
280 let event = events.into_iter().next().unwrap().unwrap();
281 match event {
282 Event::SessionStart(s) => {
283 assert_eq!(s.session_id, "s-42");
284 assert_eq!(s.agent, "cursor");
285 assert_eq!(s.model, Some("gpt-5.2".into()));
286 assert_eq!(s.cwd, Some("/home/user".into()));
287 }
288 other => panic!("expected SessionStart, got {other:?}"),
289 }
290 }
291
292 #[test]
293 fn parse_assistant_message() {
294 let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the bug"}]},"session_id":"s-42"}"#;
295 let events = parse_cursor_line(line);
296 assert_eq!(events.len(), 1);
297 let event = events.into_iter().next().unwrap().unwrap();
298 match event {
299 Event::Message(m) => {
300 assert_eq!(m.role, Role::Assistant);
301 assert_eq!(m.text, "I found the bug");
302 }
303 other => panic!("expected Message, got {other:?}"),
304 }
305 }
306
307 #[test]
308 fn parse_tool_call_started() {
309 let line = r#"{"type":"tool_call","subtype":"started","call_id":"c-1","tool_call":{"readToolCall":{"args":{"path":"src/main.rs"}}},"session_id":"s-42"}"#;
310 let events = parse_cursor_line(line);
311 assert_eq!(events.len(), 1);
312 let event = events.into_iter().next().unwrap().unwrap();
313 match event {
314 Event::ToolStart(t) => {
315 assert_eq!(t.call_id, "c-1");
316 assert_eq!(t.tool_name, "read");
317 assert_eq!(t.input, Some(serde_json::json!({"path": "src/main.rs"})));
318 }
319 other => panic!("expected ToolStart, got {other:?}"),
320 }
321 }
322
323 #[test]
324 fn parse_tool_call_completed() {
325 let line = r#"{"type":"tool_call","subtype":"completed","call_id":"c-1","tool_call":{"readToolCall":{"result":{"success":{"content":"fn main(){}"}}}},"session_id":"s-42"}"#;
326 let events = parse_cursor_line(line);
327 assert_eq!(events.len(), 1);
328 match events.into_iter().next().unwrap().unwrap() {
329 Event::ToolEnd(e) => {
330 assert_eq!(e.call_id, "c-1");
331 assert_eq!(e.tool_name, "read");
332 assert!(e.success);
333 }
334 other => panic!("expected ToolEnd, got {other:?}"),
335 }
336 }
337
338 #[test]
339 fn parse_result_success() {
340 let line = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"result":"Task completed","session_id":"s-42"}"#;
341 let events = parse_cursor_line(line);
342 assert_eq!(events.len(), 1);
343 let event = events.into_iter().next().unwrap().unwrap();
344 match event {
345 Event::Result(r) => {
346 assert!(r.success);
347 assert_eq!(r.text, "Task completed");
348 assert_eq!(r.session_id, "s-42");
349 assert_eq!(r.duration_ms, Some(2000));
350 }
351 other => panic!("expected Result, got {other:?}"),
352 }
353 }
354
355 #[test]
356 fn build_args_full_access_uses_force() {
357 let mut config = TaskConfig::new("fix it", crate::config::AgentKind::Cursor);
358 config.model = Some("sonnet-4.5-thinking".into());
359
360 let runner = CursorRunner;
361 let args = runner.build_args(&config);
362 assert!(args.contains(&"-p".to_string()));
363 assert!(args.contains(&"--force".to_string()));
364 assert!(args.contains(&"--model".to_string()));
365 assert!(args.contains(&"sonnet-4.5-thinking".to_string()));
366 assert_eq!(args.last().unwrap(), "fix it");
367 }
368}