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 currently support OTel natively, but these keys are included
41/// for consistency with other adapters and for use in `remove_hooks()` to clean up
42/// any manually-added OTel configuration. If Cursor adds OTel support in the future,
43/// `generate_hooks_config()` can be updated to use these keys.
44const CURSOR_OTEL_KEYS: &[&str] = &[
45    "OTEL_LOGS_EXPORTER",
46    "OTEL_EXPORTER_OTLP_PROTOCOL",
47    "OTEL_EXPORTER_OTLP_ENDPOINT",
48];
49
50/// Cursor IDE framework adapter.
51///
52/// Handles hook generation and event parsing for Cursor IDE agent mode.
53pub 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        // Cursor-supported event names
82        let cursor_events: std::collections::HashSet<&str> =
83            self.supported_events().into_iter().collect();
84
85        // Process canonical events that map to Cursor events
86        for event in enabled_events {
87            let cursor_event = canonical_to_cursor(event);
88
89            // Skip events that Cursor doesn't support
90            if !cursor_events.contains(cursor_event.as_ref()) {
91                continue;
92            }
93
94            // Cursor hook format: array of hook objects with "command" field
95            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        // Add framework-specific events that have no canonical equivalent
107        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        // Ensure version is set
134        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        // Ensure hooks object exists
141        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        // Merge hooks, preserving user hooks
150        // Cursor uses flat command structure: { "command": "..." }
151        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                    // Remove any existing mi6 hooks to avoid duplicates
159                    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                    // Append the new mi6 hooks
167                    for hook in new_hooks_arr {
168                        existing_arr.push(hook.clone());
169                    }
170                }
171            } else {
172                // Event type doesn't exist, add the entire array
173                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        // Cursor provides workspace_roots as an array - use first one as cwd fallback
195        if parsed.cwd.is_none() {
196            parsed.cwd = get_first_array_element(stdin_json, "workspace_roots");
197        }
198
199        parsed
200        // Note: Cursor doesn't provide subagent_type, spawned_agent_id, permission_mode,
201        // transcript_path, session_source, agent_id, agent_transcript_path, compact_trigger,
202        // tokens, or cost via hooks
203    }
204
205    fn map_event_type(&self, framework_event: &str) -> EventType {
206        match framework_event {
207            // Tool-related events
208            "beforeShellExecution"
209            | "beforeMCPExecution"
210            | "beforeReadFile"
211            | "beforeTabFileRead" => EventType::PreToolUse,
212            "afterShellExecution" | "afterMCPExecution" | "afterFileEdit" | "afterTabFileEdit" => {
213                EventType::PostToolUse
214            }
215            // User interaction
216            "beforeSubmitPrompt" => EventType::UserPromptSubmit,
217            // Session lifecycle
218            "stop" => EventType::Stop,
219            // Cursor-specific events become Custom
220            "afterAgentResponse" | "afterAgentThought" => {
221                EventType::Custom(framework_event.to_string())
222            }
223            // Try canonical parsing, fall back to custom
224            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        // These Cursor events have no canonical equivalent
249        vec!["afterAgentResponse", "afterAgentThought"]
250    }
251
252    fn detection_env_vars(&self) -> &[&'static str] {
253        // Cursor may set these env vars when running hooks
254        // Note: These need to be verified with actual Cursor installation
255        &["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        // Cursor supports OTEL via env vars in hooks.json
265        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        // Check for OTEL env vars in the "env" section
276        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        // Cursor uses a different hook structure than Claude/Gemini
288        // Each event has an array of hook objects with "command" field
289        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                    // Filter out mi6 hooks (direct command field, not nested)
302                    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 object is now empty, remove it entirely
320            if hooks_obj.is_empty()
321                && let Some(obj) = settings.as_object_mut()
322            {
323                obj.remove("hooks");
324            }
325        }
326
327        // Remove OTel env vars if present
328        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        // Cursor's blocking hooks expect a JSON response to allow the operation.
349        // Different hooks use different response formats:
350        // - beforeShellExecution, beforeMCPExecution, beforeReadFile, beforeTabFileRead
351        //   use: {"permission": "allow" | "deny" | "ask"}
352        // - beforeSubmitPrompt uses: {"continue": true | false}
353        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
364/// Map canonical event types to Cursor event names.
365fn 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        // Events that don't have a direct Cursor equivalent
372        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        // Tool events
409        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        // User interaction
435        assert_eq!(
436            adapter.map_event_type("beforeSubmitPrompt"),
437            EventType::UserPromptSubmit
438        );
439
440        // Session lifecycle
441        assert_eq!(adapter.map_event_type("stop"), EventType::Stop);
442
443        // Cursor-specific events become Custom
444        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        // Claude-specific fields should be None
476        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        // afterShellExecution includes duration
485        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        // afterAgentThought uses duration_ms
504        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        // When cwd is not present, use first workspace_root
521        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        // Should have version field
539        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        // PreToolUse maps to beforeShellExecution
548        assert!(hooks.contains_key("beforeShellExecution"));
549        // UserPromptSubmit maps to beforeSubmitPrompt
550        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        // Should also include framework-specific events
558        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        // Should merge hooks - both should exist
617        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        // Test for issue #440: User hooks should be preserved when mi6 enables
624        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        // First hook should be the user's custom hook (preserved)
644        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        // Second hook should be mi6's hook (appended)
653        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        // When mi6 is re-enabled, it should replace the old mi6 hook
665        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        // First hook should be the user's custom hook
688        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        // Second hook should be the NEW mi6 hook
697        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        // Permission-based blocking hooks
739        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        // beforeSubmitPrompt uses "continue" format instead of "permission"
757        assert_eq!(
758            adapter.hook_response("beforeSubmitPrompt"),
759            Some(r#"{"continue":true}"#)
760        );
761
762        // Non-blocking hooks should return None
763        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        // Version should be preserved
785        assert_eq!(settings["version"], 1);
786        // Hooks should be removed (was only mi6 hooks)
787        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        // Hooks object should still exist with the non-mi6 hook
808        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        // Should return None since no mi6 hooks were found
834        assert!(result.is_none());
835    }
836}