mi6_core/framework/
install.rs

1//! Hook installation logic.
2//!
3//! This module provides programmatic access to hook installation,
4//! allowing Rust code to install mi6 hooks without going through the CLI.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use mi6_core::{Config, framework::{InitOptions, initialize}};
10//!
11//! let config = Config::load()?;
12//!
13//! // Initialize for a single framework
14//! let options = InitOptions::for_framework("claude")
15//!     .otel(true)
16//!     .otel_port(4318);
17//!
18//! let result = initialize(&config, options)?;
19//! println!("Hooks installed to: {}", result.settings_path.display());
20//! ```
21
22use std::path::{Path, PathBuf};
23
24use crate::config::{Config, DEFAULT_OTEL_PORT, OTEL_ENV_KEYS, generate_otel_env};
25use crate::model::error::InitError;
26
27use super::{ConfigFormat, FrameworkAdapter, get_adapter};
28
29/// Options for hook initialization and activation.
30///
31/// This struct contains all the options needed to initialize mi6 programmatically.
32#[derive(Debug, Clone)]
33pub struct InitOptions {
34    /// Frameworks to install hooks for (e.g., `["claude", "gemini", "codex"]`).
35    /// If empty, auto-detection will be used.
36    pub frameworks: Vec<String>,
37    /// Install hooks to project config instead of global.
38    pub local: bool,
39    /// Install hooks to project local config (not committed to git).
40    pub settings_local: bool,
41    /// Configure OpenTelemetry env vars for token tracking.
42    pub otel: bool,
43    /// Port for OTel server.
44    pub otel_port: u16,
45    /// Remove OTel configuration (disables token tracking).
46    pub remove_otel: bool,
47    /// Only initialize database, skip hook installation (high-level API only).
48    pub db_only: bool,
49    /// Only install hooks, skip database initialization (high-level API only).
50    pub hooks_only: bool,
51}
52
53impl Default for InitOptions {
54    fn default() -> Self {
55        Self {
56            frameworks: vec![],
57            local: false,
58            settings_local: false,
59            otel: false,
60            otel_port: DEFAULT_OTEL_PORT,
61            remove_otel: false,
62            db_only: false,
63            hooks_only: false,
64        }
65    }
66}
67
68impl InitOptions {
69    /// Create new InitOptions with default values.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Create InitOptions for a single framework.
75    pub fn for_framework(framework: impl Into<String>) -> Self {
76        Self {
77            frameworks: vec![framework.into()],
78            ..Default::default()
79        }
80    }
81
82    /// Create InitOptions for multiple frameworks.
83    pub fn for_frameworks(frameworks: Vec<String>) -> Self {
84        Self {
85            frameworks,
86            ..Default::default()
87        }
88    }
89
90    /// Get the list of frameworks to initialize.
91    pub fn get_frameworks(&self) -> Vec<String> {
92        self.frameworks.clone()
93    }
94
95    /// Set install location to project config instead of global.
96    pub fn local(mut self, local: bool) -> Self {
97        self.local = local;
98        self
99    }
100
101    /// Set install location to project local config (not committed to git).
102    pub fn settings_local(mut self, settings_local: bool) -> Self {
103        self.settings_local = settings_local;
104        self
105    }
106
107    /// Enable OpenTelemetry configuration for token tracking.
108    pub fn otel(mut self, otel: bool) -> Self {
109        self.otel = otel;
110        self
111    }
112
113    /// Set the port for OTel server.
114    pub fn otel_port(mut self, port: u16) -> Self {
115        self.otel_port = port;
116        self
117    }
118
119    /// Remove OTel configuration (disables token tracking).
120    pub fn remove_otel(mut self, remove: bool) -> Self {
121        self.remove_otel = remove;
122        self
123    }
124
125    /// Only initialize database, skip hook installation.
126    pub fn db_only(mut self, db_only: bool) -> Self {
127        self.db_only = db_only;
128        self
129    }
130
131    /// Only install hooks, skip database initialization.
132    pub fn hooks_only(mut self, hooks_only: bool) -> Self {
133        self.hooks_only = hooks_only;
134        self
135    }
136}
137
138/// Result of initialization.
139///
140/// Contains information about what was initialized and where.
141#[derive(Debug, Clone)]
142pub struct InitResult {
143    /// Path to the database (if database initialization was requested).
144    pub db_path: Option<PathBuf>,
145    /// Path where settings/hooks were installed.
146    pub settings_path: PathBuf,
147    /// The hooks configuration that was generated.
148    pub hooks_config: serde_json::Value,
149}
150
151/// Convert a serde_json::Value to a TOML string.
152///
153/// Useful for frameworks that use TOML configuration files.
154pub fn json_to_toml_string(json: &serde_json::Value) -> Result<String, InitError> {
155    let toml_value: toml::Value = serde_json::from_value(json.clone())
156        .map_err(|e| InitError::Config(format!("failed to convert JSON to TOML: {e}")))?;
157    toml::to_string_pretty(&toml_value)
158        .map_err(|e| InitError::Config(format!("failed to serialize TOML: {e}")))
159}
160
161/// Initialize mi6 for a single framework.
162///
163/// This is the main entry point for programmatic initialization of a single
164/// framework. It:
165/// 1. Resolves the framework adapter
166/// 2. Generates the hooks configuration
167/// 3. Installs the configuration to the appropriate settings file
168pub fn initialize(config: &Config, options: InitOptions) -> Result<InitResult, InitError> {
169    let frameworks = options.get_frameworks();
170    let framework = frameworks
171        .first()
172        .cloned()
173        .unwrap_or_else(|| "claude".to_string());
174
175    initialize_framework(config, &framework, &options)
176}
177
178/// Initialize mi6 for multiple frameworks.
179///
180/// This function initializes mi6 for all specified frameworks, returning
181/// a result for each one.
182pub fn initialize_all(config: &Config, options: InitOptions) -> Result<Vec<InitResult>, InitError> {
183    let frameworks = options.get_frameworks();
184    let mut results = Vec::with_capacity(frameworks.len());
185
186    for framework in &frameworks {
187        let result = initialize_framework(config, framework, &options)?;
188        results.push(result);
189    }
190
191    Ok(results)
192}
193
194/// Initialize mi6 for a specific framework.
195fn initialize_framework(
196    config: &Config,
197    framework: &str,
198    options: &InitOptions,
199) -> Result<InitResult, InitError> {
200    let adapter =
201        get_adapter(framework).ok_or_else(|| InitError::UnknownFramework(framework.to_string()))?;
202
203    let settings_path = adapter.settings_path(options.local, options.settings_local)?;
204    let enabled_events = config.hooks.enabled_hooks();
205    let hooks_json =
206        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
207
208    let otel_env = if options.otel {
209        Some(generate_otel_env(options.otel_port))
210    } else {
211        None
212    };
213
214    adapter.install_hooks(
215        &settings_path,
216        &hooks_json,
217        otel_env.clone(),
218        options.remove_otel,
219    )?;
220
221    // Build final hooks_config for return value (include env if otel enabled)
222    let mut hooks_config = hooks_json;
223    if let Some(env) = otel_env {
224        hooks_config["env"] = env;
225    }
226
227    Ok(InitResult {
228        db_path: config.db_path().ok(),
229        settings_path,
230        hooks_config,
231    })
232}
233
234/// Generate hooks configuration without writing to disk.
235///
236/// This is useful for previewing what would be installed (e.g., `mi6 enable --print`).
237pub fn generate_config(
238    adapter: &dyn FrameworkAdapter,
239    config: &Config,
240    options: &InitOptions,
241) -> serde_json::Value {
242    let enabled_events = config.hooks.enabled_hooks();
243    let mut hooks_json =
244        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
245
246    if options.otel {
247        hooks_json["env"] = generate_otel_env(options.otel_port);
248    }
249
250    hooks_json
251}
252
253// ============================================================================
254// Default trait method implementations
255// ============================================================================
256
257/// Resolve the settings path for a framework.
258pub(crate) fn default_settings_path<A: FrameworkAdapter + ?Sized>(
259    adapter: &A,
260    local: bool,
261    settings_local: bool,
262) -> Result<PathBuf, InitError> {
263    if settings_local {
264        let project_path = adapter.project_config_path();
265        let file_name = project_path
266            .file_stem()
267            .and_then(|s| s.to_str())
268            .unwrap_or("settings");
269        let extension = project_path
270            .extension()
271            .and_then(|s| s.to_str())
272            .unwrap_or("json");
273        let local_name = format!("{file_name}.local.{extension}");
274        Ok(project_path.with_file_name(local_name))
275    } else if local {
276        Ok(adapter.project_config_path())
277    } else {
278        adapter
279            .user_config_path()
280            .ok_or_else(|| InitError::SettingsPath("failed to determine home directory".into()))
281    }
282}
283
284/// Check if a framework has mi6 hooks active in its config file.
285pub(crate) fn default_has_mi6_hooks<A: FrameworkAdapter + ?Sized>(
286    adapter: &A,
287    local: bool,
288    settings_local: bool,
289) -> bool {
290    let Ok(settings_path) = adapter.settings_path(local, settings_local) else {
291        return false;
292    };
293
294    if !settings_path.exists() {
295        return false;
296    }
297
298    match adapter.config_format() {
299        ConfigFormat::Json => {
300            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
301                return false;
302            };
303            let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
304                return false;
305            };
306            adapter.remove_hooks(json).is_some()
307        }
308        ConfigFormat::Toml => {
309            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
310                return false;
311            };
312            let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
313                return false;
314            };
315            let Ok(json) = serde_json::to_value(toml_val) else {
316                return false;
317            };
318            adapter.remove_hooks(json).is_some()
319        }
320    }
321}
322
323/// Install hooks configuration to a settings file.
324pub(crate) fn default_install_hooks<A: FrameworkAdapter + ?Sized>(
325    adapter: &A,
326    path: &Path,
327    hooks: &serde_json::Value,
328    otel_env: Option<serde_json::Value>,
329    remove_otel: bool,
330) -> Result<(), InitError> {
331    // Create parent directory if needed
332    if let Some(parent) = path.parent() {
333        std::fs::create_dir_all(parent)?;
334    }
335
336    match adapter.config_format() {
337        ConfigFormat::Json => install_json_settings(adapter, path, hooks, otel_env, remove_otel),
338        ConfigFormat::Toml => install_toml_settings(adapter, path, hooks, remove_otel),
339    }
340}
341
342/// Install settings to a JSON configuration file.
343fn install_json_settings<A: FrameworkAdapter + ?Sized>(
344    adapter: &A,
345    settings_path: &Path,
346    new_hooks: &serde_json::Value,
347    otel_env: Option<serde_json::Value>,
348    remove_otel: bool,
349) -> Result<(), InitError> {
350    let existing: Option<serde_json::Value> = if settings_path.exists() {
351        let contents = std::fs::read_to_string(settings_path)?;
352        serde_json::from_str(&contents).ok()
353    } else {
354        None
355    };
356
357    let mut settings = adapter.merge_config(new_hooks.clone(), existing);
358
359    if remove_otel {
360        if let Some(existing_env) = settings.get_mut("env")
361            && let Some(env_obj) = existing_env.as_object_mut()
362        {
363            for key in OTEL_ENV_KEYS {
364                env_obj.remove(*key);
365            }
366        }
367    } else if let Some(new_env) = otel_env {
368        if let Some(existing_env) = settings.get_mut("env") {
369            if let (Some(existing_obj), Some(new_obj)) =
370                (existing_env.as_object_mut(), new_env.as_object())
371            {
372                for (key, value) in new_obj {
373                    existing_obj.insert(key.clone(), value.clone());
374                }
375            }
376        } else {
377            settings["env"] = new_env;
378        }
379    }
380
381    let output = serde_json::to_string_pretty(&settings)
382        .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?;
383    std::fs::write(settings_path, output)?;
384
385    Ok(())
386}
387
388/// Install settings to a TOML configuration file.
389fn install_toml_settings<A: FrameworkAdapter + ?Sized>(
390    adapter: &A,
391    settings_path: &Path,
392    new_config: &serde_json::Value,
393    remove_otel: bool,
394) -> Result<(), InitError> {
395    let existing: Option<serde_json::Value> = if settings_path.exists() {
396        let contents = std::fs::read_to_string(settings_path)?;
397        toml::from_str::<toml::Value>(&contents)
398            .ok()
399            .and_then(|v| serde_json::to_value(v).ok())
400    } else {
401        None
402    };
403
404    let mut settings = adapter.merge_config(new_config.clone(), existing);
405
406    if remove_otel && let Some(obj) = settings.as_object_mut() {
407        obj.remove("otel");
408    }
409
410    let toml_str = json_to_toml_string(&settings)?;
411    std::fs::write(settings_path, toml_str)?;
412
413    Ok(())
414}
415
416/// Serialize configuration to string in the appropriate format.
417pub(crate) fn default_serialize_config<A: FrameworkAdapter + ?Sized>(
418    adapter: &A,
419    config: &serde_json::Value,
420) -> Result<String, InitError> {
421    match adapter.config_format() {
422        ConfigFormat::Json => serde_json::to_string_pretty(config)
423            .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}"))),
424        ConfigFormat::Toml => json_to_toml_string(config),
425    }
426}
427
428/// Uninstall mi6 hooks from a framework's configuration.
429///
430/// This is the default implementation that removes mi6 hooks from a settings file.
431/// Frameworks with plugin-based installation (like Claude) should override this.
432pub(crate) fn default_uninstall_hooks<A: FrameworkAdapter + ?Sized>(
433    adapter: &A,
434    local: bool,
435    settings_local: bool,
436) -> Result<bool, InitError> {
437    let settings_path = adapter.settings_path(local, settings_local)?;
438
439    if !settings_path.exists() {
440        return Ok(false);
441    }
442
443    let contents = std::fs::read_to_string(&settings_path)?;
444
445    let existing: serde_json::Value = match adapter.config_format() {
446        ConfigFormat::Json => serde_json::from_str(&contents)
447            .map_err(|e| InitError::Config(format!("failed to parse JSON: {e}")))?,
448        ConfigFormat::Toml => {
449            let toml_val: toml::Value = toml::from_str(&contents)
450                .map_err(|e| InitError::Config(format!("failed to parse TOML: {e}")))?;
451            serde_json::to_value(toml_val)
452                .map_err(|e| InitError::Config(format!("failed to convert TOML to JSON: {e}")))?
453        }
454    };
455
456    // Try to remove mi6 hooks
457    if let Some(modified) = adapter.remove_hooks(existing) {
458        // Write back the modified config
459        let output = match adapter.config_format() {
460            ConfigFormat::Json => serde_json::to_string_pretty(&modified)
461                .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?,
462            ConfigFormat::Toml => json_to_toml_string(&modified)?,
463        };
464        std::fs::write(&settings_path, output)?;
465        Ok(true)
466    } else {
467        Ok(false)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::ClaudeAdapter;
475
476    #[test]
477    fn test_init_options_default() {
478        let opts = InitOptions::default();
479        assert!(opts.frameworks.is_empty());
480        assert!(!opts.local);
481        assert!(!opts.settings_local);
482        assert!(!opts.otel);
483        assert_eq!(opts.otel_port, 4318);
484        assert!(!opts.remove_otel);
485    }
486
487    #[test]
488    fn test_init_options_for_framework() {
489        let opts = InitOptions::for_framework("claude");
490        assert_eq!(opts.get_frameworks(), vec!["claude"]);
491        assert!(!opts.local);
492        assert!(!opts.otel);
493    }
494
495    #[test]
496    fn test_init_options_for_frameworks() {
497        let opts = InitOptions::for_frameworks(vec!["claude".into(), "gemini".into()]);
498        assert_eq!(opts.get_frameworks(), vec!["claude", "gemini"]);
499    }
500
501    #[test]
502    fn test_init_options_builder() {
503        let opts = InitOptions::for_framework("claude")
504            .local(true)
505            .otel(true)
506            .otel_port(9999);
507        assert_eq!(opts.get_frameworks(), vec!["claude"]);
508        assert!(opts.local);
509        assert!(opts.otel);
510        assert_eq!(opts.otel_port, 9999);
511    }
512
513    #[test]
514    fn test_init_error_unknown_framework() {
515        let config = Config::default();
516        let options = InitOptions::for_framework("unknown");
517
518        let result = initialize(&config, options);
519        assert!(matches!(result, Err(InitError::UnknownFramework(_))));
520    }
521
522    #[test]
523    fn test_settings_path_global() -> Result<(), String> {
524        let adapter = ClaudeAdapter;
525        let path = adapter
526            .settings_path(false, false)
527            .map_err(|e| e.to_string())?;
528        // Claude now uses marketplace-based installation
529        assert!(
530            path.to_string_lossy().contains("marketplace://"),
531            "expected marketplace URI, got: {}",
532            path.display()
533        );
534        Ok(())
535    }
536
537    #[test]
538    fn test_settings_path_local() -> Result<(), String> {
539        let adapter = ClaudeAdapter;
540        let path = adapter
541            .settings_path(true, false)
542            .map_err(|e| e.to_string())?;
543        // Claude marketplace installation doesn't distinguish local vs global
544        assert!(
545            path.to_string_lossy().contains("marketplace://"),
546            "expected marketplace URI, got: {}",
547            path.display()
548        );
549        Ok(())
550    }
551
552    #[test]
553    fn test_settings_path_settings_local() -> Result<(), String> {
554        // For Claude plugins, settings_local is ignored (plugins don't have a .local variant)
555        // Test with Codex which still uses config files
556        let adapter = crate::CodexAdapter;
557        let path = adapter
558            .settings_path(false, true)
559            .map_err(|e| e.to_string())?;
560        assert!(path.to_string_lossy().contains(".local."));
561        Ok(())
562    }
563
564    #[test]
565    fn test_json_to_toml_string() -> Result<(), String> {
566        let json = serde_json::json!({
567            "key": "value",
568            "number": 42
569        });
570        let toml_str = json_to_toml_string(&json).map_err(|e| e.to_string())?;
571        assert!(toml_str.contains("key = \"value\""));
572        assert!(toml_str.contains("number = 42"));
573        Ok(())
574    }
575
576    #[test]
577    fn test_generate_config() {
578        let config = Config::default();
579        let adapter = ClaudeAdapter;
580        let options = InitOptions::for_framework("claude")
581            .otel(true)
582            .otel_port(4318);
583
584        let config_json = generate_config(&adapter, &config, &options);
585        assert!(config_json["hooks"].is_object());
586        assert!(config_json["env"].is_object());
587    }
588
589    #[test]
590    fn test_serialize_config_json() -> Result<(), String> {
591        let adapter = ClaudeAdapter;
592        let config = serde_json::json!({"key": "value"});
593
594        let output = adapter
595            .serialize_config(&config)
596            .map_err(|e| e.to_string())?;
597        assert!(output.contains("\"key\""));
598        assert!(output.contains("\"value\""));
599        Ok(())
600    }
601
602    #[test]
603    fn test_serialize_config_toml() -> Result<(), String> {
604        let adapter = crate::CodexAdapter;
605        let config = serde_json::json!({"key": "value"});
606
607        let output = adapter
608            .serialize_config(&config)
609            .map_err(|e| e.to_string())?;
610        assert!(output.contains("key = \"value\""));
611        Ok(())
612    }
613}