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}