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