1use 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#[derive(Debug, Clone, Copy, Default)]
35pub struct CursorAgent {
36 _private: (),
37}
38
39impl CursorAgent {
40 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 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
403fn 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
412fn 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 other => other.as_str().into(),
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use serde_json::json;
441 use tempfile::tempdir;
442
443 fn local_spec(tag: &str) -> HookSpec {
444 HookSpec::builder(tag)
445 .command_program("myapp", ["hook"])
446 .matcher(Matcher::Bash)
447 .event(Event::PreToolUse)
448 .build()
449 }
450
451 fn read_json(p: &std::path::Path) -> Value {
452 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
453 }
454
455 #[test]
456 fn writes_lowercamel_event_and_shell_matcher() {
457 let dir = tempdir().unwrap();
458 let agent = CursorAgent::new();
459 let scope = Scope::Local(dir.path().to_path_buf());
460 agent.install(&scope, &local_spec("alpha")).unwrap();
461
462 let v = read_json(&dir.path().join(".cursor/hooks.json"));
463 assert_eq!(v["version"], json!(1));
464 assert_eq!(v["hooks"]["preToolUse"][0]["matcher"], json!("Shell"));
465 assert_eq!(v["hooks"]["preToolUse"][0]["command"], json!("myapp hook"));
466 assert_eq!(
467 v["hooks"]["preToolUse"][0]["_agent_config_tag"],
468 json!("alpha")
469 );
470 }
471
472 #[test]
473 fn install_idempotent() {
474 let dir = tempdir().unwrap();
475 let agent = CursorAgent::new();
476 let scope = Scope::Local(dir.path().to_path_buf());
477 let r1 = agent.install(&scope, &local_spec("alpha")).unwrap();
478 let r2 = agent.install(&scope, &local_spec("alpha")).unwrap();
479 assert!(!r1.already_installed && r2.already_installed);
480 }
481
482 #[test]
483 fn install_preserves_user_hooks_and_other_settings() {
484 let dir = tempdir().unwrap();
485 let p = dir.path().join(".cursor/hooks.json");
486 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
487 std::fs::write(
488 &p,
489 r#"{
490 "version": 1,
491 "hooks": { "preToolUse": [
492 { "command": "user-script", "matcher": "Edit" }
493 ]},
494 "beforeShellExecution": [
495 { "command": "user-net-check", "matcher": "curl" }
496 ]
497}"#,
498 )
499 .unwrap();
500
501 let agent = CursorAgent::new();
502 let scope = Scope::Local(dir.path().to_path_buf());
503 agent.install(&scope, &local_spec("alpha")).unwrap();
504
505 let v = read_json(&p);
506 assert_eq!(v["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
507 assert_eq!(
508 v["beforeShellExecution"][0]["command"],
509 json!("user-net-check")
510 );
511 }
512
513 #[test]
514 fn uninstall_removes_only_our_entry_and_keeps_user_data() {
515 let dir = tempdir().unwrap();
516 let p = dir.path().join(".cursor/hooks.json");
517 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
518 std::fs::write(
519 &p,
520 r#"{
521 "version": 1,
522 "hooks": { "preToolUse": [
523 { "command": "user", "matcher": "Edit" }
524 ]}
525}"#,
526 )
527 .unwrap();
528
529 let agent = CursorAgent::new();
530 let scope = Scope::Local(dir.path().to_path_buf());
531 agent.install(&scope, &local_spec("alpha")).unwrap();
532 agent.uninstall(&scope, "alpha").unwrap();
533
534 let v = read_json(&p);
535 let arr = v["hooks"]["preToolUse"].as_array().unwrap();
536 assert_eq!(arr.len(), 1);
537 assert_eq!(arr[0]["matcher"], json!("Edit"));
538 }
539
540 #[test]
541 fn uninstall_only_us_restores_backup_or_removes() {
542 let dir = tempdir().unwrap();
543 let agent = CursorAgent::new();
544 let scope = Scope::Local(dir.path().to_path_buf());
545 agent.install(&scope, &local_spec("alpha")).unwrap();
546 let p = dir.path().join(".cursor/hooks.json");
547 assert!(p.exists());
548
549 agent.uninstall(&scope, "alpha").unwrap();
550 assert!(
551 !p.exists(),
552 "we authored the file; should be removed on uninstall"
553 );
554 }
555
556 #[test]
557 fn matcher_bash_maps_to_shell_not_bash() {
558 assert_eq!(matcher_to_cursor(&Matcher::Bash), "Shell");
560 }
561
562 #[test]
563 fn post_tool_use_lowercamel() {
564 let dir = tempdir().unwrap();
565 let agent = CursorAgent::new();
566 let scope = Scope::Local(dir.path().to_path_buf());
567 let spec = HookSpec::builder("alpha")
568 .command_program("noop", [] as [&str; 0])
569 .event(Event::PostToolUse)
570 .build();
571 agent.install(&scope, &spec).unwrap();
572 let v = read_json(&dir.path().join(".cursor/hooks.json"));
573 assert!(v["hooks"]["postToolUse"].is_array());
574 }
575
576 fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
577 McpSpec::builder(name)
578 .owner(owner)
579 .stdio("npx", ["-y", "@example/server"])
580 .build()
581 }
582
583 #[test]
584 fn install_mcp_writes_dot_cursor_mcp_json() {
585 let dir = tempdir().unwrap();
586 let agent = CursorAgent::new();
587 let scope = Scope::Local(dir.path().to_path_buf());
588 agent
589 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
590 .unwrap();
591 let cfg = dir.path().join(".cursor/mcp.json");
592 assert!(cfg.exists());
593 let v = read_json(&cfg);
594 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
595 }
596
597 #[test]
598 fn install_mcp_separate_from_hooks_file() {
599 let dir = tempdir().unwrap();
600 let agent = CursorAgent::new();
601 let scope = Scope::Local(dir.path().to_path_buf());
602 agent.install(&scope, &local_spec("alpha")).unwrap();
603 agent
604 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
605 .unwrap();
606 assert!(dir.path().join(".cursor/hooks.json").exists());
607 assert!(dir.path().join(".cursor/mcp.json").exists());
608 let hooks = read_json(&dir.path().join(".cursor/hooks.json"));
610 assert!(hooks.get("mcpServers").is_none());
611 }
612
613 #[test]
614 fn install_mcp_idempotent() {
615 let dir = tempdir().unwrap();
616 let agent = CursorAgent::new();
617 let scope = Scope::Local(dir.path().to_path_buf());
618 let spec = local_mcp_spec("github", "myapp");
619 agent.install_mcp(&scope, &spec).unwrap();
620 let r2 = agent.install_mcp(&scope, &spec).unwrap();
621 assert!(r2.already_installed);
622 }
623
624 #[test]
625 fn uninstall_mcp_owner_mismatch_refused() {
626 let dir = tempdir().unwrap();
627 let agent = CursorAgent::new();
628 let scope = Scope::Local(dir.path().to_path_buf());
629 agent
630 .install_mcp(&scope, &local_mcp_spec("github", "appA"))
631 .unwrap();
632 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
633 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
634 }
635
636 #[test]
637 fn uninstall_mcp_round_trip() {
638 let dir = tempdir().unwrap();
639 let agent = CursorAgent::new();
640 let scope = Scope::Local(dir.path().to_path_buf());
641 agent
642 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
643 .unwrap();
644 agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
645 assert!(!dir.path().join(".cursor/mcp.json").exists());
646 }
647}