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}