Skip to main content

harness/agents/
cursor.rs

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
11/// Adapter for Cursor CLI (`agent` binary).
12///
13/// Headless invocation:
14///   agent -p --output-format stream-json "<prompt>"
15///
16/// Stream format: NDJSON with event types:
17///   - { type: "system", subtype: "init", session_id, model, cwd, apiKeySource, permissionMode }
18///   - { type: "user", message: { role, content: [...] }, session_id }
19///   - { type: "assistant", message: { role, content: [...] }, session_id }
20///   - { type: "tool_call", subtype: "started"|"completed", call_id, tool_call, session_id }
21///   - { type: "result", subtype: "success", result, session_id, duration_ms, is_error }
22pub 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        // Prompt must be the last positional argument.
63        args.push(config.prompt.clone());
64        args
65    }
66
67    fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
68        // Cursor reads CURSOR_API_KEY from the environment.
69        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            // User message echoed back — usually the initial prompt.
143            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    // Cursor nests tool calls under keys like "readToolCall", "writeToolCall", etc.
246    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        // Fallback: check for "name" and "arguments" keys.
262        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}