Skip to main content

everruns_integrations_cursor/
lib.rs

1//! Cursor Cloud Agents integration.
2//!
3//! Decision: use Cursor's public Background/Cloud Agents REST API directly.
4//! Cursor does not publish an official Rust client in the public API docs.
5//! Decision: API key resolves from user connections first, then a per-session
6//! `CURSOR_API_KEY` session secret. The previous process-wide env-var
7//! fallback was removed to enforce per-session credential boundaries.
8
9pub mod client;
10pub mod connection;
11mod tools;
12
13use everruns_core::capabilities::{
14    Capability, CapabilityLocalization, CapabilityStatus, IntegrationPlugin, RiskLevel,
15};
16use everruns_core::connector::ConnectorPlugin;
17use everruns_core::tool_narration::ToolNarrationPhase;
18use everruns_core::tool_types::{ToolCall, ToolDefinition};
19use everruns_core::tools::Tool;
20
21use connection::CursorConnector;
22use tools::{
23    CursorAddFollowupTool, CursorDeleteAgentTool, CursorGetAgentTool, CursorGetConversationTool,
24    CursorKeyInfoTool, CursorLaunchAgentTool, CursorListAgentsTool, CursorListModelsTool,
25    CursorListRepositoriesTool,
26};
27
28inventory::submit! {
29    IntegrationPlugin {
30        experimental_only: false,
31        feature_flag: None,
32        factory: || Box::new(CursorCapability),
33    }
34}
35
36inventory::submit! {
37    ConnectorPlugin {
38        experimental_only: false,
39        factory: || Box::new(CursorConnector),
40    }
41}
42
43pub const CURSOR_API_BASE: &str = "https://api.cursor.com";
44pub const CURSOR_API_KEY_SECRET: &str = "CURSOR_API_KEY";
45pub const CURSOR_CONNECTION_PROVIDER: &str = "cursor";
46pub const CURSOR_API_BASE_ENV: &str = "EVERRUNS_CURSOR_API_BASE";
47
48pub struct CursorCapability;
49
50impl Capability for CursorCapability {
51    fn id(&self) -> &str {
52        "cursor"
53    }
54
55    fn name(&self) -> &str {
56        "Cursor"
57    }
58
59    fn description(&self) -> &str {
60        "Create and manage Cursor Cloud Agents that work asynchronously on GitHub repositories."
61    }
62
63    fn localizations(&self) -> Vec<CapabilityLocalization> {
64        vec![CapabilityLocalization::text(
65            "uk",
66            "Cursor",
67            "Створюйте агентів Cursor Cloud Agents, що асинхронно працюють із репозиторіями \
68             GitHub, і керуйте ними.",
69        )]
70    }
71
72    fn status(&self) -> CapabilityStatus {
73        CapabilityStatus::Available
74    }
75
76    fn icon(&self) -> Option<&str> {
77        Some("code")
78    }
79
80    fn category(&self) -> Option<&str> {
81        Some("Execution")
82    }
83
84    fn risk_level(&self) -> RiskLevel {
85        RiskLevel::High
86    }
87
88    fn system_prompt_addition(&self) -> Option<&str> {
89        Some(
90            "Delegate async coding work to Cursor Cloud Agents when a remote repository task should run outside this session. Launch with a precise repo, base ref, task, and optional branch; track status, send follow-ups while running, inspect the transcript before summarizing, and delete agent records only when asked. Omit model unless the user specifies one.",
91        )
92    }
93
94    fn tools(&self) -> Vec<Box<dyn Tool>> {
95        vec![
96            Box::new(CursorLaunchAgentTool),
97            Box::new(CursorGetAgentTool),
98            Box::new(CursorListAgentsTool),
99            Box::new(CursorAddFollowupTool),
100            Box::new(CursorGetConversationTool),
101            Box::new(CursorDeleteAgentTool),
102            Box::new(CursorListModelsTool),
103            Box::new(CursorListRepositoriesTool),
104            Box::new(CursorKeyInfoTool),
105        ]
106    }
107
108    fn narrate(
109        &self,
110        _tool_def: Option<&ToolDefinition>,
111        tool_call: &ToolCall,
112        phase: ToolNarrationPhase,
113        _locale: Option<&str>,
114        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
115    ) -> Option<String> {
116        let target = tool_call
117            .arguments
118            .get("name")
119            .or_else(|| tool_call.arguments.get("agent_id"))
120            .or_else(|| tool_call.arguments.get("repository"))
121            .and_then(|v| v.as_str())
122            .map(truncate_target);
123
124        let (started, completed, failed) = match tool_call.name.as_str() {
125            "cursor_launch_agent" => (
126                "Starting Cursor agent",
127                "Started Cursor agent",
128                "Failed to start Cursor agent",
129            ),
130            "cursor_get_agent" => (
131                "Checking Cursor agent",
132                "Checked Cursor agent",
133                "Failed to check Cursor agent",
134            ),
135            "cursor_list_agents" => (
136                "Listing Cursor agents",
137                "Listed Cursor agents",
138                "Failed to list Cursor agents",
139            ),
140            "cursor_add_followup" => (
141                "Sending Cursor follow-up",
142                "Sent Cursor follow-up",
143                "Failed to send Cursor follow-up",
144            ),
145            "cursor_get_conversation" => (
146                "Reading Cursor conversation",
147                "Read Cursor conversation",
148                "Failed to read Cursor conversation",
149            ),
150            "cursor_delete_agent" => (
151                "Deleting Cursor agent",
152                "Deleted Cursor agent",
153                "Failed to delete Cursor agent",
154            ),
155            "cursor_list_models" => (
156                "Listing Cursor models",
157                "Listed Cursor models",
158                "Failed to list Cursor models",
159            ),
160            "cursor_list_repositories" => (
161                "Listing Cursor repositories",
162                "Listed Cursor repositories",
163                "Failed to list Cursor repositories",
164            ),
165            "cursor_key_info" => (
166                "Checking Cursor connection",
167                "Checked Cursor connection",
168                "Failed to check Cursor connection",
169            ),
170            _ => return None,
171        };
172
173        let verb = match phase {
174            ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => started,
175            ToolNarrationPhase::Completed => completed,
176            ToolNarrationPhase::Failed => failed,
177        };
178
179        Some(match target {
180            Some(target) => format!("{verb}: {target}"),
181            None => verb.to_string(),
182        })
183    }
184}
185
186fn truncate_target(value: &str) -> String {
187    const MAX_CHARS: usize = 48;
188    let clean = value.trim();
189    if clean.chars().count() <= MAX_CHARS {
190        return clean.to_string();
191    }
192    format!("{}...", clean.chars().take(MAX_CHARS).collect::<String>())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn capability_metadata() {
201        let cap = CursorCapability;
202        assert_eq!(cap.id(), "cursor");
203        assert_eq!(cap.name(), "Cursor");
204        assert_eq!(cap.status(), CapabilityStatus::Available);
205        assert_eq!(cap.icon(), Some("code"));
206        assert_eq!(cap.category(), Some("Execution"));
207        assert_eq!(cap.risk_level(), RiskLevel::High);
208    }
209
210    #[test]
211    fn capability_has_all_tools() {
212        let cap = CursorCapability;
213        let tools = cap.tools();
214        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
215        assert_eq!(tools.len(), 9);
216        assert!(names.contains(&"cursor_launch_agent"));
217        assert!(names.contains(&"cursor_add_followup"));
218        assert!(names.contains(&"cursor_get_conversation"));
219        assert!(names.contains(&"cursor_list_repositories"));
220    }
221
222    #[tokio::test]
223    async fn system_prompt_within_budget() {
224        let cap = CursorCapability;
225        let ctx = everruns_core::capabilities::SystemPromptContext::without_file_store(
226            everruns_core::SessionId::new(),
227        );
228        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
229        assert!(prompt.len() <= 475, "prompt is {} bytes", prompt.len());
230    }
231}