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