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