mi6_core/framework/
claude.rs

1//! Claude Code framework adapter.
2//!
3//! mi6 installs as a Claude Code plugin via the official marketplace system.
4//! The plugin files are embedded in the binary and written to `~/.mi6/claude-plugin/`
5//! during installation. This avoids needing a separate GitHub repo for the marketplace.
6//!
7//! # Installation Flow
8//!
9//! 1. `mi6 enable` writes embedded plugin files to `~/.mi6/claude-plugin/`
10//! 2. Registers the local directory as a marketplace via `claude plugin marketplace add`
11//! 3. Installs the plugin to user scope via `claude plugin install mi6@mi6 --scope user`
12//!
13//! # Uninstallation
14//!
15//! `mi6 disable` uninstalls via `claude plugin uninstall mi6@mi6`. It detects the
16//! installation scope to handle legacy project-scope installs correctly.
17
18use super::{
19    FrameworkAdapter, InstallHooksResult, ParsedHookInput, ParsedHookInputBuilder,
20    UninstallHooksResult, common,
21};
22use crate::model::EventType;
23use crate::model::error::InitError;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27/// Plugin identifier in format `plugin@marketplace`.
28const PLUGIN_ID: &str = "mi6@mi6";
29
30/// Embedded marketplace.json content.
31const MARKETPLACE_JSON: &str = include_str!("claude-plugin/marketplace.json");
32
33/// Embedded plugin.json content.
34const PLUGIN_JSON: &str = include_str!("claude-plugin/plugin.json");
35
36/// Embedded hooks.json content.
37const HOOKS_JSON: &str = include_str!("claude-plugin/hooks.json");
38
39/// Claude Code framework adapter.
40pub struct ClaudeAdapter;
41
42impl ClaudeAdapter {
43    /// Run a claude CLI command, optionally ignoring specific error patterns.
44    ///
45    /// When `ignore_patterns` contains strings that match the error output,
46    /// the command is treated as successful (for idempotent operations).
47    ///
48    /// Returns the full command string that was executed for verbose logging.
49    fn run_claude_command_opt(
50        args: &[&str],
51        cwd: Option<&Path>,
52        ignore_patterns: &[&str],
53    ) -> Result<String, InitError> {
54        let mut cmd = Command::new("claude");
55        cmd.args(args);
56        if let Some(dir) = cwd {
57            cmd.current_dir(dir);
58        }
59
60        // Build the command string for verbose output
61        let cmd_str = format!("claude {}", args.join(" "));
62
63        let output = cmd
64            .output()
65            .map_err(|e| InitError::Config(format!("failed to run claude CLI: {e}")))?;
66
67        if !output.status.success() {
68            let stderr = String::from_utf8_lossy(&output.stderr);
69            let stdout = String::from_utf8_lossy(&output.stdout);
70            let combined = format!("{} {}", stderr.trim(), stdout.trim());
71
72            // Check if this is an expected "already done" error
73            if ignore_patterns.iter().any(|p| combined.contains(p)) {
74                return Ok(cmd_str);
75            }
76
77            return Err(InitError::Config(format!(
78                "claude {} failed: {}",
79                args.first().unwrap_or(&"command"),
80                combined.trim()
81            )));
82        }
83
84        Ok(cmd_str)
85    }
86
87    /// Run a claude CLI command.
88    ///
89    /// Returns the full command string that was executed for verbose logging.
90    fn run_claude_command(args: &[&str]) -> Result<String, InitError> {
91        Self::run_claude_command_opt(args, None, &[])
92    }
93
94    /// Get the path to the local marketplace cache directory (`~/.mi6/claude-plugin/`).
95    fn marketplace_cache_path() -> Result<PathBuf, InitError> {
96        let home = dirs::home_dir()
97            .ok_or_else(|| InitError::Config("could not determine home directory".to_string()))?;
98        Ok(home.join(".mi6/claude-plugin"))
99    }
100
101    /// Write embedded marketplace files to the local cache.
102    ///
103    /// Creates:
104    /// ```text
105    /// ~/.mi6/claude-plugin/
106    /// ├── .claude-plugin/marketplace.json
107    /// └── plugins/mi6/
108    ///     ├── .claude-plugin/plugin.json
109    ///     └── hooks/hooks.json
110    /// ```
111    fn write_marketplace_to_cache() -> Result<PathBuf, InitError> {
112        let cache_path = Self::marketplace_cache_path()?;
113
114        let marketplace_meta = cache_path.join(".claude-plugin");
115        let plugin_meta = cache_path.join("plugins/mi6/.claude-plugin");
116        let hooks_dir = cache_path.join("plugins/mi6/hooks");
117
118        // Create all directories
119        for dir in [&marketplace_meta, &plugin_meta, &hooks_dir] {
120            std::fs::create_dir_all(dir).map_err(|e| {
121                InitError::Config(format!("failed to create {}: {e}", dir.display()))
122            })?;
123        }
124
125        // Write embedded files
126        let files = [
127            (marketplace_meta.join("marketplace.json"), MARKETPLACE_JSON),
128            (plugin_meta.join("plugin.json"), PLUGIN_JSON),
129            (hooks_dir.join("hooks.json"), HOOKS_JSON),
130        ];
131
132        for (path, content) in files {
133            std::fs::write(&path, content).map_err(|e| {
134                InitError::Config(format!("failed to write {}: {e}", path.display()))
135            })?;
136        }
137
138        Ok(cache_path)
139    }
140
141    /// Check if the mi6 plugin is enabled in settings.json.
142    fn is_plugin_installed() -> bool {
143        let Some(home) = dirs::home_dir() else {
144            return false;
145        };
146        let settings_path = home.join(".claude/settings.json");
147        let Ok(contents) = std::fs::read_to_string(&settings_path) else {
148            return false;
149        };
150        let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
151            return false;
152        };
153        json.get("enabledPlugins")
154            .is_some_and(|enabled| enabled.get(PLUGIN_ID).is_some())
155    }
156
157    /// Get the installation scope and project path from installed_plugins.json.
158    ///
159    /// Returns `Some((scope, project_path))` where:
160    /// - `scope` is "user" or "project"
161    /// - `project_path` is only present for project-scope installs
162    fn get_plugin_install_scope() -> Option<(String, Option<PathBuf>)> {
163        let home = dirs::home_dir()?;
164        let plugins_path = home.join(".claude/plugins/installed_plugins.json");
165        let contents = std::fs::read_to_string(&plugins_path).ok()?;
166        let json: serde_json::Value = serde_json::from_str(&contents).ok()?;
167
168        // Structure: { "plugins": { "mi6@mi6": [{ "scope": "...", "projectPath": "..." }] } }
169        let install = json.get("plugins")?.get(PLUGIN_ID)?.as_array()?.first()?;
170
171        let scope = install.get("scope")?.as_str()?.to_string();
172        let project_path = install
173            .get("projectPath")
174            .and_then(|v| v.as_str())
175            .map(PathBuf::from);
176
177        Some((scope, project_path))
178    }
179}
180
181impl FrameworkAdapter for ClaudeAdapter {
182    fn name(&self) -> &'static str {
183        "claude"
184    }
185
186    fn display_name(&self) -> &'static str {
187        "Claude Code"
188    }
189
190    fn project_config_path(&self) -> PathBuf {
191        // For marketplace plugins, there's no local config to edit
192        // Return a placeholder path
193        PathBuf::from(".claude/plugins/mi6/hooks/hooks.json")
194    }
195
196    fn user_config_path(&self) -> Option<PathBuf> {
197        // For marketplace plugins, Claude manages the installation path
198        None
199    }
200
201    fn generate_hooks_config(
202        &self,
203        enabled_events: &[EventType],
204        mi6_bin: &str,
205        otel_enabled: bool,
206        otel_port: u16,
207    ) -> serde_json::Value {
208        // This is used for --print mode to show what hooks would be configured
209        let mut hooks = serde_json::Map::new();
210
211        for event in enabled_events {
212            let command = if otel_enabled && *event == EventType::SessionStart {
213                format!(
214                    "{} otel start --port {} </dev/null >/dev/null 2>&1; {} ingest event {} --framework claude",
215                    mi6_bin, otel_port, mi6_bin, event
216                )
217            } else {
218                format!("{} ingest event {} --framework claude", mi6_bin, event)
219            };
220
221            let hook_entry = serde_json::json!([{
222                "matcher": "*",
223                "hooks": [{
224                    "type": "command",
225                    "command": command,
226                    "timeout": 10
227                }]
228            }]);
229            hooks.insert(event.to_string(), hook_entry);
230        }
231
232        serde_json::json!({ "hooks": hooks })
233    }
234
235    fn merge_config(
236        &self,
237        generated: serde_json::Value,
238        _existing: Option<serde_json::Value>,
239    ) -> serde_json::Value {
240        generated
241    }
242
243    fn parse_hook_input(
244        &self,
245        _event_type: &str,
246        stdin_json: &serde_json::Value,
247    ) -> ParsedHookInput {
248        ParsedHookInputBuilder::new(stdin_json)
249            .session_id("session_id")
250            .tool_use_id("tool_use_id")
251            .tool_name("tool_name")
252            .cwd("cwd")
253            .permission_mode("permission_mode")
254            .transcript_path("transcript_path")
255            .subagent_type("tool_input.subagent_type")
256            .spawned_agent_id("tool_response.agentId")
257            .session_source("source")
258            .agent_id("agent_id")
259            .agent_transcript_path("agent_transcript_path")
260            .compact_trigger("trigger")
261            .build()
262        // Note: Claude doesn't provide model/duration/tokens/cost via hooks (uses OTel)
263    }
264
265    fn map_event_type(&self, framework_event: &str) -> EventType {
266        framework_event
267            .parse()
268            .unwrap_or_else(|_| EventType::Custom(framework_event.to_string()))
269    }
270
271    fn supported_events(&self) -> Vec<&'static str> {
272        vec![
273            "SessionStart",
274            "SessionEnd",
275            "PreToolUse",
276            "PostToolUse",
277            "PermissionRequest",
278            "PreCompact",
279            "Stop",
280            "SubagentStart",
281            "SubagentStop",
282            "Notification",
283            "UserPromptSubmit",
284        ]
285    }
286
287    fn detection_env_vars(&self) -> &[&'static str] {
288        &["CLAUDE_SESSION_ID", "CLAUDE_PROJECT_DIR"]
289    }
290
291    fn is_installed(&self) -> bool {
292        common::is_framework_installed(dirs::home_dir().map(|h| h.join(".claude")), "claude")
293    }
294
295    fn otel_support(&self) -> super::OtelSupport {
296        use super::OtelSupport;
297        // Claude supports OTEL via env vars in settings.json
298        let Some(home) = dirs::home_dir() else {
299            return OtelSupport::Disabled;
300        };
301        let settings_path = home.join(".claude/settings.json");
302        let Ok(contents) = std::fs::read_to_string(&settings_path) else {
303            return OtelSupport::Disabled;
304        };
305        let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
306            return OtelSupport::Disabled;
307        };
308
309        // Check for OTEL env vars in the "env" section
310        if let Some(env) = json.get("env")
311            && (env.get("OTEL_EXPORTER_OTLP_ENDPOINT").is_some()
312                || env.get("OTEL_LOGS_EXPORTER").is_some())
313        {
314            return OtelSupport::Enabled;
315        }
316        OtelSupport::Disabled
317    }
318
319    fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
320        None
321    }
322
323    // ========================================================================
324    // Marketplace-based installation
325    // ========================================================================
326
327    fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
328        // Return the actual plugin directory path for display purposes
329        Self::marketplace_cache_path()
330    }
331
332    fn has_mi6_hooks(&self, _local: bool, _settings_local: bool) -> bool {
333        Self::is_plugin_installed()
334    }
335
336    fn install_hooks(
337        &self,
338        _path: &std::path::Path,
339        _hooks: &serde_json::Value,
340        _otel_env: Option<serde_json::Value>,
341        _remove_otel: bool,
342    ) -> Result<InstallHooksResult, InitError> {
343        let mut commands_run = Vec::new();
344
345        // Write embedded marketplace files to local cache
346        let cache_path = Self::write_marketplace_to_cache()?;
347        let cache_path_str = cache_path.to_string_lossy();
348
349        // Add the marketplace (idempotent - ignore if already registered)
350        let cmd = Self::run_claude_command_opt(
351            &["plugin", "marketplace", "add", &cache_path_str],
352            None,
353            &["already installed", "already added", "already registered"],
354        )?;
355        commands_run.push(cmd);
356
357        // Install the plugin to user scope (idempotent - ignore if already installed)
358        let cmd = Self::run_claude_command_opt(
359            &["plugin", "install", PLUGIN_ID, "--scope", "user"],
360            None,
361            &["already installed", "already enabled"],
362        )?;
363        commands_run.push(cmd);
364
365        Ok(InstallHooksResult { commands_run })
366    }
367
368    fn uninstall_hooks(
369        &self,
370        _local: bool,
371        _settings_local: bool,
372    ) -> Result<UninstallHooksResult, InitError> {
373        if !Self::is_plugin_installed() {
374            return Ok(UninstallHooksResult {
375                hooks_removed: false,
376                commands_run: vec![],
377            });
378        }
379
380        let mut commands_run = Vec::new();
381
382        // Determine the installation scope to use the correct uninstall command
383        match Self::get_plugin_install_scope() {
384            Some((scope, Some(project_path))) if scope == "project" => {
385                // Project-scope: must run from the project directory
386                let cmd = Self::run_claude_command_opt(
387                    &["plugin", "uninstall", PLUGIN_ID, "--scope", "project"],
388                    Some(&project_path),
389                    &[],
390                )?;
391                commands_run.push(cmd);
392            }
393            _ => {
394                // User scope (default) or unknown - try user scope
395                let cmd = Self::run_claude_command(&[
396                    "plugin",
397                    "uninstall",
398                    PLUGIN_ID,
399                    "--scope",
400                    "user",
401                ])?;
402                commands_run.push(cmd);
403            }
404        }
405
406        Ok(UninstallHooksResult {
407            hooks_removed: true,
408            commands_run,
409        })
410    }
411
412    fn resume_command(&self, session_id: &str) -> Option<String> {
413        Some(format!("claude --resume {}", session_id))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_name() {
423        let adapter = ClaudeAdapter;
424        assert_eq!(adapter.name(), "claude");
425        assert_eq!(adapter.display_name(), "Claude Code");
426    }
427
428    #[test]
429    fn test_map_event_type() {
430        let adapter = ClaudeAdapter;
431        assert_eq!(
432            adapter.map_event_type("SessionStart"),
433            EventType::SessionStart
434        );
435        assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
436        assert_eq!(
437            adapter.map_event_type("CustomEvent"),
438            EventType::Custom("CustomEvent".to_string())
439        );
440    }
441
442    #[test]
443    fn test_parse_hook_input() {
444        let adapter = ClaudeAdapter;
445        let input = serde_json::json!({
446            "session_id": "test-session",
447            "tool_use_id": "tool-123",
448            "tool_name": "Bash",
449            "cwd": "/projects/test",
450            "permission_mode": "default",
451            "tool_input": {
452                "subagent_type": "Explore"
453            },
454            "tool_response": {
455                "agentId": "agent-456"
456            }
457        });
458
459        let parsed = adapter.parse_hook_input("PreToolUse", &input);
460
461        assert_eq!(parsed.session_id, Some("test-session".to_string()));
462        assert_eq!(parsed.tool_use_id, Some("tool-123".to_string()));
463        assert_eq!(parsed.tool_name, Some("Bash".to_string()));
464        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
465        assert_eq!(parsed.permission_mode, Some("default".to_string()));
466        assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
467        assert_eq!(parsed.spawned_agent_id, Some("agent-456".to_string()));
468    }
469
470    #[test]
471    fn test_parse_hook_input_new_fields() {
472        let adapter = ClaudeAdapter;
473
474        // Test SessionStart with source field
475        let session_start_input = serde_json::json!({
476            "session_id": "test-session",
477            "source": "startup",
478            "cwd": "/projects/test"
479        });
480        let parsed = adapter.parse_hook_input("SessionStart", &session_start_input);
481        assert_eq!(parsed.session_source, Some("startup".to_string()));
482
483        // Test SubagentStop with agent_id and agent_transcript_path
484        let subagent_stop_input = serde_json::json!({
485            "session_id": "parent-session",
486            "agent_id": "subagent-123",
487            "agent_transcript_path": "/tmp/transcripts/subagent.jsonl"
488        });
489        let parsed = adapter.parse_hook_input("SubagentStop", &subagent_stop_input);
490        assert_eq!(parsed.agent_id, Some("subagent-123".to_string()));
491        assert_eq!(
492            parsed.agent_transcript_path,
493            Some("/tmp/transcripts/subagent.jsonl".to_string())
494        );
495
496        // Test PreCompact with trigger field
497        let pre_compact_input = serde_json::json!({
498            "session_id": "test-session",
499            "trigger": "auto"
500        });
501        let parsed = adapter.parse_hook_input("PreCompact", &pre_compact_input);
502        assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
503    }
504
505    #[test]
506    fn test_generate_hooks_config() -> Result<(), String> {
507        let adapter = ClaudeAdapter;
508        let events = vec![EventType::SessionStart, EventType::PreToolUse];
509
510        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
511
512        let hooks = config
513            .get("hooks")
514            .ok_or("missing hooks")?
515            .as_object()
516            .ok_or("hooks not an object")?;
517        assert!(hooks.contains_key("SessionStart"));
518        assert!(hooks.contains_key("PreToolUse"));
519
520        let session_start = &hooks["SessionStart"][0]["hooks"][0];
521        let command = session_start["command"].as_str().ok_or("missing command")?;
522        assert!(command.contains("mi6 ingest event SessionStart"));
523        assert!(command.contains("--framework claude"));
524        Ok(())
525    }
526
527    #[test]
528    fn test_generate_hooks_config_with_otel() -> Result<(), String> {
529        let adapter = ClaudeAdapter;
530        let events = vec![EventType::SessionStart];
531
532        let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
533
534        let hooks = config
535            .get("hooks")
536            .ok_or("missing hooks")?
537            .as_object()
538            .ok_or("hooks not an object")?;
539        let session_start = &hooks["SessionStart"][0]["hooks"][0];
540        let command = session_start["command"].as_str().ok_or("missing command")?;
541
542        assert!(command.contains("otel start"));
543        assert!(command.contains("--port 4318"));
544        assert!(command.contains("--framework claude"));
545        Ok(())
546    }
547
548    #[test]
549    fn test_generate_hooks_config_matcher_structure() -> Result<(), String> {
550        let adapter = ClaudeAdapter;
551        let events = vec![
552            EventType::SessionStart,
553            EventType::PreToolUse,
554            EventType::PostToolUse,
555        ];
556
557        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
558        let hooks = config.get("hooks").ok_or("missing hooks")?;
559
560        // All events should have matcher field with "*" for wildcard
561        for event in &events {
562            let hook = &hooks[event.to_string()][0];
563            assert_eq!(
564                hook.get("matcher").and_then(|m| m.as_str()),
565                Some("*"),
566                "{} should have matcher: \"*\"",
567                event
568            );
569        }
570
571        Ok(())
572    }
573
574    #[test]
575    fn test_plugin_constants() {
576        assert_eq!(PLUGIN_ID, "mi6@mi6");
577    }
578
579    #[test]
580    fn test_embedded_marketplace_files() {
581        // Verify embedded files are valid JSON
582        let _: serde_json::Value =
583            serde_json::from_str(MARKETPLACE_JSON).expect("marketplace.json should be valid JSON");
584        let _: serde_json::Value =
585            serde_json::from_str(PLUGIN_JSON).expect("plugin.json should be valid JSON");
586        let _: serde_json::Value =
587            serde_json::from_str(HOOKS_JSON).expect("hooks.json should be valid JSON");
588    }
589}