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