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::{FrameworkAdapter, ParsedHookInput, common};
32use crate::model::EventType;
33use std::borrow::Cow;
34use std::path::PathBuf;
35
36/// OTel environment variable keys.
37///
38/// NOTE: Cursor doesn't currently support OTel natively, but these keys are included
39/// for consistency with other adapters and for use in `remove_hooks()` to clean up
40/// any manually-added OTel configuration. If Cursor adds OTel support in the future,
41/// `generate_hooks_config()` can be updated to use these keys.
42const CURSOR_OTEL_KEYS: &[&str] = &[
43    "OTEL_LOGS_EXPORTER",
44    "OTEL_EXPORTER_OTLP_PROTOCOL",
45    "OTEL_EXPORTER_OTLP_ENDPOINT",
46];
47
48/// Cursor IDE framework adapter.
49///
50/// Handles hook generation and event parsing for Cursor IDE agent mode.
51pub 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        // Cursor-supported event names
80        let cursor_events: std::collections::HashSet<&str> =
81            self.supported_events().into_iter().collect();
82
83        // Process canonical events that map to Cursor events
84        for event in enabled_events {
85            let cursor_event = canonical_to_cursor(event);
86
87            // Skip events that Cursor doesn't support
88            if !cursor_events.contains(cursor_event.as_ref()) {
89                continue;
90            }
91
92            // Cursor hook format: array of hook objects with "command" field
93            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        // Add framework-specific events that have no canonical equivalent
105        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        // Ensure version is set
132        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        // Ensure hooks object exists
139        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        // Merge hooks, preserving user hooks
148        // Cursor uses flat command structure: { "command": "..." }
149        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                    // Remove any existing mi6 hooks to avoid duplicates
157                    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                    // Append the new mi6 hooks
165                    for hook in new_hooks_arr {
166                        existing_arr.push(hook.clone());
167                    }
168                }
169            } else {
170                // Event type doesn't exist, add the entire array
171                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        // Cursor provides workspace_roots as an array - use the first one as cwd
184        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        // Extract duration - Cursor uses "duration" (ms) or "duration_ms"
198        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            // Cursor uses conversation_id for session tracking
205            session_id: stdin_json
206                .get("conversation_id")
207                .and_then(|v| v.as_str())
208                .map(String::from),
209            // generation_id is used as tool correlation ID
210            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            // Cursor provides model in every hook payload
220            model: stdin_json
221                .get("model")
222                .and_then(|v| v.as_str())
223                .map(String::from),
224            // Duration for afterShellExecution, afterMCPExecution, afterAgentThought
225            duration_ms,
226            // Cursor doesn't provide these Claude-specific fields
227            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            // Tool-related events
247            "beforeShellExecution"
248            | "beforeMCPExecution"
249            | "beforeReadFile"
250            | "beforeTabFileRead" => EventType::PreToolUse,
251            "afterShellExecution" | "afterMCPExecution" | "afterFileEdit" | "afterTabFileEdit" => {
252                EventType::PostToolUse
253            }
254            // User interaction
255            "beforeSubmitPrompt" => EventType::UserPromptSubmit,
256            // Session lifecycle
257            "stop" => EventType::Stop,
258            // Cursor-specific events become Custom
259            "afterAgentResponse" | "afterAgentThought" => {
260                EventType::Custom(framework_event.to_string())
261            }
262            // Try canonical parsing, fall back to custom
263            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        // These Cursor events have no canonical equivalent
288        vec!["afterAgentResponse", "afterAgentThought"]
289    }
290
291    fn detection_env_vars(&self) -> &[&'static str] {
292        // Cursor may set these env vars when running hooks
293        // Note: These need to be verified with actual Cursor installation
294        &["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        // Cursor supports OTEL via env vars in hooks.json
304        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        // Check for OTEL env vars in the "env" section
315        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        // Cursor uses a different hook structure than Claude/Gemini
327        // Each event has an array of hook objects with "command" field
328        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                    // Filter out mi6 hooks (direct command field, not nested)
341                    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 object is now empty, remove it entirely
359            if hooks_obj.is_empty()
360                && let Some(obj) = settings.as_object_mut()
361            {
362                obj.remove("hooks");
363            }
364        }
365
366        // Remove OTel env vars if present
367        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        // Cursor's blocking hooks expect a JSON response to allow the operation.
388        // Different hooks use different response formats:
389        // - beforeShellExecution, beforeMCPExecution, beforeReadFile, beforeTabFileRead
390        //   use: {"permission": "allow" | "deny" | "ask"}
391        // - beforeSubmitPrompt uses: {"continue": true | false}
392        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
403/// Map canonical event types to Cursor event names.
404fn 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        // Events that don't have a direct Cursor equivalent
411        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        // Tool events
448        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        // User interaction
474        assert_eq!(
475            adapter.map_event_type("beforeSubmitPrompt"),
476            EventType::UserPromptSubmit
477        );
478
479        // Session lifecycle
480        assert_eq!(adapter.map_event_type("stop"), EventType::Stop);
481
482        // Cursor-specific events become Custom
483        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        // Claude-specific fields should be None
515        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        // afterShellExecution includes duration
524        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        // afterAgentThought uses duration_ms
543        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        // When cwd is not present, use first workspace_root
560        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        // Should have version field
578        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        // PreToolUse maps to beforeShellExecution
587        assert!(hooks.contains_key("beforeShellExecution"));
588        // UserPromptSubmit maps to beforeSubmitPrompt
589        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        // Should also include framework-specific events
597        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        // Should merge hooks - both should exist
656        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        // Test for issue #440: User hooks should be preserved when mi6 enables
663        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        // First hook should be the user's custom hook (preserved)
683        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        // Second hook should be mi6's hook (appended)
692        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        // When mi6 is re-enabled, it should replace the old mi6 hook
704        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        // First hook should be the user's custom hook
727        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        // Second hook should be the NEW mi6 hook
736        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        // Permission-based blocking hooks
778        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        // beforeSubmitPrompt uses "continue" format instead of "permission"
796        assert_eq!(
797            adapter.hook_response("beforeSubmitPrompt"),
798            Some(r#"{"continue":true}"#)
799        );
800
801        // Non-blocking hooks should return None
802        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        // Version should be preserved
824        assert_eq!(settings["version"], 1);
825        // Hooks should be removed (was only mi6 hooks)
826        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        // Hooks object should still exist with the non-mi6 hook
847        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        // Should return None since no mi6 hooks were found
873        assert!(result.is_none());
874    }
875}