Skip to main content

agent_config/agents/
cursor.rs

1//! Cursor integration.
2//!
3//! Hook surface: `<scope>/.cursor/hooks.json`. Cursor uses lowerCamelCase
4//! event names and requires a top-level `"version": 1`.
5//!
6//! ```json
7//! {
8//!   "version": 1,
9//!   "hooks": {
10//!     "preToolUse": [
11//!       { "command": "...", "matcher": "Shell", "_agent_config_tag": "myapp" }
12//!     ]
13//!   }
14//! }
15//! ```
16
17use std::path::PathBuf;
18
19use serde_json::{json, Value};
20
21use crate::agents::planning as agent_planning;
22use crate::error::AgentConfigError;
23use crate::integration::{InstallReport, Integration, McpSurface, SkillSurface, UninstallReport};
24use crate::paths;
25use crate::plan::{InstallPlan, PlanTarget, UninstallPlan};
26use crate::scope::{Scope, ScopeKind};
27use crate::spec::{Event, HookSpec, Matcher, McpSpec, SkillSpec};
28use crate::status::StatusReport;
29use crate::util::{
30    file_lock, json_patch, mcp_json_object, ownership, planning, safe_fs, skills_dir,
31};
32
33/// Cursor (the AI editor and CLI).
34#[derive(Debug, Clone, Copy, Default)]
35pub struct CursorAgent {
36    _private: (),
37}
38
39impl CursorAgent {
40    /// Construct an instance. The struct is stateless.
41    pub const fn new() -> Self {
42        Self { _private: () }
43    }
44
45    fn hooks_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
46        Ok(match scope {
47            Scope::Global => paths::cursor_home()?.join("hooks.json"),
48            Scope::Local(p) => p.join(".cursor").join("hooks.json"),
49        })
50    }
51
52    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
53        Ok(match scope {
54            Scope::Global => paths::cursor_mcp_user_file()?,
55            Scope::Local(p) => p.join(".cursor").join("mcp.json"),
56        })
57    }
58
59    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
60        Ok(match scope {
61            Scope::Global => paths::cursor_home()?.join("skills"),
62            Scope::Local(p) => p.join(".cursor").join("skills"),
63        })
64    }
65}
66
67impl Integration for CursorAgent {
68    fn id(&self) -> &'static str {
69        "cursor"
70    }
71
72    fn display_name(&self) -> &'static str {
73        "Cursor"
74    }
75
76    fn supported_scopes(&self) -> &'static [ScopeKind] {
77        &[ScopeKind::Global, ScopeKind::Local]
78    }
79
80    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
81        HookSpec::validate_tag(tag)?;
82        let p = Self::hooks_path(scope)?;
83        let presence = json_patch::tagged_hook_presence(&p, &["hooks"], tag)?;
84        Ok(StatusReport::for_tagged_hook(tag, p, presence))
85    }
86
87    fn plan_install(
88        &self,
89        scope: &Scope,
90        spec: &HookSpec,
91    ) -> Result<InstallPlan, AgentConfigError> {
92        HookSpec::validate_tag(&spec.tag)?;
93        let target = PlanTarget::Hook {
94            integration_id: Integration::id(self),
95            scope: scope.clone(),
96            tag: spec.tag.clone(),
97        };
98        let p = Self::hooks_path(scope)?;
99        let event_key = event_to_string(&spec.event);
100        let matcher_str = matcher_to_cursor(&spec.matcher);
101        let entry = json!({
102            "command": spec.command.render_shell(),
103            "matcher": matcher_str,
104        });
105        let mut changes = Vec::new();
106        planning::plan_tagged_json_upsert(
107            &mut changes,
108            &p,
109            &["hooks", event_key.as_str()],
110            &spec.tag,
111            entry,
112            |root| {
113                if root.get("version").is_none() {
114                    if let Some(obj) = root.as_object_mut() {
115                        obj.insert("version".into(), json!(1));
116                    }
117                }
118            },
119        )?;
120        Ok(InstallPlan::from_changes(target, changes))
121    }
122
123    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
124        HookSpec::validate_tag(tag)?;
125        let target = PlanTarget::Hook {
126            integration_id: Integration::id(self),
127            scope: scope.clone(),
128            tag: tag.to_string(),
129        };
130        let p = Self::hooks_path(scope)?;
131        let mut changes = Vec::new();
132        planning::plan_tagged_json_remove_under(
133            &mut changes,
134            &p,
135            &["hooks"],
136            tag,
137            is_effectively_empty,
138            true,
139        )?;
140        Ok(UninstallPlan::from_changes(target, changes))
141    }
142
143    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
144        HookSpec::validate_tag(&spec.tag)?;
145        let mut report = InstallReport::default();
146
147        let p = Self::hooks_path(scope)?;
148        scope.ensure_contained(&p)?;
149        file_lock::with_lock(&p, || {
150            let mut root = json_patch::read_or_empty(&p)?;
151
152            // Cursor requires top-level version: 1.
153            if root.get("version").is_none() {
154                if let Some(obj) = root.as_object_mut() {
155                    obj.insert("version".into(), json!(1));
156                }
157            }
158
159            let event_key = event_to_string(&spec.event);
160            let matcher_str = matcher_to_cursor(&spec.matcher);
161
162            let entry = json!({
163                "command": spec.command.render_shell(),
164                "matcher": matcher_str,
165            });
166
167            let changed = json_patch::upsert_tagged_array_entry(
168                &mut root,
169                &["hooks", &event_key],
170                &spec.tag,
171                entry,
172            )?;
173
174            if changed {
175                let bytes = json_patch::to_pretty(&root);
176                let outcome = safe_fs::write(scope, &p, &bytes, true)?;
177                if outcome.existed {
178                    report.patched.push(outcome.path.clone());
179                } else {
180                    report.created.push(outcome.path.clone());
181                }
182                if let Some(b) = outcome.backup {
183                    report.backed_up.push(b);
184                }
185            } else {
186                report.already_installed = true;
187            }
188            Ok::<(), AgentConfigError>(())
189        })?;
190
191        Ok(report)
192    }
193
194    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
195        HookSpec::validate_tag(tag)?;
196        let mut report = UninstallReport::default();
197
198        let p = Self::hooks_path(scope)?;
199        scope.ensure_contained(&p)?;
200        if p.exists() {
201            file_lock::with_lock(&p, || {
202                let mut root = json_patch::read_or_empty(&p)?;
203                let changed =
204                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
205
206                if !changed {
207                    report.not_installed = true;
208                    return Ok(());
209                }
210
211                if is_effectively_empty(&root) {
212                    let bytes = json_patch::to_pretty(&root);
213                    if safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
214                        report.restored.push(p.clone());
215                    } else {
216                        safe_fs::remove_file(scope, &p)?;
217                        report.removed.push(p.clone());
218                    }
219                } else {
220                    let bytes = json_patch::to_pretty(&root);
221                    safe_fs::write(scope, &p, &bytes, false)?;
222                    report.patched.push(p.clone());
223                }
224                Ok::<(), AgentConfigError>(())
225            })?;
226        } else {
227            report.not_installed = true;
228        }
229
230        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
231            report.not_installed = true;
232        }
233
234        Ok(report)
235    }
236}
237
238impl McpSurface for CursorAgent {
239    fn id(&self) -> &'static str {
240        "cursor"
241    }
242
243    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
244        &[ScopeKind::Global, ScopeKind::Local]
245    }
246
247    fn mcp_status(
248        &self,
249        scope: &Scope,
250        name: &str,
251        expected_owner: &str,
252    ) -> Result<StatusReport, AgentConfigError> {
253        McpSpec::validate_name(name)?;
254        let cfg = Self::mcp_path(scope)?;
255        let ledger = ownership::mcp_ledger_for(&cfg);
256        let presence = mcp_json_object::config_presence(&cfg, name)?;
257        let recorded = ownership::owner_of(&ledger, name)?;
258        Ok(StatusReport::for_mcp(
259            name,
260            cfg,
261            ledger,
262            presence,
263            expected_owner,
264            recorded,
265        ))
266    }
267
268    fn plan_install_mcp(
269        &self,
270        scope: &Scope,
271        spec: &McpSpec,
272    ) -> Result<InstallPlan, AgentConfigError> {
273        agent_planning::mcp_json_object_install(
274            McpSurface::id(self),
275            scope,
276            spec,
277            Self::mcp_path(scope),
278        )
279    }
280
281    fn plan_uninstall_mcp(
282        &self,
283        scope: &Scope,
284        name: &str,
285        owner_tag: &str,
286    ) -> Result<UninstallPlan, AgentConfigError> {
287        agent_planning::mcp_json_object_uninstall(
288            McpSurface::id(self),
289            scope,
290            name,
291            owner_tag,
292            Self::mcp_path(scope),
293        )
294    }
295
296    fn install_mcp(
297        &self,
298        scope: &Scope,
299        spec: &McpSpec,
300    ) -> Result<InstallReport, AgentConfigError> {
301        spec.validate()?;
302        let cfg = Self::mcp_path(scope)?;
303        spec.validate_local_secret_policy(scope)?;
304        scope.ensure_contained(&cfg)?;
305        let ledger = ownership::mcp_ledger_for(&cfg);
306        mcp_json_object::install(&cfg, &ledger, spec)
307    }
308
309    fn uninstall_mcp(
310        &self,
311        scope: &Scope,
312        name: &str,
313        owner_tag: &str,
314    ) -> Result<UninstallReport, AgentConfigError> {
315        McpSpec::validate_name(name)?;
316        HookSpec::validate_tag(owner_tag)?;
317        let cfg = Self::mcp_path(scope)?;
318        scope.ensure_contained(&cfg)?;
319        let ledger = ownership::mcp_ledger_for(&cfg);
320        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
321    }
322}
323
324impl SkillSurface for CursorAgent {
325    fn id(&self) -> &'static str {
326        "cursor"
327    }
328
329    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
330        &[ScopeKind::Global, ScopeKind::Local]
331    }
332
333    fn skill_status(
334        &self,
335        scope: &Scope,
336        name: &str,
337        expected_owner: &str,
338    ) -> Result<StatusReport, AgentConfigError> {
339        SkillSpec::validate_name(name)?;
340        let root = Self::skills_root(scope)?;
341        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
342        let recorded = ownership::owner_of(&ledger, name)?;
343        Ok(StatusReport::for_skill(
344            name,
345            dir,
346            manifest,
347            ledger,
348            expected_owner,
349            recorded,
350        ))
351    }
352
353    fn plan_install_skill(
354        &self,
355        scope: &Scope,
356        spec: &SkillSpec,
357    ) -> Result<InstallPlan, AgentConfigError> {
358        agent_planning::skill_install(
359            SkillSurface::id(self),
360            scope,
361            spec,
362            Self::skills_root(scope),
363        )
364    }
365
366    fn plan_uninstall_skill(
367        &self,
368        scope: &Scope,
369        name: &str,
370        owner_tag: &str,
371    ) -> Result<UninstallPlan, AgentConfigError> {
372        agent_planning::skill_uninstall(
373            SkillSurface::id(self),
374            scope,
375            name,
376            owner_tag,
377            Self::skills_root(scope),
378        )
379    }
380
381    fn install_skill(
382        &self,
383        scope: &Scope,
384        spec: &SkillSpec,
385    ) -> Result<InstallReport, AgentConfigError> {
386        let root = Self::skills_root(scope)?;
387        scope.ensure_contained(&root)?;
388        skills_dir::install(&root, spec)
389    }
390
391    fn uninstall_skill(
392        &self,
393        scope: &Scope,
394        name: &str,
395        owner_tag: &str,
396    ) -> Result<UninstallReport, AgentConfigError> {
397        let root = Self::skills_root(scope)?;
398        scope.ensure_contained(&root)?;
399        skills_dir::uninstall(&root, name, owner_tag)
400    }
401}
402
403/// True if the document has nothing meaningful left (only `{"version": ...}`
404/// or fully empty).
405fn is_effectively_empty(v: &Value) -> bool {
406    let Some(obj) = v.as_object() else {
407        return true;
408    };
409    obj.iter().all(|(k, _)| k == "version")
410}
411
412/// Map our [`Matcher`] enum to Cursor's matcher syntax.
413///
414/// For `preToolUse`/`postToolUse`, matcher is a tool-type literal:
415/// `Shell`, `Read`, `Write`, `Edit`, `Grep`, `Delete`, `Task`,
416/// or `MCP:<tool_name>`. For shell execution Cursor uses `Shell` (Claude's
417/// equivalent is `Bash`).
418fn matcher_to_cursor(m: &Matcher) -> String {
419    match m {
420        Matcher::All => "*".to_string(),
421        Matcher::Bash => "Shell".to_string(),
422        Matcher::Exact(s) => s.clone(),
423        Matcher::AnyOf(names) => names.join("|"),
424        Matcher::Regex(s) => s.clone(),
425    }
426}
427
428fn event_to_string(e: &Event) -> String {
429    match e {
430        Event::PreToolUse => "preToolUse".into(),
431        Event::PostToolUse => "postToolUse".into(),
432        Event::Custom(s) => s.clone(),
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use serde_json::json;
440    use tempfile::tempdir;
441
442    fn local_spec(tag: &str) -> HookSpec {
443        HookSpec::builder(tag)
444            .command_program("myapp", ["hook"])
445            .matcher(Matcher::Bash)
446            .event(Event::PreToolUse)
447            .build()
448    }
449
450    fn read_json(p: &std::path::Path) -> Value {
451        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
452    }
453
454    #[test]
455    fn writes_lowercamel_event_and_shell_matcher() {
456        let dir = tempdir().unwrap();
457        let agent = CursorAgent::new();
458        let scope = Scope::Local(dir.path().to_path_buf());
459        agent.install(&scope, &local_spec("alpha")).unwrap();
460
461        let v = read_json(&dir.path().join(".cursor/hooks.json"));
462        assert_eq!(v["version"], json!(1));
463        assert_eq!(v["hooks"]["preToolUse"][0]["matcher"], json!("Shell"));
464        assert_eq!(v["hooks"]["preToolUse"][0]["command"], json!("myapp hook"));
465        assert_eq!(
466            v["hooks"]["preToolUse"][0]["_agent_config_tag"],
467            json!("alpha")
468        );
469    }
470
471    #[test]
472    fn install_idempotent() {
473        let dir = tempdir().unwrap();
474        let agent = CursorAgent::new();
475        let scope = Scope::Local(dir.path().to_path_buf());
476        let r1 = agent.install(&scope, &local_spec("alpha")).unwrap();
477        let r2 = agent.install(&scope, &local_spec("alpha")).unwrap();
478        assert!(!r1.already_installed && r2.already_installed);
479    }
480
481    #[test]
482    fn install_preserves_user_hooks_and_other_settings() {
483        let dir = tempdir().unwrap();
484        let p = dir.path().join(".cursor/hooks.json");
485        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
486        std::fs::write(
487            &p,
488            r#"{
489  "version": 1,
490  "hooks": { "preToolUse": [
491    { "command": "user-script", "matcher": "Edit" }
492  ]},
493  "beforeShellExecution": [
494    { "command": "user-net-check", "matcher": "curl" }
495  ]
496}"#,
497        )
498        .unwrap();
499
500        let agent = CursorAgent::new();
501        let scope = Scope::Local(dir.path().to_path_buf());
502        agent.install(&scope, &local_spec("alpha")).unwrap();
503
504        let v = read_json(&p);
505        assert_eq!(v["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
506        assert_eq!(
507            v["beforeShellExecution"][0]["command"],
508            json!("user-net-check")
509        );
510    }
511
512    #[test]
513    fn uninstall_removes_only_our_entry_and_keeps_user_data() {
514        let dir = tempdir().unwrap();
515        let p = dir.path().join(".cursor/hooks.json");
516        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
517        std::fs::write(
518            &p,
519            r#"{
520  "version": 1,
521  "hooks": { "preToolUse": [
522    { "command": "user", "matcher": "Edit" }
523  ]}
524}"#,
525        )
526        .unwrap();
527
528        let agent = CursorAgent::new();
529        let scope = Scope::Local(dir.path().to_path_buf());
530        agent.install(&scope, &local_spec("alpha")).unwrap();
531        agent.uninstall(&scope, "alpha").unwrap();
532
533        let v = read_json(&p);
534        let arr = v["hooks"]["preToolUse"].as_array().unwrap();
535        assert_eq!(arr.len(), 1);
536        assert_eq!(arr[0]["matcher"], json!("Edit"));
537    }
538
539    #[test]
540    fn uninstall_only_us_restores_backup_or_removes() {
541        let dir = tempdir().unwrap();
542        let agent = CursorAgent::new();
543        let scope = Scope::Local(dir.path().to_path_buf());
544        agent.install(&scope, &local_spec("alpha")).unwrap();
545        let p = dir.path().join(".cursor/hooks.json");
546        assert!(p.exists());
547
548        agent.uninstall(&scope, "alpha").unwrap();
549        assert!(
550            !p.exists(),
551            "we authored the file; should be removed on uninstall"
552        );
553    }
554
555    #[test]
556    fn matcher_bash_maps_to_shell_not_bash() {
557        // This is the most common cross-tool footgun; pin the behavior.
558        assert_eq!(matcher_to_cursor(&Matcher::Bash), "Shell");
559    }
560
561    #[test]
562    fn post_tool_use_lowercamel() {
563        let dir = tempdir().unwrap();
564        let agent = CursorAgent::new();
565        let scope = Scope::Local(dir.path().to_path_buf());
566        let spec = HookSpec::builder("alpha")
567            .command_program("noop", [] as [&str; 0])
568            .event(Event::PostToolUse)
569            .build();
570        agent.install(&scope, &spec).unwrap();
571        let v = read_json(&dir.path().join(".cursor/hooks.json"));
572        assert!(v["hooks"]["postToolUse"].is_array());
573    }
574
575    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
576        McpSpec::builder(name)
577            .owner(owner)
578            .stdio("npx", ["-y", "@example/server"])
579            .build()
580    }
581
582    #[test]
583    fn install_mcp_writes_dot_cursor_mcp_json() {
584        let dir = tempdir().unwrap();
585        let agent = CursorAgent::new();
586        let scope = Scope::Local(dir.path().to_path_buf());
587        agent
588            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
589            .unwrap();
590        let cfg = dir.path().join(".cursor/mcp.json");
591        assert!(cfg.exists());
592        let v = read_json(&cfg);
593        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
594    }
595
596    #[test]
597    fn install_mcp_separate_from_hooks_file() {
598        let dir = tempdir().unwrap();
599        let agent = CursorAgent::new();
600        let scope = Scope::Local(dir.path().to_path_buf());
601        agent.install(&scope, &local_spec("alpha")).unwrap();
602        agent
603            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
604            .unwrap();
605        assert!(dir.path().join(".cursor/hooks.json").exists());
606        assert!(dir.path().join(".cursor/mcp.json").exists());
607        // Hooks file does not contain the MCP server.
608        let hooks = read_json(&dir.path().join(".cursor/hooks.json"));
609        assert!(hooks.get("mcpServers").is_none());
610    }
611
612    #[test]
613    fn install_mcp_idempotent() {
614        let dir = tempdir().unwrap();
615        let agent = CursorAgent::new();
616        let scope = Scope::Local(dir.path().to_path_buf());
617        let spec = local_mcp_spec("github", "myapp");
618        agent.install_mcp(&scope, &spec).unwrap();
619        let r2 = agent.install_mcp(&scope, &spec).unwrap();
620        assert!(r2.already_installed);
621    }
622
623    #[test]
624    fn uninstall_mcp_owner_mismatch_refused() {
625        let dir = tempdir().unwrap();
626        let agent = CursorAgent::new();
627        let scope = Scope::Local(dir.path().to_path_buf());
628        agent
629            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
630            .unwrap();
631        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
632        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
633    }
634
635    #[test]
636    fn uninstall_mcp_round_trip() {
637        let dir = tempdir().unwrap();
638        let agent = CursorAgent::new();
639        let scope = Scope::Local(dir.path().to_path_buf());
640        agent
641            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
642            .unwrap();
643        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
644        assert!(!dir.path().join(".cursor/mcp.json").exists());
645    }
646}