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}