1use super::{
32 FrameworkAdapter, ParsedHookInput, ParsedHookInputBuilder, common, get_first_array_element,
33};
34use crate::model::EventType;
35use std::borrow::Cow;
36use std::path::PathBuf;
37
38const CURSOR_OTEL_KEYS: &[&str] = &[
44 "OTEL_LOGS_EXPORTER",
45 "OTEL_EXPORTER_OTLP_PROTOCOL",
46 "OTEL_EXPORTER_OTLP_ENDPOINT",
47];
48
49pub struct CursorAdapter;
53
54impl FrameworkAdapter for CursorAdapter {
55 fn name(&self) -> &'static str {
56 "cursor"
57 }
58
59 fn display_name(&self) -> &'static str {
60 "Cursor"
61 }
62
63 fn project_config_path(&self) -> PathBuf {
64 PathBuf::from(".cursor/hooks.json")
65 }
66
67 fn user_config_path(&self) -> Option<PathBuf> {
68 dirs::home_dir().map(|h| h.join(".cursor/hooks.json"))
69 }
70
71 fn generate_hooks_config(
72 &self,
73 enabled_events: &[EventType],
74 mi6_bin: &str,
75 _otel_enabled: bool,
76 _otel_port: u16,
77 ) -> serde_json::Value {
78 let mut hooks = serde_json::Map::new();
79
80 let cursor_events: std::collections::HashSet<&str> =
82 self.supported_events().into_iter().collect();
83
84 for event in enabled_events {
86 let cursor_event = canonical_to_cursor(event);
87
88 if !cursor_events.contains(cursor_event.as_ref()) {
90 continue;
91 }
92
93 let command = format!(
95 "{} ingest event {} --framework cursor",
96 mi6_bin, cursor_event
97 );
98
99 let hook_entry = serde_json::json!([{
100 "command": command
101 }]);
102 hooks.insert(cursor_event.into_owned(), hook_entry);
103 }
104
105 for event_name in self.framework_specific_events() {
107 if hooks.contains_key(event_name) {
108 continue;
109 }
110
111 let command = format!("{} ingest event {} --framework cursor", mi6_bin, event_name);
112
113 let hook_entry = serde_json::json!([{
114 "command": command
115 }]);
116 hooks.insert(event_name.to_string(), hook_entry);
117 }
118
119 serde_json::json!({
120 "version": 1,
121 "hooks": hooks
122 })
123 }
124
125 fn merge_config(
126 &self,
127 generated: serde_json::Value,
128 existing: Option<serde_json::Value>,
129 ) -> serde_json::Value {
130 let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
131
132 settings["version"] = serde_json::json!(1);
134
135 let Some(new_hooks) = generated.get("hooks").and_then(|h| h.as_object()) else {
136 return settings;
137 };
138
139 if settings.get("hooks").is_none() {
141 settings["hooks"] = serde_json::json!({});
142 }
143
144 let Some(existing_hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
145 return settings;
146 };
147
148 for (event_type, new_hook_array) in new_hooks {
151 let Some(new_hooks_arr) = new_hook_array.as_array() else {
152 continue;
153 };
154
155 if let Some(existing_event_hooks) = existing_hooks.get_mut(event_type) {
156 if let Some(existing_arr) = existing_event_hooks.as_array_mut() {
157 existing_arr.retain(|entry| {
159 !entry
160 .get("command")
161 .and_then(|c| c.as_str())
162 .is_some_and(common::is_mi6_command)
163 });
164
165 for hook in new_hooks_arr {
167 existing_arr.push(hook.clone());
168 }
169 }
170 } else {
171 existing_hooks.insert(event_type.clone(), new_hook_array.clone());
173 }
174 }
175
176 settings
177 }
178
179 fn parse_hook_input(
180 &self,
181 _event_type: &str,
182 stdin_json: &serde_json::Value,
183 ) -> ParsedHookInput {
184 let mut parsed = ParsedHookInputBuilder::new(stdin_json)
185 .session_id("conversation_id")
186 .tool_use_id("generation_id")
187 .tool_name("tool_name")
188 .cwd("cwd")
189 .model("model")
190 .duration_ms_or(&["duration_ms", "duration"])
191 .build();
192
193 if parsed.cwd.is_none() {
195 parsed.cwd = get_first_array_element(stdin_json, "workspace_roots");
196 }
197
198 parsed
199 }
203
204 fn map_event_type(&self, framework_event: &str) -> EventType {
205 match framework_event {
206 "beforeShellExecution"
208 | "beforeMCPExecution"
209 | "beforeReadFile"
210 | "beforeTabFileRead" => EventType::PreToolUse,
211 "afterShellExecution" | "afterMCPExecution" | "afterFileEdit" | "afterTabFileEdit" => {
212 EventType::PostToolUse
213 }
214 "beforeSubmitPrompt" => EventType::UserPromptSubmit,
216 "stop" => EventType::Stop,
218 "afterAgentResponse" | "afterAgentThought" => {
220 EventType::Custom(framework_event.to_string())
221 }
222 other => other
224 .parse()
225 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
226 }
227 }
228
229 fn supported_events(&self) -> Vec<&'static str> {
230 vec![
231 "beforeShellExecution",
232 "afterShellExecution",
233 "beforeMCPExecution",
234 "afterMCPExecution",
235 "beforeReadFile",
236 "afterFileEdit",
237 "beforeSubmitPrompt",
238 "afterAgentResponse",
239 "afterAgentThought",
240 "stop",
241 "beforeTabFileRead",
242 "afterTabFileEdit",
243 ]
244 }
245
246 fn framework_specific_events(&self) -> Vec<&'static str> {
247 vec!["afterAgentResponse", "afterAgentThought"]
249 }
250
251 fn detection_env_vars(&self) -> &[&'static str] {
252 &["CURSOR_SESSION_ID", "CURSOR_WORKSPACE_ROOT"]
255 }
256
257 fn is_installed(&self) -> bool {
258 common::is_framework_installed(self.user_config_path(), "cursor")
259 }
260
261 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
266 let mut settings = existing;
269 let mut modified = false;
270
271 if let Some(hooks) = settings.get_mut("hooks")
272 && let Some(hooks_obj) = hooks.as_object_mut()
273 {
274 let keys: Vec<String> = hooks_obj.keys().cloned().collect();
275 for key in keys {
276 if let Some(event_hooks) = hooks_obj.get_mut(&key)
277 && let Some(arr) = event_hooks.as_array_mut()
278 {
279 let original_len = arr.len();
280 arr.retain(|entry| {
282 !entry
283 .get("command")
284 .and_then(|c| c.as_str())
285 .is_some_and(common::is_mi6_command)
286 });
287
288 if arr.len() != original_len {
289 modified = true;
290 }
291
292 if arr.is_empty() {
293 hooks_obj.remove(&key);
294 }
295 }
296 }
297
298 if hooks_obj.is_empty()
300 && let Some(obj) = settings.as_object_mut()
301 {
302 obj.remove("hooks");
303 }
304 }
305
306 if let Some(env) = settings.get_mut("env")
308 && let Some(env_obj) = env.as_object_mut()
309 {
310 for key in CURSOR_OTEL_KEYS {
311 if env_obj.remove(*key).is_some() {
312 modified = true;
313 }
314 }
315
316 if env_obj.is_empty()
317 && let Some(obj) = settings.as_object_mut()
318 {
319 obj.remove("env");
320 }
321 }
322
323 if modified { Some(settings) } else { None }
324 }
325
326 fn hook_response(&self, event_type: &str) -> Option<&'static str> {
327 match event_type {
333 "beforeShellExecution"
334 | "beforeMCPExecution"
335 | "beforeReadFile"
336 | "beforeTabFileRead" => Some(r#"{"permission":"allow"}"#),
337 "beforeSubmitPrompt" => Some(r#"{"continue":true}"#),
338 _ => None,
339 }
340 }
341
342 fn resume_command(&self, _session_id: &str) -> Option<String> {
343 None
345 }
346}
347
348fn canonical_to_cursor(event: &EventType) -> Cow<'static, str> {
350 match event {
351 EventType::PreToolUse => Cow::Borrowed("beforeShellExecution"),
352 EventType::PostToolUse => Cow::Borrowed("afterShellExecution"),
353 EventType::UserPromptSubmit => Cow::Borrowed("beforeSubmitPrompt"),
354 EventType::Stop => Cow::Borrowed("stop"),
355 EventType::SessionStart => Cow::Borrowed("SessionStart"),
357 EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
358 EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
359 EventType::PreCompact => Cow::Borrowed("PreCompact"),
360 EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
361 EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
362 EventType::Notification => Cow::Borrowed("Notification"),
363 EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
364 EventType::Custom(s) => Cow::Owned(s.clone()),
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_name() {
374 let adapter = CursorAdapter;
375 assert_eq!(adapter.name(), "cursor");
376 assert_eq!(adapter.display_name(), "Cursor");
377 }
378
379 #[test]
380 fn test_project_config_path() {
381 let adapter = CursorAdapter;
382 assert_eq!(
383 adapter.project_config_path(),
384 PathBuf::from(".cursor/hooks.json")
385 );
386 }
387
388 #[test]
389 fn test_map_event_type() {
390 let adapter = CursorAdapter;
391
392 assert_eq!(
394 adapter.map_event_type("beforeShellExecution"),
395 EventType::PreToolUse
396 );
397 assert_eq!(
398 adapter.map_event_type("afterShellExecution"),
399 EventType::PostToolUse
400 );
401 assert_eq!(
402 adapter.map_event_type("beforeMCPExecution"),
403 EventType::PreToolUse
404 );
405 assert_eq!(
406 adapter.map_event_type("afterMCPExecution"),
407 EventType::PostToolUse
408 );
409 assert_eq!(
410 adapter.map_event_type("beforeReadFile"),
411 EventType::PreToolUse
412 );
413 assert_eq!(
414 adapter.map_event_type("afterFileEdit"),
415 EventType::PostToolUse
416 );
417
418 assert_eq!(
420 adapter.map_event_type("beforeSubmitPrompt"),
421 EventType::UserPromptSubmit
422 );
423
424 assert_eq!(adapter.map_event_type("stop"), EventType::Stop);
426
427 assert_eq!(
429 adapter.map_event_type("afterAgentResponse"),
430 EventType::Custom("afterAgentResponse".to_string())
431 );
432 assert_eq!(
433 adapter.map_event_type("afterAgentThought"),
434 EventType::Custom("afterAgentThought".to_string())
435 );
436 }
437
438 #[test]
439 fn test_parse_hook_input() {
440 let adapter = CursorAdapter;
441 let input = serde_json::json!({
442 "conversation_id": "cursor-conv-123",
443 "generation_id": "gen-456",
444 "tool_name": "shell",
445 "cwd": "/projects/test",
446 "model": "claude-3-opus",
447 "cursor_version": "1.7.0",
448 "workspace_roots": ["/projects/test", "/projects/other"]
449 });
450
451 let parsed = adapter.parse_hook_input("beforeShellExecution", &input);
452
453 assert_eq!(parsed.session_id, Some("cursor-conv-123".to_string()));
454 assert_eq!(parsed.tool_use_id, Some("gen-456".to_string()));
455 assert_eq!(parsed.tool_name, Some("shell".to_string()));
456 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
457 assert_eq!(parsed.model, Some("claude-3-opus".to_string()));
458
459 assert_eq!(parsed.subagent_type, None);
461 assert_eq!(parsed.spawned_agent_id, None);
462 assert_eq!(parsed.permission_mode, None);
463 }
464
465 #[test]
466 fn test_parse_hook_input_with_duration() {
467 let adapter = CursorAdapter;
468 let input = serde_json::json!({
470 "conversation_id": "cursor-123",
471 "generation_id": "gen-456",
472 "command": "ls -la",
473 "output": "file1.txt\nfile2.txt",
474 "duration": 1234,
475 "model": "gpt-4"
476 });
477
478 let parsed = adapter.parse_hook_input("afterShellExecution", &input);
479
480 assert_eq!(parsed.duration_ms, Some(1234));
481 assert_eq!(parsed.model, Some("gpt-4".to_string()));
482 }
483
484 #[test]
485 fn test_parse_hook_input_with_duration_ms() {
486 let adapter = CursorAdapter;
487 let input = serde_json::json!({
489 "conversation_id": "cursor-123",
490 "text": "Thinking about the problem...",
491 "duration_ms": 5000,
492 "model": "claude-3-sonnet"
493 });
494
495 let parsed = adapter.parse_hook_input("afterAgentThought", &input);
496
497 assert_eq!(parsed.duration_ms, Some(5000));
498 assert_eq!(parsed.model, Some("claude-3-sonnet".to_string()));
499 }
500
501 #[test]
502 fn test_parse_hook_input_workspace_roots_fallback() {
503 let adapter = CursorAdapter;
504 let input = serde_json::json!({
506 "conversation_id": "cursor-123",
507 "workspace_roots": ["/projects/main", "/projects/other"]
508 });
509
510 let parsed = adapter.parse_hook_input("beforeShellExecution", &input);
511
512 assert_eq!(parsed.cwd, Some("/projects/main".to_string()));
513 }
514
515 #[test]
516 fn test_generate_hooks_config() -> Result<(), String> {
517 let adapter = CursorAdapter;
518 let events = vec![EventType::PreToolUse, EventType::UserPromptSubmit];
519
520 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
521
522 assert_eq!(config["version"], 1);
524
525 let hooks = config
526 .get("hooks")
527 .ok_or("missing hooks")?
528 .as_object()
529 .ok_or("hooks not an object")?;
530
531 assert!(hooks.contains_key("beforeShellExecution"));
533 assert!(hooks.contains_key("beforeSubmitPrompt"));
535
536 let shell_hook = &hooks["beforeShellExecution"][0];
537 let command = shell_hook["command"].as_str().ok_or("missing command")?;
538 assert!(command.contains("mi6 ingest event beforeShellExecution"));
539 assert!(command.contains("--framework cursor"));
540
541 assert!(
543 hooks.contains_key("afterAgentResponse"),
544 "Framework-specific afterAgentResponse should be included"
545 );
546 assert!(
547 hooks.contains_key("afterAgentThought"),
548 "Framework-specific afterAgentThought should be included"
549 );
550
551 Ok(())
552 }
553
554 #[test]
555 fn test_framework_specific_events() {
556 let adapter = CursorAdapter;
557 let events = adapter.framework_specific_events();
558
559 assert!(events.contains(&"afterAgentResponse"));
560 assert!(events.contains(&"afterAgentThought"));
561 assert_eq!(events.len(), 2);
562 }
563
564 #[test]
565 fn test_merge_config_new() {
566 let adapter = CursorAdapter;
567 let generated = serde_json::json!({
568 "version": 1,
569 "hooks": {
570 "beforeShellExecution": [{"command": "mi6 ingest event beforeShellExecution"}]
571 }
572 });
573
574 let merged = adapter.merge_config(generated, None);
575
576 assert_eq!(merged["version"], 1);
577 assert!(merged.get("hooks").is_some());
578 assert!(merged["hooks"].get("beforeShellExecution").is_some());
579 }
580
581 #[test]
582 fn test_merge_config_existing() {
583 let adapter = CursorAdapter;
584 let generated = serde_json::json!({
585 "version": 1,
586 "hooks": {
587 "beforeShellExecution": [{"command": "mi6 ingest event beforeShellExecution --framework cursor"}]
588 }
589 });
590 let existing = serde_json::json!({
591 "version": 1,
592 "hooks": {
593 "stop": [{"command": "other-tool log"}]
594 }
595 });
596
597 let merged = adapter.merge_config(generated, Some(existing));
598
599 assert_eq!(merged["version"], 1);
600 assert!(merged["hooks"].get("stop").is_some());
602 assert!(merged["hooks"].get("beforeShellExecution").is_some());
603 }
604
605 #[test]
606 fn test_merge_config_preserves_user_hooks_for_same_event() {
607 let adapter = CursorAdapter;
609 let generated = serde_json::json!({
610 "version": 1,
611 "hooks": {
612 "beforeShellExecution": [{"command": "mi6 ingest event beforeShellExecution --framework cursor"}]
613 }
614 });
615 let existing = serde_json::json!({
616 "version": 1,
617 "hooks": {
618 "beforeShellExecution": [{"command": "my-custom-logger --event shell"}]
619 }
620 });
621
622 let merged = adapter.merge_config(generated, Some(existing));
623
624 let shell_hooks = merged["hooks"]["beforeShellExecution"].as_array().unwrap();
625 assert_eq!(shell_hooks.len(), 2, "Should have both user and mi6 hooks");
626
627 assert!(
629 shell_hooks[0]["command"]
630 .as_str()
631 .unwrap()
632 .contains("my-custom-logger"),
633 "User's custom hook should be preserved"
634 );
635
636 assert!(
638 shell_hooks[1]["command"]
639 .as_str()
640 .unwrap()
641 .contains("mi6 ingest"),
642 "mi6 hook should be appended"
643 );
644 }
645
646 #[test]
647 fn test_merge_config_updates_existing_mi6_hook() {
648 let adapter = CursorAdapter;
650 let generated = serde_json::json!({
651 "version": 1,
652 "hooks": {
653 "beforeShellExecution": [{"command": "mi6 ingest event beforeShellExecution --new-flag"}]
654 }
655 });
656 let existing = serde_json::json!({
657 "version": 1,
658 "hooks": {
659 "beforeShellExecution": [
660 {"command": "my-custom-logger"},
661 {"command": "mi6 ingest event beforeShellExecution --old-flag"}
662 ]
663 }
664 });
665
666 let merged = adapter.merge_config(generated, Some(existing));
667
668 let shell_hooks = merged["hooks"]["beforeShellExecution"].as_array().unwrap();
669 assert_eq!(shell_hooks.len(), 2, "Should still have 2 hooks");
670
671 assert!(
673 shell_hooks[0]["command"]
674 .as_str()
675 .unwrap()
676 .contains("my-custom-logger"),
677 "User's custom hook should be preserved"
678 );
679
680 let mi6_command = shell_hooks[1]["command"].as_str().unwrap();
682 assert!(
683 mi6_command.contains("--new-flag"),
684 "New mi6 hook should be present"
685 );
686 assert!(
687 !mi6_command.contains("--old-flag"),
688 "Old mi6 hook should be replaced"
689 );
690 }
691
692 #[test]
693 fn test_supported_events() {
694 let adapter = CursorAdapter;
695 let events = adapter.supported_events();
696
697 assert!(events.contains(&"beforeShellExecution"));
698 assert!(events.contains(&"afterShellExecution"));
699 assert!(events.contains(&"beforeMCPExecution"));
700 assert!(events.contains(&"afterMCPExecution"));
701 assert!(events.contains(&"beforeReadFile"));
702 assert!(events.contains(&"afterFileEdit"));
703 assert!(events.contains(&"beforeSubmitPrompt"));
704 assert!(events.contains(&"afterAgentResponse"));
705 assert!(events.contains(&"afterAgentThought"));
706 assert!(events.contains(&"stop"));
707 }
708
709 #[test]
710 fn test_detection_env_vars() {
711 let adapter = CursorAdapter;
712 let vars = adapter.detection_env_vars();
713
714 assert!(vars.contains(&"CURSOR_SESSION_ID"));
715 assert!(vars.contains(&"CURSOR_WORKSPACE_ROOT"));
716 }
717
718 #[test]
719 fn test_hook_response() {
720 let adapter = CursorAdapter;
721
722 assert_eq!(
724 adapter.hook_response("beforeShellExecution"),
725 Some(r#"{"permission":"allow"}"#)
726 );
727 assert_eq!(
728 adapter.hook_response("beforeMCPExecution"),
729 Some(r#"{"permission":"allow"}"#)
730 );
731 assert_eq!(
732 adapter.hook_response("beforeReadFile"),
733 Some(r#"{"permission":"allow"}"#)
734 );
735 assert_eq!(
736 adapter.hook_response("beforeTabFileRead"),
737 Some(r#"{"permission":"allow"}"#)
738 );
739
740 assert_eq!(
742 adapter.hook_response("beforeSubmitPrompt"),
743 Some(r#"{"continue":true}"#)
744 );
745
746 assert_eq!(adapter.hook_response("afterShellExecution"), None);
748 assert_eq!(adapter.hook_response("afterAgentResponse"), None);
749 assert_eq!(adapter.hook_response("stop"), None);
750 }
751
752 #[test]
753 fn test_remove_hooks_with_mi6() {
754 let adapter = CursorAdapter;
755 let existing = serde_json::json!({
756 "version": 1,
757 "hooks": {
758 "beforeShellExecution": [
759 {"command": "mi6 ingest event beforeShellExecution --framework cursor"}
760 ]
761 }
762 });
763
764 let result = adapter.remove_hooks(existing);
765
766 assert!(result.is_some());
767 let settings = result.unwrap();
768 assert_eq!(settings["version"], 1);
770 assert!(settings.get("hooks").is_none());
772 }
773
774 #[test]
775 fn test_remove_hooks_preserves_non_mi6() {
776 let adapter = CursorAdapter;
777 let existing = serde_json::json!({
778 "version": 1,
779 "hooks": {
780 "beforeShellExecution": [
781 {"command": "mi6 ingest event beforeShellExecution --framework cursor"},
782 {"command": "other-tool log"}
783 ]
784 }
785 });
786
787 let result = adapter.remove_hooks(existing);
788
789 assert!(result.is_some());
790 let settings = result.unwrap();
791 assert!(settings.get("hooks").is_some());
793 let shell_hooks = settings["hooks"]["beforeShellExecution"]
794 .as_array()
795 .unwrap();
796 assert_eq!(shell_hooks.len(), 1);
797 assert!(
798 shell_hooks[0]["command"]
799 .as_str()
800 .unwrap()
801 .contains("other-tool")
802 );
803 }
804
805 #[test]
806 fn test_remove_hooks_no_mi6() {
807 let adapter = CursorAdapter;
808 let existing = serde_json::json!({
809 "version": 1,
810 "hooks": {
811 "beforeShellExecution": [{"command": "other-tool log"}]
812 }
813 });
814
815 let result = adapter.remove_hooks(existing);
816
817 assert!(result.is_none());
819 }
820}