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