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