1use 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
27const PLUGIN_ID: &str = "mi6@mi6";
29
30const MARKETPLACE_JSON: &str = include_str!("claude-plugin/marketplace.json");
32
33const PLUGIN_JSON: &str = include_str!("claude-plugin/plugin.json");
35
36const HOOKS_JSON: &str = include_str!("claude-plugin/hooks.json");
38
39pub struct ClaudeAdapter;
41
42impl ClaudeAdapter {
43 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 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 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 fn run_claude_command(args: &[&str]) -> Result<String, InitError> {
91 Self::run_claude_command_opt(args, None, &[])
92 }
93
94 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 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 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 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 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 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 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 PathBuf::from(".claude/plugins/mi6/hooks/hooks.json")
194 }
195
196 fn user_config_path(&self) -> Option<PathBuf> {
197 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 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 }
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 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 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 fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
328 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 let cache_path = Self::write_marketplace_to_cache()?;
347 let cache_path_str = cache_path.to_string_lossy();
348
349 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 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 match Self::get_plugin_install_scope() {
384 Some((scope, Some(project_path))) if scope == "project" => {
385 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 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 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 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 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 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 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}