1use super::{FrameworkAdapter, ParsedHookInput, common};
19use crate::model::EventType;
20use crate::model::error::InitError;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24const PLUGIN_ID: &str = "mi6@mi6";
26
27const MARKETPLACE_JSON: &str = include_str!("claude-plugin/marketplace.json");
29
30const PLUGIN_JSON: &str = include_str!("claude-plugin/plugin.json");
32
33const HOOKS_JSON: &str = include_str!("claude-plugin/hooks.json");
35
36pub struct ClaudeAdapter;
38
39impl ClaudeAdapter {
40 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 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 fn run_claude_command(args: &[&str]) -> Result<(), InitError> {
81 Self::run_claude_command_opt(args, None, &[])
82 }
83
84 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 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 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 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 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 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 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 PathBuf::from(".claude/plugins/mi6/hooks/hooks.json")
184 }
185
186 fn user_config_path(&self) -> Option<PathBuf> {
187 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 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 session_source: stdin_json
275 .get("source")
276 .and_then(|v| v.as_str())
277 .map(String::from),
278 agent_id: stdin_json
280 .get("agent_id")
281 .and_then(|v| v.as_str())
282 .map(String::from),
283 agent_transcript_path: stdin_json
285 .get("agent_transcript_path")
286 .and_then(|v| v.as_str())
287 .map(String::from),
288 compact_trigger: stdin_json
290 .get("trigger")
291 .and_then(|v| v.as_str())
292 .map(String::from),
293 model: None,
295 duration_ms: None,
296 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 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 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 fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
369 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 let cache_path = Self::write_marketplace_to_cache()?;
386 let cache_path_str = cache_path.to_string_lossy();
387
388 Self::run_claude_command_opt(
390 &["plugin", "marketplace", "add", &cache_path_str],
391 None,
392 &["already installed", "already added", "already registered"],
393 )?;
394
395 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 match Self::get_plugin_install_scope() {
412 Some((scope, Some(project_path))) if scope == "project" => {
413 Self::run_claude_command_opt(
415 &["plugin", "uninstall", PLUGIN_ID, "--scope", "project"],
416 Some(&project_path),
417 &[],
418 )?;
419 }
420 _ => {
421 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 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 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 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 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 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}