diff --git a/crates/goose-cli/src/scenario_tests/provider_configs.rs b/crates/goose-cli/src/scenario_tests/provider_configs.rs index 1e8f8019070..8bcbf2f1f9a 100644 --- a/crates/goose-cli/src/scenario_tests/provider_configs.rs +++ b/crates/goose-cli/src/scenario_tests/provider_configs.rs @@ -75,6 +75,7 @@ static PROVIDER_CONFIGS: LazyLock> = LazyLock::new(|| { "claude-3-5-sonnet", Some("No keys available"), ), + ProviderConfig::simple_skip("cursor-agent", "gpt-5", Some("No keys available")), ProviderConfig::simple_skip( "databricks", "databricks-dbrx-instruct", diff --git a/crates/goose/src/providers/cursor_agent.rs b/crates/goose/src/providers/cursor_agent.rs new file mode 100644 index 00000000000..432093df0b5 --- /dev/null +++ b/crates/goose/src/providers/cursor_agent.rs @@ -0,0 +1,497 @@ +use anyhow::Result; +use async_trait::async_trait; +use rmcp::model::Role; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage}; +use super::errors::ProviderError; +use super::utils::emit_debug_trace; +use crate::conversation::message::{Message, MessageContent}; +use crate::impl_provider_default; +use crate::model::ModelConfig; +use rmcp::model::Tool; + +pub const CURSOR_AGENT_DEFAULT_MODEL: &str = "gpt-5"; +pub const CURSOR_AGENT_KNOWN_MODELS: &[&str] = &["gpt-5", "opus-4.1", "sonnet-4"]; + +pub const CURSOR_AGENT_DOC_URL: &str = "https://docs.cursor.com/en/cli/overview"; + +#[derive(Debug, serde::Serialize)] +pub struct CursorAgentProvider { + command: String, + model: ModelConfig, +} + +impl_provider_default!(CursorAgentProvider); + +impl CursorAgentProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let command: String = config + .get_param("CURSOR_AGENT_COMMAND") + .unwrap_or_else(|_| "cursor-agent".to_string()); + + let resolved_command = if !command.contains('/') { + Self::find_cursor_agent_executable(&command).unwrap_or(command) + } else { + command + }; + + Ok(Self { + command: resolved_command, + model, + }) + } + + /// Search for cursor-agent executable in common installation locations + fn find_cursor_agent_executable(command_name: &str) -> Option { + let home = std::env::var("HOME").ok()?; + + let search_paths = vec![ + format!("/opt/homebrew/bin/{}", command_name), + format!("/usr/bin/{}", command_name), + format!("/usr/local/bin/{}", command_name), + format!("{}/.local/bin/{}", home, command_name), + format!("{}/bin/{}", home, command_name), + ]; + + for path in search_paths { + let path_buf = PathBuf::from(&path); + if path_buf.exists() && path_buf.is_file() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = std::fs::metadata(&path_buf) { + let permissions = metadata.permissions(); + if permissions.mode() & 0o111 != 0 { + tracing::info!("Found cursor-agent executable at: {}", path); + return Some(path); + } + } + } + #[cfg(not(unix))] + { + tracing::info!("Found cursor-agent executable at: {}", path); + return Some(path); + } + } + } + + if let Ok(path_var) = std::env::var("PATH") { + #[cfg(unix)] + let path_separator = ':'; + #[cfg(windows)] + let path_separator = ';'; + + for dir in path_var.split(path_separator) { + let path_buf = PathBuf::from(dir).join(command_name); + if path_buf.exists() && path_buf.is_file() { + let full_path = path_buf.to_string_lossy().to_string(); + tracing::info!("Found cursor-agent executable in PATH at: {}", full_path); + return Some(full_path); + } + } + } + + tracing::warn!("Could not find cursor-agent executable in common locations"); + None + } + + /// Filter out the Extensions section from the system prompt + fn filter_extensions_from_system_prompt(&self, system: &str) -> String { + // Find the Extensions section and remove it + if let Some(extensions_start) = system.find("# Extensions") { + // Look for the next major section that starts with # + let after_extensions = &system[extensions_start..]; + if let Some(next_section_pos) = after_extensions[1..].find("\n# ") { + // Found next section, keep everything before Extensions and after the next section + let before_extensions = &system[..extensions_start]; + let next_section_start = extensions_start + next_section_pos + 1; + let after_next_section = &system[next_section_start..]; + format!("{}{}", before_extensions.trim_end(), after_next_section) + } else { + // No next section found, just remove everything from Extensions onward + system[..extensions_start].trim_end().to_string() + } + } else { + // No Extensions section found, return original + system.to_string() + } + } + + /// Convert goose messages to a simple prompt format for cursor-agent CLI + fn messages_to_cursor_agent_format(&self, system: &str, messages: &[Message]) -> String { + let mut full_prompt = String::new(); + + // Add system prompt + let filtered_system = self.filter_extensions_from_system_prompt(system); + full_prompt.push_str(&filtered_system); + full_prompt.push_str("\n\n"); + + // Add conversation history + for message in messages { + let role_prefix = match message.role { + Role::User => "Human: ", + Role::Assistant => "Assistant: ", + }; + full_prompt.push_str(role_prefix); + + for content in &message.content { + match content { + MessageContent::Text(text_content) => { + full_prompt.push_str(&text_content.text); + full_prompt.push('\n'); + } + MessageContent::ToolRequest(tool_request) => { + if let Ok(tool_call) = &tool_request.tool_call { + full_prompt.push_str(&format!( + "Tool Use: {} with args: {}\n", + tool_call.name, tool_call.arguments + )); + } + } + MessageContent::ToolResponse(tool_response) => { + if let Ok(tool_contents) = &tool_response.tool_result { + let content_text = tool_contents + .iter() + .filter_map(|content| match &content.raw { + rmcp::model::RawContent::Text(text_content) => { + Some(text_content.text.as_str()) + } + _ => None, + }) + .collect::>() + .join("\n"); + + full_prompt.push_str(&format!("Tool Result: {}\n", content_text)); + } + } + _ => { + // Skip other content types for now + } + } + } + full_prompt.push('\n'); + } + + full_prompt.push_str("Assistant: "); + full_prompt + } + + /// Parse the JSON response from cursor-agent CLI + fn parse_cursor_agent_response( + &self, + lines: &[String], + ) -> Result<(Message, Usage), ProviderError> { + // Try parsing each line as a JSON object and find the one with type="result" + for line in lines { + if let Ok(json_value) = serde_json::from_str::(line) { + if let Some(type_val) = json_value.get("type") { + if type_val == "result" { + let text_content = if let Some(result) = json_value.get("result") { + let result_str = result.as_str().unwrap_or("").to_string(); + + if result_str.is_empty() { + if json_value + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + "Error: cursor-agent returned an error response".to_string() + } else { + "cursor-agent completed successfully but returned no content" + .to_string() + } + } else { + result_str + } + } else { + format!("Raw cursor-agent response: {}", line) + }; + + let message_content = vec![MessageContent::text(text_content)]; + let response_message = Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp(), + content: message_content, + }; + + let usage = Usage::default(); + + return Ok((response_message, usage)); + } + } + } + } + + // If no valid result line found, fallback to joining all lines + let response_text = lines.join("\n"); + + let message_content = vec![MessageContent::text(response_text)]; + let response_message = Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp(), + content: message_content, + }; + let usage = Usage::default(); + + Ok((response_message, usage)) + } + + async fn execute_command( + &self, + system: &str, + messages: &[Message], + _tools: &[Tool], + ) -> Result, ProviderError> { + let prompt = self.messages_to_cursor_agent_format(system, messages); + + if std::env::var("GOOSE_CURSOR_AGENT_DEBUG").is_ok() { + println!("=== CURSOR AGENT PROVIDER DEBUG ==="); + println!("Command: {}", self.command); + println!("Original system prompt length: {} chars", system.len()); + println!( + "Filtered system prompt length: {} chars", + self.filter_extensions_from_system_prompt(system).len() + ); + println!("Full prompt: {}", prompt); + println!("Model: {}", self.model.model_name); + println!("================================"); + } + + let mut cmd = Command::new(&self.command); + + // Only pass model parameter if it's in the known models list + if CURSOR_AGENT_KNOWN_MODELS.contains(&self.model.model_name.as_str()) { + cmd.arg("-m").arg(&self.model.model_name); + } + + cmd.arg("-p") + .arg(&prompt) + .arg("--output-format") + .arg("json") + .arg("--force"); + + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| ProviderError::RequestFailed(format!( + "Failed to spawn cursor-agent CLI command '{}': {}. \ + Make sure the cursor-agent CLI is installed and in your PATH, or set CURSOR_AGENT_COMMAND in your config to the correct path.", + self.command, e + )))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| ProviderError::RequestFailed("Failed to capture stdout".to_string()))?; + + let mut reader = BufReader::new(stdout); + let mut lines = Vec::new(); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } + } + Err(e) => { + return Err(ProviderError::RequestFailed(format!( + "Failed to read output: {}", + e + ))); + } + } + } + + let exit_status = child.wait().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to wait for command: {}", e)) + })?; + + if !exit_status.success() { + return Err(ProviderError::RequestFailed(format!( + "Command failed with exit code: {:?}", + exit_status.code() + ))); + } + + tracing::debug!("Command executed successfully, got {} lines", lines.len()); + for (i, line) in lines.iter().enumerate() { + tracing::debug!("Line {}: {}", i, line); + } + + Ok(lines) + } + + /// Generate a simple session description without calling subprocess + fn generate_simple_session_description( + &self, + messages: &[Message], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Extract the first user message text + let description = messages + .iter() + .find(|m| m.role == Role::User) + .and_then(|m| { + m.content.iter().find_map(|c| match c { + MessageContent::Text(text_content) => Some(&text_content.text), + _ => None, + }) + }) + .map(|text| { + // Take first few words, limit to 4 words + text.split_whitespace() + .take(4) + .collect::>() + .join(" ") + }) + .unwrap_or_else(|| "Simple task".to_string()); + + if std::env::var("GOOSE_CURSOR_AGENT_DEBUG").is_ok() { + println!("=== CURSOR AGENT PROVIDER DEBUG ==="); + println!("Generated simple session description: {}", description); + println!("Skipped subprocess call for session description"); + println!("================================"); + } + + let message = Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp(), + content: vec![MessageContent::text(description.clone())], + }; + + let usage = Usage::default(); + + Ok(( + message, + ProviderUsage::new(self.model.model_name.clone(), usage), + )) + } +} + +#[async_trait] +impl Provider for CursorAgentProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "cursor-agent", + "Cursor Agent", + "Execute AI models via cursor-agent CLI tool", + CURSOR_AGENT_DEFAULT_MODEL, + CURSOR_AGENT_KNOWN_MODELS.to_vec(), + CURSOR_AGENT_DOC_URL, + vec![ConfigKey::new( + "CURSOR_AGENT_COMMAND", + false, + false, + Some("cursor-agent"), + )], + ) + } + + fn get_model_config(&self) -> ModelConfig { + // Return the model config with appropriate context limit for Cursor models + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Check if this is a session description request (short system prompt asking for 4 words or less) + if system.contains("four words or less") || system.contains("4 words or less") { + return self.generate_simple_session_description(messages); + } + + let lines = self.execute_command(system, messages, tools).await?; + + let (message, usage) = self.parse_cursor_agent_response(&lines)?; + + // Create a dummy payload for debug tracing + let payload = json!({ + "command": self.command, + "model": self.model.model_name, + "system": system, + "messages": messages.len() + }); + + let response = json!({ + "lines": lines.len(), + "usage": usage + }); + + emit_debug_trace(&self.model, &payload, &response, &usage); + + Ok(( + message, + ProviderUsage::new(self.model.model_name.clone(), usage), + )) + } +} + +#[cfg(test)] +mod tests { + use super::ModelConfig; + use super::*; + + #[test] + fn test_cursor_agent_model_config() { + let provider = CursorAgentProvider::default(); + let config = provider.get_model_config(); + + assert_eq!(config.model_name, "gpt-5"); + // Context limit should be set by the ModelConfig + assert!(config.context_limit() > 0); + } + + #[test] + fn test_cursor_agent_invalid_model_no_fallback() { + // Test that an invalid model is kept as-is (no fallback) + let invalid_model = ModelConfig::new_or_fail("invalid-model"); + let provider = CursorAgentProvider::from_env(invalid_model).unwrap(); + let config = provider.get_model_config(); + + assert_eq!(config.model_name, "invalid-model"); + } + + #[test] + fn test_cursor_agent_valid_model() { + // Test that a valid model is preserved + let valid_model = ModelConfig::new_or_fail("gpt-5"); + let provider = CursorAgentProvider::from_env(valid_model).unwrap(); + let config = provider.get_model_config(); + + assert_eq!(config.model_name, "gpt-5"); + } + + #[test] + fn test_filter_extensions_from_system_prompt() { + let provider = CursorAgentProvider::default(); + + let system_with_extensions = "Some system prompt\n\n# Extensions\nSome extension info\n\n# Next Section\nMore content"; + let filtered = provider.filter_extensions_from_system_prompt(system_with_extensions); + assert_eq!(filtered, "Some system prompt\n# Next Section\nMore content"); + + let system_without_extensions = "Some system prompt\n\n# Other Section\nContent"; + let filtered = provider.filter_extensions_from_system_prompt(system_without_extensions); + assert_eq!(filtered, system_without_extensions); + } +} diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 1ebf9734493..19101acb680 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -6,6 +6,7 @@ use super::{ base::{Provider, ProviderMetadata}, bedrock::BedrockProvider, claude_code::ClaudeCodeProvider, + cursor_agent::CursorAgentProvider, databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, gemini_cli::GeminiCliProvider, @@ -45,6 +46,7 @@ pub fn providers() -> Vec { AzureProvider::metadata(), BedrockProvider::metadata(), ClaudeCodeProvider::metadata(), + CursorAgentProvider::metadata(), DatabricksProvider::metadata(), GcpVertexAIProvider::metadata(), GeminiCliProvider::metadata(), @@ -153,6 +155,7 @@ fn create_provider(name: &str, model: ModelConfig) -> Result> "aws_bedrock" => Ok(Arc::new(BedrockProvider::from_env(model)?)), "azure_openai" => Ok(Arc::new(AzureProvider::from_env(model)?)), "claude-code" => Ok(Arc::new(ClaudeCodeProvider::from_env(model)?)), + "cursor-agent" => Ok(Arc::new(CursorAgentProvider::from_env(model)?)), "databricks" => Ok(Arc::new(DatabricksProvider::from_env(model)?)), "gcp_vertex_ai" => Ok(Arc::new(GcpVertexAIProvider::from_env(model)?)), "gemini-cli" => Ok(Arc::new(GeminiCliProvider::from_env(model)?)), diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 60386d2171c..6ddec035b09 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -5,6 +5,7 @@ pub mod azureauth; pub mod base; pub mod bedrock; pub mod claude_code; +pub mod cursor_agent; pub mod databricks; pub mod embedding; pub mod errors; diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 5f917352798..8ab1cf9f60b 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -45,6 +45,7 @@ Goose also supports special "pass-through" providers that work with existing CLI | Provider | Description | Requirements | |-----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [Claude Code](https://www.anthropic.com/claude-code) (`claude-code`) | Uses Anthropic's Claude CLI tool with your Claude Code subscription. Provides access to Claude with 200K context limit. | Claude CLI installed and authenticated, active Claude Code subscription | +| [Cursor Agent](https://docs.cursor.com/en/cli/overview) (`cursor-agent`) | Uses Cursor's AI CLI tool with your Cursor subscription. Provides access to GPT-5, Claude 4, and other models through the cursor-agent command-line interface. | cursor-agent CLI installed and authenticated | | [Gemini CLI](https://ai.google.dev/gemini-api/docs) (`gemini-cli`) | Uses Google's Gemini CLI tool with your Google AI subscription. Provides access to Gemini with 1M context limit. | Gemini CLI installed and authenticated | :::tip CLI Providers @@ -144,14 +145,7 @@ To configure your chosen provider or see available options, run `goose configure │ ◓ Checking your configuration... └ Configuration saved successfully - ``` - Set the model for an individual session using the [`run` command](/docs/guides/goose-cli-commands.md#run-options): - - ```bash - goose run --model goose-claude-4-sonnet -t "initial prompt" - ``` - - +``` diff --git a/documentation/docs/guides/cli-providers.md b/documentation/docs/guides/cli-providers.md index 0432be1f189..a7d3ede5ca0 100644 --- a/documentation/docs/guides/cli-providers.md +++ b/documentation/docs/guides/cli-providers.md @@ -2,18 +2,22 @@ sidebar_position: 8 title: CLI Providers sidebar_label: CLI Providers -description: Use Claude Code or Gemini CLI subscriptions in Goose +description: Use Claude Code, Cursor Agent, or Gemini CLI subscriptions in Goose --- # CLI Providers -Goose supports pass-through providers that integrate with existing CLI tools from Anthropic and Google. These providers allow you to use your existing Claude Code and Google Gemini CLI subscriptions through Goose's interface, adding session management, persistence, and workflow integration capabilities to these tools. +Goose can make use of pass-through providers that integrate with existing CLI tools from Anthropic, Cursor, and Google. These providers allow you to use your existing Claude Code, Cursor Agent, and Google Gemini CLI subscriptions through Goose's interface, adding session management, persistence, and workflow integration capabilities to these tools. + +:::warning + +These providers don’t fully support all Goose features, may have platform or capability limitations, and can sometimes require advanced debugging if issues arise. They’re included here purely as a convenience. ## Why Use CLI Providers? CLI providers are useful if you: -- already have a Claude Code or Google Gemini CLI subscription and want to use it through Goose instead of paying per token +- already have a Claude Code, Cursor, or Google Gemini CLI subscription and want to use it through Goose instead of paying per token - need session persistence to save, resume, and export conversation history - want to use Goose recipes and scheduled tasks to create repeatable workflows - prefer unified commands across different AI providers @@ -57,6 +61,20 @@ The Claude Code provider integrates with Anthropic's [Claude CLI tool](https://c - Active Claude Code subscription - CLI tool authenticated with your Anthropic account +### Cursor Agent + +The Cursor provider integrates with Cursor's [CLI agent](https://docs.cursor.com/en/cli/installation), providing access to through your existing subscription. + +**Features:** + +- integrates with Cursor Agent CLI coding tasks. +- ideal for code-related workflows and file interactions. + +**Requirements:** + +- cursor-agent tool installed and configured. +- CLI tool authenticated. + ### Gemini CLI The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.google.dev/gemini-api/docs), providing access to Gemini models through your Google AI subscription. @@ -103,6 +121,40 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go ◇ Enter a model from that provider: │ default ``` +## Cursor Agent + +1. **Install Cursor agent Tool** + + Follow the [installation instructions for Cursor Agent](https://docs.cursor.com/en/cli/installation) to install and configure the cursor agent tool. + +2. **Authenticate with Cursor** + + Ensure your Cursor Agent is authenticated and working + +3. **Configure goose** + + Set the provider environment variable: + + ```bash + export goose_provider=cursor-agent + ``` + + Or configure through the Goose CLI using `goose configure`: + + ```bash + ┌ goose-configure + │ + ◇ What would you like to configure? + │ configure providers + │ + ◇ Which model provider should we use? + │ Cursor Agent + │ + ◇ Model fetch complete + │ + ◇ Enter a model from that provider: + │ default + ``` ### Gemini CLI @@ -171,6 +223,14 @@ goose session | `GOOSE_PROVIDER` | Set to `claude-code` to use this provider | None | | `CLAUDE_CODE_COMMAND` | Path to the Claude CLI command | `claude` | + +### Cursor Agent Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `GOOSE_PROVIDER` | Set to `cursor-agent` to use this provider | None | +| `CURSOR_AGENT_COMMAND` | Path to the Cursor Agent command | `cursor-agent` | + ### Gemini CLI Configuration | Environment Variable | Description | Default | @@ -182,16 +242,18 @@ goose session ### System Prompt Filtering -Both CLI providers automatically filter out Goose's extension information from system prompts since these CLI tools have their own tool ecosystems. This prevents conflicts and ensures clean interaction with the underlying CLI tools. +The CLI providers automatically filter out Goose's extension information from system prompts since these CLI tools have their own tool ecosystems. This prevents conflicts and ensures clean interaction with the underlying CLI tools. ### Message Translation - **Claude Code**: Converts Goose messages to Claude's JSON message format, handling tool calls and responses appropriately +- **Cursor Agent**: Converts Goose messages to Cursor's JSON message format, handling tool calls and responses appropriately - **Gemini CLI**: Converts messages to simple text prompts with role prefixes (Human:/Assistant:) ### Response Processing - **Claude Code**: Parses JSON responses to extract text content and usage information +- **Cursor Agent**: Parses JSON responses to extract text content and usage information - **Gemini CLI**: Processes plain text responses from the CLI tool ## Error Handling