mi6_core/framework/
mod.rs

1//! Framework adapter system for multi-platform AI coding assistant support.
2//!
3//! This module provides a trait-based abstraction for different AI coding assistant
4//! platforms (Claude, Cursor, Gemini, OpenCode, Codex), allowing mi6 to capture
5//! hook events from any supported platform.
6//!
7//! # Architecture
8//!
9//! Each platform has its own adapter implementing [`FrameworkAdapter`] that handles:
10//! - Hook configuration generation for `mi6 enable`
11//! - Event parsing and normalization for `mi6 ingest event`
12//! - Framework detection from environment variables
13//!
14//! # Supported Frameworks
15//!
16//! - **claude**: The default, fully supported framework (Anthropic's Claude Code)
17//! - **cursor**: Cursor IDE with agent mode hooks
18//! - **gemini**: Google's Gemini CLI tool
19//! - **codex**: OpenAI's Codex CLI tool
20//! - **opencode**: OpenCode AI coding agent
21//! - **pi**: Pi Coding Agent (TypeScript extension-based)
22//! - **amp**: Amp coding agent (process-only detection, no hooks)
23//!
24//! Future support planned for:
25//! - Additional framework integrations
26
27mod amp;
28mod claude;
29mod codex;
30pub mod common;
31mod cursor;
32mod gemini;
33mod install;
34mod opencode;
35mod pi;
36
37pub use amp::AmpAdapter;
38pub use claude::ClaudeAdapter;
39pub use codex::CodexAdapter;
40pub use common::{ParsedHookInputBuilder, get_first_array_element};
41pub use cursor::CursorAdapter;
42pub use gemini::GeminiAdapter;
43pub use install::{
44    InitOptions, InitResult, generate_config, initialize, initialize_all, initialize_framework,
45    json_to_toml_string,
46};
47pub use opencode::OpenCodeAdapter;
48pub use pi::PiAdapter;
49
50// Re-export InitError from model/error for backward compatibility
51pub use crate::model::error::InitError;
52
53use crate::model::EventType;
54use crate::model::error::FrameworkResolutionError;
55use std::collections::HashSet;
56use std::path::{Path, PathBuf};
57
58/// Configuration file format used by a framework.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ConfigFormat {
61    /// JSON configuration (default for most frameworks)
62    #[default]
63    Json,
64    /// TOML configuration (used by Codex CLI)
65    Toml,
66}
67
68/// OpenTelemetry support status for a framework.
69///
70/// This enum replaces the ambiguous `Option<bool>` pattern to provide
71/// clear semantics for OTEL support detection.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum OtelSupport {
74    /// OTEL is configured and enabled for this framework.
75    Enabled,
76    /// OTEL is supported by this framework but not currently enabled.
77    Disabled,
78    /// This framework does not support OTEL.
79    #[default]
80    Unsupported,
81}
82
83/// Framework adapter trait for AI coding assistant integrations.
84///
85/// Implementations of this trait handle the platform-specific details of:
86/// - Generating hook configuration files
87/// - Parsing hook input from stdin
88/// - Mapping platform events to canonical event types
89pub trait FrameworkAdapter: Send + Sync {
90    /// Framework identifier (e.g., "claude", "cursor", "gemini", "opencode").
91    fn name(&self) -> &'static str;
92
93    /// Human-readable display name for the framework.
94    fn display_name(&self) -> &'static str;
95
96    /// Config file path for project-level settings.
97    ///
98    /// Returns the path relative to the project root where hooks should be installed.
99    fn project_config_path(&self) -> PathBuf;
100
101    /// Config file path for user-level settings.
102    ///
103    /// Returns the absolute path to the user's global settings file.
104    fn user_config_path(&self) -> Option<PathBuf>;
105
106    /// Generate hook configuration JSON for this framework.
107    ///
108    /// # Arguments
109    /// * `enabled_events` - List of event types to enable hooks for
110    /// * `mi6_bin` - Path to the mi6 binary (or "mi6" if in PATH)
111    /// * `otel_enabled` - Whether OpenTelemetry is enabled
112    /// * `otel_port` - Port for OTel server
113    ///
114    /// # Returns
115    /// JSON value representing the hooks configuration for this framework.
116    fn generate_hooks_config(
117        &self,
118        enabled_events: &[EventType],
119        mi6_bin: &str,
120        otel_enabled: bool,
121        otel_port: u16,
122    ) -> serde_json::Value;
123
124    /// Merge generated hooks into existing settings.
125    ///
126    /// # Arguments
127    /// * `generated` - The generated hooks configuration
128    /// * `existing` - The existing settings file content (if any)
129    ///
130    /// # Returns
131    /// The merged settings JSON.
132    fn merge_config(
133        &self,
134        generated: serde_json::Value,
135        existing: Option<serde_json::Value>,
136    ) -> serde_json::Value;
137
138    /// Parse hook input JSON from stdin into canonical fields.
139    ///
140    /// # Arguments
141    /// * `event_type` - The framework-specific event type string
142    /// * `stdin_json` - Raw JSON from stdin
143    ///
144    /// # Returns
145    /// Parsed hook data with normalized fields.
146    fn parse_hook_input(&self, event_type: &str, stdin_json: &serde_json::Value)
147    -> ParsedHookInput;
148
149    /// Map a framework-specific event type to a canonical EventType.
150    ///
151    /// # Arguments
152    /// * `framework_event` - The event type string from the framework
153    ///
154    /// # Returns
155    /// The corresponding canonical EventType.
156    fn map_event_type(&self, framework_event: &str) -> EventType;
157
158    /// List of hook event types supported by this framework.
159    fn supported_events(&self) -> Vec<&'static str>;
160
161    /// Framework-specific events that have no canonical equivalent.
162    ///
163    /// These events are unique to this framework and will be configured
164    /// alongside canonical events. They are stored as `Custom(name)` EventType.
165    ///
166    /// Override this method to add framework-specific events that don't map
167    /// to any canonical event type.
168    fn framework_specific_events(&self) -> Vec<&'static str> {
169        vec![]
170    }
171
172    /// Configuration file format used by this framework.
173    ///
174    /// Most frameworks use JSON, but some (like Codex) use TOML.
175    /// Override this method if the framework uses a non-JSON format.
176    fn config_format(&self) -> ConfigFormat {
177        ConfigFormat::Json
178    }
179
180    /// Environment variables that indicate this framework is the caller.
181    fn detection_env_vars(&self) -> &[&'static str];
182
183    /// Detect if this framework is currently calling mi6.
184    ///
185    /// Checks environment variables to determine if a hook from this
186    /// framework is invoking mi6.
187    fn detect(&self) -> bool {
188        self.detection_env_vars()
189            .iter()
190            .any(|var| std::env::var(var).is_ok())
191    }
192
193    /// Check if this framework is installed on the system.
194    ///
195    /// Returns true if the framework's config directory exists or
196    /// the framework's CLI tool is in PATH.
197    fn is_installed(&self) -> bool;
198
199    /// Check OpenTelemetry support status for this framework.
200    ///
201    /// Returns:
202    /// - [`OtelSupport::Enabled`] if OTEL is configured and enabled
203    /// - [`OtelSupport::Disabled`] if OTEL is supported but not enabled
204    /// - [`OtelSupport::Unsupported`] if the framework doesn't support OTEL
205    fn otel_support(&self) -> OtelSupport {
206        OtelSupport::Unsupported // Default: framework doesn't support OTEL
207    }
208
209    /// Remove mi6 hooks from the framework's settings.
210    ///
211    /// Returns the modified settings with mi6 hooks removed,
212    /// or None if there are no mi6 hooks to remove.
213    ///
214    /// # Arguments
215    /// * `existing` - The existing settings file content
216    ///
217    /// # Returns
218    /// Some(modified settings) if hooks were removed, None if no mi6 hooks found.
219    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value>;
220
221    // ========================================================================
222    // Default implementations for hook installation
223    // ========================================================================
224
225    /// Resolve the settings path for this framework.
226    ///
227    /// Determines where the hooks configuration should be installed based on
228    /// the user's preferences (local vs global, settings_local).
229    ///
230    /// # Arguments
231    /// * `local` - If true, use project-level config instead of user-level.
232    /// * `settings_local` - If true, use a `.local` variant (not committed to git).
233    fn settings_path(&self, local: bool, settings_local: bool) -> Result<PathBuf, InitError> {
234        install::default_settings_path(self, local, settings_local)
235    }
236
237    /// Check if this framework has mi6 hooks active in its config file.
238    ///
239    /// # Arguments
240    /// * `local` - If true, check project-level config instead of user-level.
241    /// * `settings_local` - If true, check the `.local` variant.
242    fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
243        install::default_has_mi6_hooks(self, local, settings_local)
244    }
245
246    /// Install hooks configuration to a settings file.
247    ///
248    /// This method merges the new hooks configuration with any existing settings
249    /// and writes the result to the specified path.
250    ///
251    /// # Arguments
252    /// * `path` - Path where settings should be written.
253    /// * `hooks` - The hooks configuration to install.
254    /// * `otel_env` - Optional OTel environment variables to add.
255    /// * `remove_otel` - Whether to remove existing OTel configuration.
256    ///
257    /// # Returns
258    /// An [`InstallHooksResult`] containing any shell commands that were executed.
259    fn install_hooks(
260        &self,
261        path: &Path,
262        hooks: &serde_json::Value,
263        otel_env: Option<serde_json::Value>,
264        remove_otel: bool,
265    ) -> Result<InstallHooksResult, InitError> {
266        install::default_install_hooks(self, path, hooks, otel_env, remove_otel)
267    }
268
269    /// Serialize configuration to string in the appropriate format.
270    ///
271    /// Returns the configuration as a string in either JSON or TOML format,
272    /// depending on the framework's configuration format.
273    fn serialize_config(&self, config: &serde_json::Value) -> Result<String, InitError> {
274        install::default_serialize_config(self, config)
275    }
276
277    /// Uninstall mi6 hooks from this framework.
278    ///
279    /// This method handles the complete removal of mi6 integration from the framework.
280    /// For config-based frameworks, it removes mi6 hooks from the settings file.
281    /// For plugin-based frameworks (like Claude Code), it removes the plugin directory.
282    ///
283    /// # Arguments
284    /// * `local` - If true, uninstall from project-level config instead of user-level.
285    /// * `settings_local` - If true, uninstall from the `.local` variant.
286    ///
287    /// # Returns
288    /// An [`UninstallHooksResult`] containing whether hooks were removed and
289    /// any shell commands that were executed, or an error if uninstallation failed.
290    fn uninstall_hooks(
291        &self,
292        local: bool,
293        settings_local: bool,
294    ) -> Result<UninstallHooksResult, InitError> {
295        install::default_uninstall_hooks(self, local, settings_local)
296    }
297
298    /// Get the JSON response to write to stdout for blocking hooks.
299    ///
300    /// Some frameworks (like Cursor) have blocking hooks that expect a JSON response
301    /// to allow/deny the operation. This method returns the appropriate response
302    /// for the given event type.
303    ///
304    /// # Arguments
305    /// * `event_type` - The framework-specific event type string
306    ///
307    /// # Returns
308    /// `Some(response)` if this event expects a response, `None` for observation-only hooks.
309    ///
310    /// # Example
311    /// For Cursor's `beforeShellExecution`, returns `{"permission":"allow"}` to allow
312    /// the shell command to proceed.
313    fn hook_response(&self, _event_type: &str) -> Option<&'static str> {
314        None
315    }
316
317    /// Get the command to resume a session.
318    ///
319    /// Returns the shell command to resume a session with the given session ID.
320    /// This is framework-specific:
321    /// - Claude: `claude --resume <session_id>`
322    /// - Codex: `codex resume <session_id>`
323    ///
324    /// # Arguments
325    /// * `session_id` - The session ID to resume
326    ///
327    /// # Returns
328    /// `Some(command)` if the framework supports resume, `None` otherwise.
329    fn resume_command(&self, session_id: &str) -> Option<String>;
330}
331
332/// Result of hook installation.
333///
334/// Contains information about what was done during installation,
335/// including any shell commands that were executed.
336#[derive(Debug, Default, Clone)]
337pub struct InstallHooksResult {
338    /// Shell commands that were executed during installation.
339    /// Each string is the full command that was run.
340    pub commands_run: Vec<String>,
341}
342
343/// Result of hook uninstallation.
344///
345/// Contains information about what was done during uninstallation,
346/// including any shell commands that were executed.
347#[derive(Debug, Default, Clone)]
348pub struct UninstallHooksResult {
349    /// Whether hooks were actually removed (false if no mi6 hooks were present).
350    pub hooks_removed: bool,
351    /// Shell commands that were executed during uninstallation.
352    /// Each string is the full command that was run.
353    pub commands_run: Vec<String>,
354}
355
356/// Parsed hook input with normalized fields.
357///
358/// This struct contains fields extracted from framework-specific hook JSON,
359/// normalized to a common format for storage.
360#[derive(Debug, Default, Clone)]
361pub struct ParsedHookInput {
362    /// Session identifier
363    pub session_id: Option<String>,
364    /// Tool use correlation ID
365    pub tool_use_id: Option<String>,
366    /// Tool name (Bash, Read, Write, etc.)
367    pub tool_name: Option<String>,
368    /// Subagent type for Task tool
369    pub subagent_type: Option<String>,
370    /// Spawned agent ID from PostToolUse
371    pub spawned_agent_id: Option<String>,
372    /// Permission mode
373    pub permission_mode: Option<String>,
374    /// Transcript file path
375    pub transcript_path: Option<String>,
376    /// Working directory
377    pub cwd: Option<String>,
378    /// SessionStart source (startup, resume, clear)
379    pub session_source: Option<String>,
380    /// Agent ID from SubagentStop/SubagentStart
381    pub agent_id: Option<String>,
382    /// Path to agent's transcript file (from SubagentStop)
383    pub agent_transcript_path: Option<String>,
384    /// PreCompact trigger type (manual, auto)
385    pub compact_trigger: Option<String>,
386    /// Model name (e.g., "claude-3-opus", "gpt-4")
387    pub model: Option<String>,
388    /// Duration in milliseconds (for tool execution, API calls, etc.)
389    pub duration_ms: Option<i64>,
390    /// Input tokens used
391    pub tokens_input: Option<i64>,
392    /// Output tokens generated
393    pub tokens_output: Option<i64>,
394    /// Tokens read from cache
395    pub tokens_cache_read: Option<i64>,
396    /// Tokens written to cache
397    pub tokens_cache_write: Option<i64>,
398    /// Cost in USD
399    pub cost_usd: Option<f64>,
400    /// User prompt text (for UserPromptSubmit events)
401    pub prompt: Option<String>,
402}
403
404/// Mode for auto-detecting frameworks when no names are specified.
405#[derive(Debug, Clone, Copy)]
406pub enum FrameworkResolutionMode {
407    /// Find frameworks that are installed on the system (for activation).
408    Installed,
409    /// Find frameworks that have mi6 hooks currently active (for deactivation).
410    Active {
411        /// Check project-level config instead of user-level.
412        local: bool,
413        /// Check the `.local` variant (not committed to git).
414        settings_local: bool,
415    },
416}
417
418/// Resolve framework names to adapters with deduplication and validation.
419///
420/// When `names` is empty, auto-detection is performed based on the `mode`:
421/// - [`FrameworkResolutionMode::Installed`]: Returns frameworks installed on the system.
422/// - [`FrameworkResolutionMode::Active`]: Returns frameworks with active mi6 hooks.
423///
424/// When `names` contains framework names, each is validated and the list is deduplicated.
425///
426/// # Arguments
427/// * `names` - Framework names to resolve (empty for auto-detection).
428/// * `mode` - How to auto-detect frameworks when `names` is empty. Pass `None` to
429///   require explicit framework names and error if `names` is empty.
430///
431/// # Returns
432/// A deduplicated list of framework adapters, or an error if:
433/// - A framework name is unknown
434/// - Auto-detection found no frameworks
435///
436/// # Example
437/// ```
438/// use mi6_core::framework::{resolve_frameworks, FrameworkResolutionMode};
439///
440/// // Resolve specific frameworks
441/// let adapters = resolve_frameworks(&["claude".to_string()], None).unwrap();
442/// assert_eq!(adapters.len(), 1);
443///
444/// // Auto-detect installed frameworks
445/// let installed = resolve_frameworks(&[], Some(FrameworkResolutionMode::Installed));
446/// ```
447pub fn resolve_frameworks(
448    names: &[String],
449    mode: Option<FrameworkResolutionMode>,
450) -> Result<Vec<&'static dyn FrameworkAdapter>, FrameworkResolutionError> {
451    if names.is_empty() {
452        // Auto-detect based on mode
453        let Some(mode) = mode else {
454            return Err(FrameworkResolutionError::NoFrameworksFound(
455                "no frameworks specified".to_string(),
456            ));
457        };
458
459        let adapters = match mode {
460            FrameworkResolutionMode::Installed => {
461                let installed = installed_frameworks();
462                if installed.is_empty() {
463                    let all_names: Vec<&str> = all_adapters().iter().map(|a| a.name()).collect();
464                    return Err(FrameworkResolutionError::NoFrameworksFound(format!(
465                        "No supported AI coding frameworks detected.\n\
466                         Supported frameworks: {}\n\
467                         Install one first, or specify explicitly: mi6 enable claude",
468                        all_names.join(", ")
469                    )));
470                }
471                installed
472            }
473            FrameworkResolutionMode::Active {
474                local,
475                settings_local,
476            } => {
477                let active: Vec<_> = all_adapters()
478                    .into_iter()
479                    .filter(|a| a.has_mi6_hooks(local, settings_local))
480                    .collect();
481                if active.is_empty() {
482                    return Err(FrameworkResolutionError::NoFrameworksFound(
483                        "No frameworks have mi6 enabled".to_string(),
484                    ));
485                }
486                active
487            }
488        };
489
490        Ok(adapters)
491    } else {
492        // Resolve each specified framework, deduplicating by name
493        let mut adapters: Vec<&'static dyn FrameworkAdapter> = Vec::new();
494        let mut seen = HashSet::new();
495        for name in names {
496            let adapter = get_adapter(name)
497                .ok_or_else(|| FrameworkResolutionError::UnknownFramework(name.clone()))?;
498            if seen.insert(adapter.name()) {
499                adapters.push(adapter);
500            }
501        }
502        Ok(adapters)
503    }
504}
505
506/// Get all available framework adapters.
507pub fn all_adapters() -> Vec<&'static dyn FrameworkAdapter> {
508    vec![
509        &AmpAdapter,
510        &ClaudeAdapter,
511        &CursorAdapter,
512        &GeminiAdapter,
513        &CodexAdapter,
514        &OpenCodeAdapter,
515        &PiAdapter,
516    ]
517}
518
519/// Get a framework adapter by name.
520pub fn get_adapter(name: &str) -> Option<&'static dyn FrameworkAdapter> {
521    match name.to_lowercase().as_str() {
522        "amp" => Some(&AmpAdapter),
523        "claude" => Some(&ClaudeAdapter),
524        "cursor" => Some(&CursorAdapter),
525        "gemini" => Some(&GeminiAdapter),
526        "codex" => Some(&CodexAdapter),
527        "opencode" => Some(&OpenCodeAdapter),
528        "pi" => Some(&PiAdapter),
529        _ => None,
530    }
531}
532
533/// Detect the current framework from environment variables.
534///
535/// Checks environment variables set by each framework to determine
536/// which one is currently invoking mi6. Returns the first matching
537/// framework if multiple are detected.
538pub fn detect_framework() -> Option<&'static dyn FrameworkAdapter> {
539    all_adapters().into_iter().find(|adapter| adapter.detect())
540}
541
542/// Detect all frameworks that match current environment variables.
543///
544/// Useful for diagnosing ambiguous detection scenarios.
545pub fn detect_all_frameworks() -> Vec<&'static dyn FrameworkAdapter> {
546    all_adapters()
547        .into_iter()
548        .filter(|adapter| adapter.detect())
549        .collect()
550}
551
552/// Get all frameworks that are installed on the system.
553///
554/// Uses the `is_installed()` method on each adapter to determine
555/// if the framework's config directory exists or CLI tool is available.
556pub fn installed_frameworks() -> Vec<&'static dyn FrameworkAdapter> {
557    all_adapters()
558        .into_iter()
559        .filter(|adapter| adapter.is_installed())
560        .collect()
561}
562
563/// Get the default framework (Claude Code).
564pub fn default_adapter() -> &'static dyn FrameworkAdapter {
565    &ClaudeAdapter
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_get_adapter_claude() {
574        assert!(get_adapter("claude").is_some());
575        assert!(get_adapter("CLAUDE").is_some());
576    }
577
578    #[test]
579    fn test_get_adapter_gemini() {
580        assert!(get_adapter("gemini").is_some());
581        assert!(get_adapter("GEMINI").is_some());
582    }
583
584    #[test]
585    fn test_get_adapter_unknown() {
586        assert!(get_adapter("unknown").is_none());
587        assert!(get_adapter("").is_none());
588    }
589
590    #[test]
591    fn test_get_adapter_codex() {
592        assert!(get_adapter("codex").is_some());
593        assert!(get_adapter("CODEX").is_some());
594    }
595
596    #[test]
597    fn test_get_adapter_cursor() {
598        assert!(get_adapter("cursor").is_some());
599        assert!(get_adapter("CURSOR").is_some());
600    }
601
602    #[test]
603    fn test_get_adapter_opencode() {
604        assert!(get_adapter("opencode").is_some());
605        assert!(get_adapter("OPENCODE").is_some());
606    }
607
608    #[test]
609    fn test_get_adapter_pi() {
610        assert!(get_adapter("pi").is_some());
611        assert!(get_adapter("PI").is_some());
612    }
613
614    #[test]
615    fn test_get_adapter_amp() {
616        assert!(get_adapter("amp").is_some());
617        assert!(get_adapter("AMP").is_some());
618    }
619
620    #[test]
621    fn test_all_adapters() {
622        let adapters = all_adapters();
623        assert!(!adapters.is_empty());
624        assert!(adapters.iter().any(|a| a.name() == "amp"));
625        assert!(adapters.iter().any(|a| a.name() == "claude"));
626        assert!(adapters.iter().any(|a| a.name() == "cursor"));
627        assert!(adapters.iter().any(|a| a.name() == "gemini"));
628        assert!(adapters.iter().any(|a| a.name() == "codex"));
629        assert!(adapters.iter().any(|a| a.name() == "opencode"));
630        assert!(adapters.iter().any(|a| a.name() == "pi"));
631    }
632
633    #[test]
634    fn test_default_adapter() {
635        let adapter = default_adapter();
636        assert_eq!(adapter.name(), "claude");
637    }
638
639    #[test]
640    fn test_resolve_frameworks_single() {
641        let adapters = resolve_frameworks(&["claude".to_string()], None).unwrap();
642        assert_eq!(adapters.len(), 1);
643        assert_eq!(adapters[0].name(), "claude");
644    }
645
646    #[test]
647    fn test_resolve_frameworks_multiple() {
648        let adapters =
649            resolve_frameworks(&["claude".to_string(), "gemini".to_string()], None).unwrap();
650        assert_eq!(adapters.len(), 2);
651        assert!(adapters.iter().any(|a| a.name() == "claude"));
652        assert!(adapters.iter().any(|a| a.name() == "gemini"));
653    }
654
655    #[test]
656    fn test_resolve_frameworks_deduplicates() {
657        // Same framework specified twice should be deduplicated
658        let adapters = resolve_frameworks(
659            &[
660                "claude".to_string(),
661                "claude".to_string(),
662                "CLAUDE".to_string(),
663            ],
664            None,
665        )
666        .unwrap();
667        assert_eq!(adapters.len(), 1);
668        assert_eq!(adapters[0].name(), "claude");
669    }
670
671    #[test]
672    fn test_resolve_frameworks_unknown_framework() {
673        let result = resolve_frameworks(&["unknown_framework".to_string()], None);
674        match result {
675            Err(FrameworkResolutionError::UnknownFramework(name)) => {
676                assert_eq!(name, "unknown_framework");
677            }
678            _ => panic!("expected UnknownFramework error"),
679        }
680    }
681
682    #[test]
683    fn test_resolve_frameworks_empty_no_mode() {
684        // Empty names with None mode should error
685        let result = resolve_frameworks(&[], None);
686        match result {
687            Err(FrameworkResolutionError::NoFrameworksFound(_)) => {}
688            _ => panic!("expected NoFrameworksFound error"),
689        }
690    }
691
692    #[test]
693    fn test_resolve_frameworks_case_insensitive() {
694        // Framework names should be case-insensitive
695        let adapters = resolve_frameworks(&["CLAUDE".to_string()], None).unwrap();
696        assert_eq!(adapters.len(), 1);
697        assert_eq!(adapters[0].name(), "claude");
698    }
699
700    #[test]
701    fn test_resolve_frameworks_mixed_case_deduplication() {
702        // Mixed case should deduplicate correctly
703        let adapters = resolve_frameworks(
704            &[
705                "claude".to_string(),
706                "CLAUDE".to_string(),
707                "Claude".to_string(),
708            ],
709            None,
710        )
711        .unwrap();
712        assert_eq!(adapters.len(), 1);
713    }
714}