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//! - **gemini**: Google's Gemini CLI tool
18//! - **codex**: OpenAI's Codex CLI tool
19//!
20//! Future support planned for:
21//! - **cursor**: Cursor IDE with agent mode hooks
22//! - **opencode**: OpenCode AI assistant
23
24mod claude;
25mod codex;
26mod common;
27mod gemini;
28mod install;
29
30pub use claude::ClaudeAdapter;
31pub use codex::CodexAdapter;
32pub use gemini::GeminiAdapter;
33pub use install::{
34    InitOptions, InitResult, generate_config, initialize, initialize_all, json_to_toml_string,
35};
36
37// Re-export InitError from model/error for backward compatibility
38pub use crate::model::error::InitError;
39
40use crate::model::EventType;
41use crate::model::error::FrameworkResolutionError;
42use std::collections::HashSet;
43use std::path::{Path, PathBuf};
44
45/// Configuration file format used by a framework.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum ConfigFormat {
48    /// JSON configuration (default for most frameworks)
49    #[default]
50    Json,
51    /// TOML configuration (used by Codex CLI)
52    Toml,
53}
54
55/// Framework adapter trait for AI coding assistant integrations.
56///
57/// Implementations of this trait handle the platform-specific details of:
58/// - Generating hook configuration files
59/// - Parsing hook input from stdin
60/// - Mapping platform events to canonical event types
61pub trait FrameworkAdapter: Send + Sync {
62    /// Framework identifier (e.g., "claude", "cursor", "gemini", "opencode").
63    fn name(&self) -> &'static str;
64
65    /// Human-readable display name for the framework.
66    fn display_name(&self) -> &'static str;
67
68    /// Config file path for project-level settings.
69    ///
70    /// Returns the path relative to the project root where hooks should be installed.
71    fn project_config_path(&self) -> PathBuf;
72
73    /// Config file path for user-level settings.
74    ///
75    /// Returns the absolute path to the user's global settings file.
76    fn user_config_path(&self) -> Option<PathBuf>;
77
78    /// Generate hook configuration JSON for this framework.
79    ///
80    /// # Arguments
81    /// * `enabled_events` - List of event types to enable hooks for
82    /// * `mi6_bin` - Path to the mi6 binary (or "mi6" if in PATH)
83    /// * `otel_enabled` - Whether OpenTelemetry is enabled
84    /// * `otel_port` - Port for OTel server
85    ///
86    /// # Returns
87    /// JSON value representing the hooks configuration for this framework.
88    fn generate_hooks_config(
89        &self,
90        enabled_events: &[EventType],
91        mi6_bin: &str,
92        otel_enabled: bool,
93        otel_port: u16,
94    ) -> serde_json::Value;
95
96    /// Merge generated hooks into existing settings.
97    ///
98    /// # Arguments
99    /// * `generated` - The generated hooks configuration
100    /// * `existing` - The existing settings file content (if any)
101    ///
102    /// # Returns
103    /// The merged settings JSON.
104    fn merge_config(
105        &self,
106        generated: serde_json::Value,
107        existing: Option<serde_json::Value>,
108    ) -> serde_json::Value;
109
110    /// Parse hook input JSON from stdin into canonical fields.
111    ///
112    /// # Arguments
113    /// * `event_type` - The framework-specific event type string
114    /// * `stdin_json` - Raw JSON from stdin
115    ///
116    /// # Returns
117    /// Parsed hook data with normalized fields.
118    fn parse_hook_input(&self, event_type: &str, stdin_json: &serde_json::Value)
119    -> ParsedHookInput;
120
121    /// Map a framework-specific event type to a canonical EventType.
122    ///
123    /// # Arguments
124    /// * `framework_event` - The event type string from the framework
125    ///
126    /// # Returns
127    /// The corresponding canonical EventType.
128    fn map_event_type(&self, framework_event: &str) -> EventType;
129
130    /// List of hook event types supported by this framework.
131    fn supported_events(&self) -> Vec<&'static str>;
132
133    /// Framework-specific events that have no canonical equivalent.
134    ///
135    /// These events are unique to this framework and will be configured
136    /// alongside canonical events. They are stored as `Custom(name)` EventType.
137    ///
138    /// Override this method to add framework-specific events that don't map
139    /// to any canonical event type.
140    fn framework_specific_events(&self) -> Vec<&'static str> {
141        vec![]
142    }
143
144    /// Configuration file format used by this framework.
145    ///
146    /// Most frameworks use JSON, but some (like Codex) use TOML.
147    /// Override this method if the framework uses a non-JSON format.
148    fn config_format(&self) -> ConfigFormat {
149        ConfigFormat::Json
150    }
151
152    /// Environment variables that indicate this framework is the caller.
153    fn detection_env_vars(&self) -> &[&'static str];
154
155    /// Detect if this framework is currently calling mi6.
156    ///
157    /// Checks environment variables to determine if a hook from this
158    /// framework is invoking mi6.
159    fn detect(&self) -> bool {
160        self.detection_env_vars()
161            .iter()
162            .any(|var| std::env::var(var).is_ok())
163    }
164
165    /// Check if this framework is installed on the system.
166    ///
167    /// Returns true if the framework's config directory exists or
168    /// the framework's CLI tool is in PATH.
169    fn is_installed(&self) -> bool;
170
171    /// Remove mi6 hooks from the framework's settings.
172    ///
173    /// Returns the modified settings with mi6 hooks removed,
174    /// or None if there are no mi6 hooks to remove.
175    ///
176    /// # Arguments
177    /// * `existing` - The existing settings file content
178    ///
179    /// # Returns
180    /// Some(modified settings) if hooks were removed, None if no mi6 hooks found.
181    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value>;
182
183    // ========================================================================
184    // Default implementations for hook installation
185    // ========================================================================
186
187    /// Resolve the settings path for this framework.
188    ///
189    /// Determines where the hooks configuration should be installed based on
190    /// the user's preferences (local vs global, settings_local).
191    ///
192    /// # Arguments
193    /// * `local` - If true, use project-level config instead of user-level.
194    /// * `settings_local` - If true, use a `.local` variant (not committed to git).
195    fn settings_path(&self, local: bool, settings_local: bool) -> Result<PathBuf, InitError> {
196        install::default_settings_path(self, local, settings_local)
197    }
198
199    /// Check if this framework has mi6 hooks active in its config file.
200    ///
201    /// # Arguments
202    /// * `local` - If true, check project-level config instead of user-level.
203    /// * `settings_local` - If true, check the `.local` variant.
204    fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
205        install::default_has_mi6_hooks(self, local, settings_local)
206    }
207
208    /// Install hooks configuration to a settings file.
209    ///
210    /// This method merges the new hooks configuration with any existing settings
211    /// and writes the result to the specified path.
212    ///
213    /// # Arguments
214    /// * `path` - Path where settings should be written.
215    /// * `hooks` - The hooks configuration to install.
216    /// * `otel_env` - Optional OTel environment variables to add.
217    /// * `remove_otel` - Whether to remove existing OTel configuration.
218    fn install_hooks(
219        &self,
220        path: &Path,
221        hooks: &serde_json::Value,
222        otel_env: Option<serde_json::Value>,
223        remove_otel: bool,
224    ) -> Result<(), InitError> {
225        install::default_install_hooks(self, path, hooks, otel_env, remove_otel)
226    }
227
228    /// Serialize configuration to string in the appropriate format.
229    ///
230    /// Returns the configuration as a string in either JSON or TOML format,
231    /// depending on the framework's configuration format.
232    fn serialize_config(&self, config: &serde_json::Value) -> Result<String, InitError> {
233        install::default_serialize_config(self, config)
234    }
235
236    /// Uninstall mi6 hooks from this framework.
237    ///
238    /// This method handles the complete removal of mi6 integration from the framework.
239    /// For config-based frameworks, it removes mi6 hooks from the settings file.
240    /// For plugin-based frameworks (like Claude Code), it removes the plugin directory.
241    ///
242    /// # Arguments
243    /// * `local` - If true, uninstall from project-level config instead of user-level.
244    /// * `settings_local` - If true, uninstall from the `.local` variant.
245    ///
246    /// # Returns
247    /// `Ok(true)` if hooks were removed, `Ok(false)` if no hooks were found,
248    /// or an error if uninstallation failed.
249    fn uninstall_hooks(&self, local: bool, settings_local: bool) -> Result<bool, InitError> {
250        install::default_uninstall_hooks(self, local, settings_local)
251    }
252}
253
254/// Parsed hook input with normalized fields.
255///
256/// This struct contains fields extracted from framework-specific hook JSON,
257/// normalized to a common format for storage.
258#[derive(Debug, Default, Clone)]
259pub struct ParsedHookInput {
260    /// Session identifier
261    pub session_id: Option<String>,
262    /// Tool use correlation ID
263    pub tool_use_id: Option<String>,
264    /// Tool name (Bash, Read, Write, etc.)
265    pub tool_name: Option<String>,
266    /// Subagent type for Task tool
267    pub subagent_type: Option<String>,
268    /// Spawned agent ID from PostToolUse
269    pub spawned_agent_id: Option<String>,
270    /// Permission mode
271    pub permission_mode: Option<String>,
272    /// Transcript file path
273    pub transcript_path: Option<String>,
274    /// Working directory
275    pub cwd: Option<String>,
276    /// SessionStart source (startup, resume, clear)
277    pub session_source: Option<String>,
278    /// Agent ID from SubagentStop/SubagentStart
279    pub agent_id: Option<String>,
280    /// Path to agent's transcript file (from SubagentStop)
281    pub agent_transcript_path: Option<String>,
282    /// PreCompact trigger type (manual, auto)
283    pub compact_trigger: Option<String>,
284}
285
286/// Mode for auto-detecting frameworks when no names are specified.
287#[derive(Debug, Clone, Copy)]
288pub enum FrameworkResolutionMode {
289    /// Find frameworks that are installed on the system (for activation).
290    Installed,
291    /// Find frameworks that have mi6 hooks currently active (for deactivation).
292    Active {
293        /// Check project-level config instead of user-level.
294        local: bool,
295        /// Check the `.local` variant (not committed to git).
296        settings_local: bool,
297    },
298}
299
300/// Resolve framework names to adapters with deduplication and validation.
301///
302/// When `names` is empty, auto-detection is performed based on the `mode`:
303/// - [`FrameworkResolutionMode::Installed`]: Returns frameworks installed on the system.
304/// - [`FrameworkResolutionMode::Active`]: Returns frameworks with active mi6 hooks.
305///
306/// When `names` contains framework names, each is validated and the list is deduplicated.
307///
308/// # Arguments
309/// * `names` - Framework names to resolve (empty for auto-detection).
310/// * `mode` - How to auto-detect frameworks when `names` is empty. Pass `None` to
311///   require explicit framework names and error if `names` is empty.
312///
313/// # Returns
314/// A deduplicated list of framework adapters, or an error if:
315/// - A framework name is unknown
316/// - Auto-detection found no frameworks
317///
318/// # Example
319/// ```
320/// use mi6_core::framework::{resolve_frameworks, FrameworkResolutionMode};
321///
322/// // Resolve specific frameworks
323/// let adapters = resolve_frameworks(&["claude".to_string()], None).unwrap();
324/// assert_eq!(adapters.len(), 1);
325///
326/// // Auto-detect installed frameworks
327/// let installed = resolve_frameworks(&[], Some(FrameworkResolutionMode::Installed));
328/// ```
329pub fn resolve_frameworks(
330    names: &[String],
331    mode: Option<FrameworkResolutionMode>,
332) -> Result<Vec<&'static dyn FrameworkAdapter>, FrameworkResolutionError> {
333    if names.is_empty() {
334        // Auto-detect based on mode
335        let Some(mode) = mode else {
336            return Err(FrameworkResolutionError::NoFrameworksFound(
337                "no frameworks specified".to_string(),
338            ));
339        };
340
341        let adapters = match mode {
342            FrameworkResolutionMode::Installed => {
343                let installed = installed_frameworks();
344                if installed.is_empty() {
345                    let all_names: Vec<&str> = all_adapters().iter().map(|a| a.name()).collect();
346                    return Err(FrameworkResolutionError::NoFrameworksFound(format!(
347                        "No supported AI coding frameworks detected.\n\
348                         Supported frameworks: {}\n\
349                         Install one first, or specify explicitly: mi6 enable claude",
350                        all_names.join(", ")
351                    )));
352                }
353                installed
354            }
355            FrameworkResolutionMode::Active {
356                local,
357                settings_local,
358            } => {
359                let active: Vec<_> = all_adapters()
360                    .into_iter()
361                    .filter(|a| a.has_mi6_hooks(local, settings_local))
362                    .collect();
363                if active.is_empty() {
364                    return Err(FrameworkResolutionError::NoFrameworksFound(
365                        "No frameworks have mi6 enabled".to_string(),
366                    ));
367                }
368                active
369            }
370        };
371
372        Ok(adapters)
373    } else {
374        // Resolve each specified framework, deduplicating by name
375        let mut adapters: Vec<&'static dyn FrameworkAdapter> = Vec::new();
376        let mut seen = HashSet::new();
377        for name in names {
378            let adapter = get_adapter(name)
379                .ok_or_else(|| FrameworkResolutionError::UnknownFramework(name.clone()))?;
380            if seen.insert(adapter.name()) {
381                adapters.push(adapter);
382            }
383        }
384        Ok(adapters)
385    }
386}
387
388/// Get all available framework adapters.
389pub fn all_adapters() -> Vec<&'static dyn FrameworkAdapter> {
390    vec![&ClaudeAdapter, &GeminiAdapter, &CodexAdapter]
391}
392
393/// Get a framework adapter by name.
394pub fn get_adapter(name: &str) -> Option<&'static dyn FrameworkAdapter> {
395    match name.to_lowercase().as_str() {
396        "claude" => Some(&ClaudeAdapter),
397        "gemini" => Some(&GeminiAdapter),
398        "codex" => Some(&CodexAdapter),
399        _ => None,
400    }
401}
402
403/// Detect the current framework from environment variables.
404///
405/// Checks environment variables set by each framework to determine
406/// which one is currently invoking mi6. Returns the first matching
407/// framework if multiple are detected.
408pub fn detect_framework() -> Option<&'static dyn FrameworkAdapter> {
409    all_adapters().into_iter().find(|adapter| adapter.detect())
410}
411
412/// Detect all frameworks that match current environment variables.
413///
414/// Useful for diagnosing ambiguous detection scenarios.
415pub fn detect_all_frameworks() -> Vec<&'static dyn FrameworkAdapter> {
416    all_adapters()
417        .into_iter()
418        .filter(|adapter| adapter.detect())
419        .collect()
420}
421
422/// Get all frameworks that are installed on the system.
423///
424/// Uses the `is_installed()` method on each adapter to determine
425/// if the framework's config directory exists or CLI tool is available.
426pub fn installed_frameworks() -> Vec<&'static dyn FrameworkAdapter> {
427    all_adapters()
428        .into_iter()
429        .filter(|adapter| adapter.is_installed())
430        .collect()
431}
432
433/// Get the default framework (Claude Code).
434pub fn default_adapter() -> &'static dyn FrameworkAdapter {
435    &ClaudeAdapter
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_get_adapter_claude() {
444        assert!(get_adapter("claude").is_some());
445        assert!(get_adapter("CLAUDE").is_some());
446    }
447
448    #[test]
449    fn test_get_adapter_gemini() {
450        assert!(get_adapter("gemini").is_some());
451        assert!(get_adapter("GEMINI").is_some());
452    }
453
454    #[test]
455    fn test_get_adapter_unknown() {
456        assert!(get_adapter("unknown").is_none());
457        assert!(get_adapter("").is_none());
458    }
459
460    #[test]
461    fn test_get_adapter_codex() {
462        assert!(get_adapter("codex").is_some());
463        assert!(get_adapter("CODEX").is_some());
464    }
465
466    #[test]
467    fn test_all_adapters() {
468        let adapters = all_adapters();
469        assert!(!adapters.is_empty());
470        assert!(adapters.iter().any(|a| a.name() == "claude"));
471        assert!(adapters.iter().any(|a| a.name() == "gemini"));
472        assert!(adapters.iter().any(|a| a.name() == "codex"));
473    }
474
475    #[test]
476    fn test_default_adapter() {
477        let adapter = default_adapter();
478        assert_eq!(adapter.name(), "claude");
479    }
480
481    #[test]
482    fn test_resolve_frameworks_single() {
483        let adapters = resolve_frameworks(&["claude".to_string()], None).unwrap();
484        assert_eq!(adapters.len(), 1);
485        assert_eq!(adapters[0].name(), "claude");
486    }
487
488    #[test]
489    fn test_resolve_frameworks_multiple() {
490        let adapters =
491            resolve_frameworks(&["claude".to_string(), "gemini".to_string()], None).unwrap();
492        assert_eq!(adapters.len(), 2);
493        assert!(adapters.iter().any(|a| a.name() == "claude"));
494        assert!(adapters.iter().any(|a| a.name() == "gemini"));
495    }
496
497    #[test]
498    fn test_resolve_frameworks_deduplicates() {
499        // Same framework specified twice should be deduplicated
500        let adapters = resolve_frameworks(
501            &[
502                "claude".to_string(),
503                "claude".to_string(),
504                "CLAUDE".to_string(),
505            ],
506            None,
507        )
508        .unwrap();
509        assert_eq!(adapters.len(), 1);
510        assert_eq!(adapters[0].name(), "claude");
511    }
512
513    #[test]
514    fn test_resolve_frameworks_unknown_framework() {
515        let result = resolve_frameworks(&["unknown_framework".to_string()], None);
516        match result {
517            Err(FrameworkResolutionError::UnknownFramework(name)) => {
518                assert_eq!(name, "unknown_framework");
519            }
520            _ => panic!("expected UnknownFramework error"),
521        }
522    }
523
524    #[test]
525    fn test_resolve_frameworks_empty_no_mode() {
526        // Empty names with None mode should error
527        let result = resolve_frameworks(&[], None);
528        match result {
529            Err(FrameworkResolutionError::NoFrameworksFound(_)) => {}
530            _ => panic!("expected NoFrameworksFound error"),
531        }
532    }
533
534    #[test]
535    fn test_resolve_frameworks_case_insensitive() {
536        // Framework names should be case-insensitive
537        let adapters = resolve_frameworks(&["CLAUDE".to_string()], None).unwrap();
538        assert_eq!(adapters.len(), 1);
539        assert_eq!(adapters[0].name(), "claude");
540    }
541
542    #[test]
543    fn test_resolve_frameworks_mixed_case_deduplication() {
544        // Mixed case should deduplicate correctly
545        let adapters = resolve_frameworks(
546            &[
547                "claude".to_string(),
548                "CLAUDE".to_string(),
549                "Claude".to_string(),
550            ],
551            None,
552        )
553        .unwrap();
554        assert_eq!(adapters.len(), 1);
555    }
556}