mi6_core/framework/
cursor.rs

1//! Cursor IDE framework adapter.
2//!
3//! This adapter handles integration with Cursor IDE's agent mode hooks.
4//! Cursor uses a hooks.json configuration file similar to other frameworks,
5//! but with a different structure and camelCase event names.
6//!
7//! # Hook Configuration
8//!
9//! Cursor hooks are configured in `.cursor/hooks.json` (project) or
10//! `~/.cursor/hooks.json` (user) with the following format:
11//!
12//! ```json
13//! {
14//!   "version": 1,
15//!   "hooks": {
16//!     "beforeShellExecution": [{ "command": "mi6 ingest event beforeShellExecution --framework cursor" }]
17//!   }
18//! }
19//! ```
20//!
21//! # Blocking Hooks
22//!
23//! Some Cursor hooks expect a JSON response to allow/deny operations:
24//! - `beforeShellExecution`: Expects `{"permission":"allow"}` or `{"permission":"deny"}`
25//! - `beforeMCPExecution`: Same as above
26//! - `beforeReadFile`: Same as above
27//! - `beforeTabFileRead`: Same as above
28//!
29//! This adapter implements `hook_response()` to return the appropriate response.
30
31use super::{
32    FrameworkAdapter, ParsedHookInput, ParsedHookInputBuilder, common, get_first_array_element,
33};
34use crate::model::EventType;
35use std::borrow::Cow;
36use std::path::PathBuf;
37
38/// OTel environment variable keys.
39///
40/// NOTE: Cursor doesn't support OTel natively - it can only consume traces via MCP
41/// servers, not emit them. These keys are included only for use in `remove_hooks()`
42/// to clean up any manually-added OTel configuration.
43const CURSOR_OTEL_KEYS: &[&str] = &[
44    "OTEL_LOGS_EXPORTER",
45    "OTEL_EXPORTER_OTLP_PROTOCOL",
46    "OTEL_EXPORTER_OTLP_ENDPOINT",
47];
48
49/// Cursor IDE framework adapter.
50///
51/// Handles hook generation and event parsing for Cursor IDE agent mode.
52pub 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        // Cursor-supported event names
81        let cursor_events: std::collections::HashSet<&str> =
82            self.supported_events().into_iter().collect();
83
84        // Process canonical events that map to Cursor events
85        for event in enabled_events {
86            let cursor_event = canonical_to_cursor(event);
87
88            // Skip events that Cursor doesn't support
89            if !cursor_events.contains(cursor_event.as_ref()) {
90                continue;
91            }
92
93            // Cursor hook format: array of hook objects with "command" field
94            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        // Add framework-specific events that have no canonical equivalent
106        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        // Ensure version is set
133        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        // Ensure hooks object exists
140        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        // Merge hooks, preserving user hooks
149        // Cursor uses flat command structure: { "command": "..." }
150        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                    // Remove any existing mi6 hooks to avoid duplicates
158                    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                    // Append the new mi6 hooks
166                    for hook in new_hooks_arr {
167                        existing_arr.push(hook.clone());
168                    }
169                }
170            } else {
171                // Event type doesn't exist, add the entire array
172                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        // Cursor provides workspace_roots as an array - use first one as cwd fallback
194        if parsed.cwd.is_none() {
195            parsed.cwd = get_first_array_element(stdin_json, "workspace_roots");
196        }
197
198        parsed
199        // Note: Cursor doesn't provide subagent_type, spawned_agent_id, permission_mode,
200        // transcript_path, session_source, agent_id, agent_transcript_path, compact_trigger,
201        // tokens, or cost via hooks
202    }
203
204    fn map_event_type(&self, framework_event: &str) -> EventType {
205        match framework_event {
206            // Tool-related events
207            "beforeShellExecution"
208            | "beforeMCPExecution"
209            | "beforeReadFile"
210            | "beforeTabFileRead" => EventType::PreToolUse,
211            "afterShellExecution" | "afterMCPExecution" | "afterFileEdit" | "afterTabFileEdit" => {
212                EventType::PostToolUse
213            }
214            // User interaction
215            "beforeSubmitPrompt" => EventType::UserPromptSubmit,
216            // Session lifecycle
217            "stop" => EventType::Stop,
218            // Cursor-specific events become Custom
219            "afterAgentResponse" | "afterAgentThought" => {
220                EventType::Custom(framework_event.to_string())
221            }
222            // Try canonical parsing, fall back to custom
223            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        // These Cursor events have no canonical equivalent
248        vec!["afterAgentResponse", "afterAgentThought"]
249    }
250
251    fn detection_env_vars(&self) -> &[&'static str] {
252        // Cursor may set these env vars when running hooks
253        // Note: These need to be verified with actual Cursor installation
254        &["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    // Note: otel_support() intentionally uses the default (Unsupported) because
262    // Cursor doesn't support native OTel export - it can only consume traces via
263    // MCP servers for debugging, not emit telemetry data.
264
265    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
266        // Cursor uses a different hook structure than Claude/Gemini
267        // Each event has an array of hook objects with "command" field
268        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                    // Filter out mi6 hooks (direct command field, not nested)
281                    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 object is now empty, remove it entirely
299            if hooks_obj.is_empty()
300                && let Some(obj) = settings.as_object_mut()
301            {
302                obj.remove("hooks");
303            }
304        }
305
306        // Remove OTel env vars if present
307        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        // Cursor's blocking hooks expect a JSON response to allow the operation.
328        // Different hooks use different response formats:
329        // - beforeShellExecution, beforeMCPExecution, beforeReadFile, beforeTabFileRead
330        //   use: {"permission": "allow" | "deny" | "ask"}
331        // - beforeSubmitPrompt uses: {"continue": true | false}
332        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        // Cursor doesn't have a CLI resume command
344        None
345    }
346}
347
348/// Map canonical event types to Cursor event names.
349fn 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        // Events that don't have a direct Cursor equivalent
356        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        // Tool events
393        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        // User interaction
419        assert_eq!(
420            adapter.map_event_type("beforeSubmitPrompt"),
421            EventType::UserPromptSubmit
422        );
423
424        // Session lifecycle
425        assert_eq!(adapter.map_event_type("stop"), EventType::Stop);
426
427        // Cursor-specific events become Custom
428        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        // Claude-specific fields should be None
460        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        // afterShellExecution includes duration
469        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        // afterAgentThought uses duration_ms
488        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        // When cwd is not present, use first workspace_root
505        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        // Should have version field
523        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        // PreToolUse maps to beforeShellExecution
532        assert!(hooks.contains_key("beforeShellExecution"));
533        // UserPromptSubmit maps to beforeSubmitPrompt
534        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        // Should also include framework-specific events
542        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        // Should merge hooks - both should exist
601        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        // Test for issue #440: User hooks should be preserved when mi6 enables
608        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        // First hook should be the user's custom hook (preserved)
628        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        // Second hook should be mi6's hook (appended)
637        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        // When mi6 is re-enabled, it should replace the old mi6 hook
649        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        // First hook should be the user's custom hook
672        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        // Second hook should be the NEW mi6 hook
681        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        // Permission-based blocking hooks
723        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        // beforeSubmitPrompt uses "continue" format instead of "permission"
741        assert_eq!(
742            adapter.hook_response("beforeSubmitPrompt"),
743            Some(r#"{"continue":true}"#)
744        );
745
746        // Non-blocking hooks should return None
747        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        // Version should be preserved
769        assert_eq!(settings["version"], 1);
770        // Hooks should be removed (was only mi6 hooks)
771        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        // Hooks object should still exist with the non-mi6 hook
792        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        // Should return None since no mi6 hooks were found
818        assert!(result.is_none());
819    }
820}