1use anyhow::Result;
2use async_trait::async_trait;
3use rmcp::model::Role;
4use serde_json::{json, Value};
5use std::ffi::OsString;
6use std::path::PathBuf;
7use std::process::Stdio;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10
11use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
12use super::errors::ProviderError;
13use super::utils::{filter_extensions_from_system_prompt, RequestLog};
14use crate::config::base::CursorAgentCommand;
15use crate::config::search_path::SearchPaths;
16use crate::conversation::message::{Message, MessageContent};
17use crate::model::ModelConfig;
18use crate::subprocess::configure_command_no_window;
19use rmcp::model::Tool;
20
21pub const CURSOR_AGENT_DEFAULT_MODEL: &str = "auto";
22pub const CURSOR_AGENT_KNOWN_MODELS: &[&str] = &["auto", "gpt-5", "opus-4.1", "sonnet-4"];
23
24pub const CURSOR_AGENT_DOC_URL: &str = "https://docs.cursor.com/en/cli/overview";
25
26#[derive(Debug, serde::Serialize)]
27pub struct CursorAgentProvider {
28 command: PathBuf,
29 model: ModelConfig,
30 #[serde(skip)]
31 name: String,
32}
33
34impl CursorAgentProvider {
35 pub async fn from_env(model: ModelConfig) -> Result<Self> {
36 let config = crate::config::Config::global();
37 let command: OsString = config.get_cursor_agent_command().unwrap_or_default().into();
38 let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
39
40 Ok(Self {
41 command: resolved_command,
42 model,
43 name: Self::metadata().name,
44 })
45 }
46
47 async fn get_authentication_status(&self) -> bool {
49 Command::new(&self.command)
50 .arg("status")
51 .output()
52 .await
53 .ok()
54 .map(|output| String::from_utf8_lossy(&output.stdout).contains("✓ Logged in as"))
55 .unwrap_or(false)
56 }
57
58 fn messages_to_cursor_agent_format(&self, system: &str, messages: &[Message]) -> String {
60 let mut full_prompt = String::new();
61
62 let filtered_system = filter_extensions_from_system_prompt(system);
63 full_prompt.push_str(&filtered_system);
64 full_prompt.push_str("\n\n");
65
66 for message in messages.iter().filter(|m| m.is_agent_visible()) {
68 let role_prefix = match message.role {
69 Role::User => "Human: ",
70 Role::Assistant => "Assistant: ",
71 };
72 full_prompt.push_str(role_prefix);
73
74 for content in &message.content {
75 match content {
76 MessageContent::Text(text_content) => {
77 full_prompt.push_str(&text_content.text);
78 full_prompt.push('\n');
79 }
80 MessageContent::ToolRequest(tool_request) => {
81 if let Ok(tool_call) = &tool_request.tool_call {
82 full_prompt.push_str(&format!(
83 "Tool Use: {} with args: {:?}\n",
84 tool_call.name, tool_call.arguments
85 ));
86 }
87 }
88 MessageContent::ToolResponse(tool_response) => {
89 if let Ok(result) = &tool_response.tool_result {
90 let content_text = result
91 .content
92 .iter()
93 .filter_map(|content| match &content.raw {
94 rmcp::model::RawContent::Text(text_content) => {
95 Some(text_content.text.as_str())
96 }
97 _ => None,
98 })
99 .collect::<Vec<&str>>()
100 .join("\n");
101
102 full_prompt.push_str(&format!("Tool Result: {}\n", content_text));
103 }
104 }
105 _ => {
106 }
108 }
109 }
110 full_prompt.push('\n');
111 }
112
113 full_prompt.push_str("Assistant: ");
114 full_prompt
115 }
116
117 fn parse_cursor_agent_response(
119 &self,
120 lines: &[String],
121 ) -> Result<(Message, Usage), ProviderError> {
122 for line in lines {
124 if let Ok(json_value) = serde_json::from_str::<Value>(line) {
125 if let Some(type_val) = json_value.get("type") {
126 if type_val == "result" {
127 let text_content = if let Some(result) = json_value.get("result") {
128 let result_str = result.as_str().unwrap_or("").to_string();
129
130 if result_str.is_empty() {
131 if json_value
132 .get("is_error")
133 .and_then(|v| v.as_bool())
134 .unwrap_or(false)
135 {
136 "Error: cursor-agent returned an error response".to_string()
137 } else {
138 "cursor-agent completed successfully but returned no content"
139 .to_string()
140 }
141 } else {
142 result_str
143 }
144 } else {
145 format!("Raw cursor-agent response: {}", line)
146 };
147
148 let message_content = vec![MessageContent::text(text_content)];
149 let response_message = Message::new(
150 Role::Assistant,
151 chrono::Utc::now().timestamp(),
152 message_content,
153 );
154
155 let usage = Usage::default();
156
157 return Ok((response_message, usage));
158 }
159 }
160 }
161 }
162
163 let response_text = lines.join("\n");
165
166 let message_content = vec![MessageContent::text(response_text)];
167 let response_message = Message::new(
168 Role::Assistant,
169 chrono::Utc::now().timestamp(),
170 message_content,
171 );
172 let usage = Usage::default();
173
174 Ok((response_message, usage))
175 }
176
177 async fn execute_command(
178 &self,
179 system: &str,
180 messages: &[Message],
181 _tools: &[Tool],
182 ) -> Result<Vec<String>, ProviderError> {
183 let prompt = self.messages_to_cursor_agent_format(system, messages);
184
185 if std::env::var("ASTER_CURSOR_AGENT_DEBUG").is_ok() {
186 println!("=== CURSOR AGENT PROVIDER DEBUG ===");
187 println!("Command: {:?}", self.command);
188 println!("Original system prompt length: {} chars", system.len());
189 println!(
190 "Filtered system prompt length: {} chars",
191 filter_extensions_from_system_prompt(system).len()
192 );
193 println!("Full prompt: {}", prompt);
194 println!("Model: {}", self.model.model_name);
195 println!("================================");
196 }
197
198 let mut cmd = Command::new(&self.command);
199 configure_command_no_window(&mut cmd);
200
201 if let Ok(path) = SearchPaths::builder().with_npm().path() {
202 cmd.env("PATH", path);
203 }
204
205 if CURSOR_AGENT_KNOWN_MODELS.contains(&self.model.model_name.as_str()) {
207 cmd.arg("--model").arg(&self.model.model_name);
208 }
209
210 cmd.arg("-p")
211 .arg(&prompt)
212 .arg("--output-format")
213 .arg("json")
214 .arg("--force");
215
216 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
217
218 let mut child = cmd
219 .spawn()
220 .map_err(|e| ProviderError::RequestFailed(format!(
221 "Failed to spawn cursor-agent CLI command '{:?}': {}. \
222 Make sure the cursor-agent CLI is installed and available in the configured search paths, or set CURSOR_AGENT_COMMAND in your config to the correct path.",
223 self.command, e
224 )))?;
225
226 let stdout = child
227 .stdout
228 .take()
229 .ok_or_else(|| ProviderError::RequestFailed("Failed to capture stdout".to_string()))?;
230
231 let mut reader = BufReader::new(stdout);
232 let mut lines = Vec::new();
233 let mut line = String::new();
234
235 loop {
236 line.clear();
237 match reader.read_line(&mut line).await {
238 Ok(0) => break, Ok(_) => {
240 let trimmed = line.trim();
241 if !trimmed.is_empty() {
242 lines.push(trimmed.to_string());
243 }
244 }
245 Err(e) => {
246 return Err(ProviderError::RequestFailed(format!(
247 "Failed to read output: {}",
248 e
249 )));
250 }
251 }
252 }
253
254 let exit_status = child.wait().await.map_err(|e| {
255 ProviderError::RequestFailed(format!("Failed to wait for command: {}", e))
256 })?;
257
258 if !exit_status.success() {
259 if !self.get_authentication_status().await {
260 return Err(ProviderError::Authentication(
261 "You are not logged in to cursor-agent. Please run 'cursor-agent login' to authenticate first."
262 .to_string()));
263 }
264 return Err(ProviderError::RequestFailed(format!(
265 "Command failed with exit code: {:?}",
266 exit_status.code()
267 )));
268 }
269
270 tracing::debug!("Command executed successfully, got {} lines", lines.len());
271 for (i, line) in lines.iter().enumerate() {
272 tracing::debug!("Line {}: {}", i, line);
273 }
274
275 Ok(lines)
276 }
277
278 fn generate_simple_session_description(
280 &self,
281 messages: &[Message],
282 ) -> Result<(Message, ProviderUsage), ProviderError> {
283 let description = messages
285 .iter()
286 .find(|m| m.role == Role::User)
287 .and_then(|m| {
288 m.content.iter().find_map(|c| match c {
289 MessageContent::Text(text_content) => Some(&text_content.text),
290 _ => None,
291 })
292 })
293 .map(|text| {
294 text.split_whitespace()
296 .take(4)
297 .collect::<Vec<_>>()
298 .join(" ")
299 })
300 .unwrap_or_else(|| "Simple task".to_string());
301
302 if std::env::var("ASTER_CURSOR_AGENT_DEBUG").is_ok() {
303 println!("=== CURSOR AGENT PROVIDER DEBUG ===");
304 println!("Generated simple session description: {}", description);
305 println!("Skipped subprocess call for session description");
306 println!("================================");
307 }
308
309 let message = Message::new(
310 Role::Assistant,
311 chrono::Utc::now().timestamp(),
312 vec![MessageContent::text(description.clone())],
313 );
314
315 let usage = Usage::default();
316
317 Ok((
318 message,
319 ProviderUsage::new(self.model.model_name.clone(), usage),
320 ))
321 }
322}
323
324#[async_trait]
325impl Provider for CursorAgentProvider {
326 fn metadata() -> ProviderMetadata {
327 ProviderMetadata::new(
328 "cursor-agent",
329 "Cursor Agent",
330 "Execute AI models via cursor-agent CLI tool",
331 CURSOR_AGENT_DEFAULT_MODEL,
332 CURSOR_AGENT_KNOWN_MODELS.to_vec(),
333 CURSOR_AGENT_DOC_URL,
334 vec![ConfigKey::from_value_type::<CursorAgentCommand>(
335 true, false,
336 )],
337 )
338 }
339
340 fn get_name(&self) -> &str {
341 &self.name
342 }
343
344 fn get_model_config(&self) -> ModelConfig {
345 self.model.clone()
347 }
348
349 #[tracing::instrument(
350 skip(self, model_config, system, messages, tools),
351 fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
352 )]
353 async fn complete_with_model(
354 &self,
355 model_config: &ModelConfig,
356 system: &str,
357 messages: &[Message],
358 tools: &[Tool],
359 ) -> Result<(Message, ProviderUsage), ProviderError> {
360 if system.contains("four words or less") || system.contains("4 words or less") {
362 return self.generate_simple_session_description(messages);
363 }
364
365 let lines = self.execute_command(system, messages, tools).await?;
366
367 let (message, usage) = self.parse_cursor_agent_response(&lines)?;
368
369 let payload = json!({
371 "command": self.command,
372 "model": model_config.model_name,
373 "system": system,
374 "messages": messages.len()
375 });
376
377 let response = json!({
378 "lines": lines.len(),
379 "usage": usage
380 });
381
382 let mut log = RequestLog::start(&self.model, &payload)?;
383 log.write(&response, Some(&usage))?;
384
385 Ok((
386 message,
387 ProviderUsage::new(model_config.model_name.clone(), usage),
388 ))
389 }
390}