1use std::collections::HashMap;
2use std::io::Read;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7use mi6_core::{
8 Config, EventBuilder, EventType, FrameworkAdapter, FrameworkProcessInfo, ParsedHookInput,
9 Storage, default_adapter, detect_all_frameworks, detect_framework, find_framework_process,
10 get_adapter, get_branch_info, get_github_repo, get_local_git_dir, is_branch_changing_command,
11};
12
13pub struct LogResult {
15 pub transcript_path: Option<String>,
17 pub machine_id: String,
19 pub session_id: String,
21}
22
23pub fn run_log<S: Storage>(
27 storage: &S,
28 event_type_arg: Option<String>,
29 json_payload: Option<String>,
30 framework_name: Option<String>,
31) -> Result<LogResult> {
32 let adapter: &dyn FrameworkAdapter = if let Some(ref name) = framework_name {
37 get_adapter(name).ok_or_else(|| anyhow::anyhow!("unknown framework: {}", name))?
38 } else {
39 let detected = detect_all_frameworks();
41 if detected.len() > 1 {
42 let names: Vec<_> = detected.iter().map(|a| a.name()).collect();
43 eprintln!(
44 "mi6: warning: multiple frameworks detected ({}), using {}",
45 names.join(", "),
46 detected[0].name()
47 );
48 }
49 detect_framework().unwrap_or_else(default_adapter)
50 };
51
52 let (actual_event_type, json_str) = if let Some(ref arg) = event_type_arg {
59 if arg.trim().starts_with('{') {
60 (None, arg.clone())
62 } else {
63 let json = if let Some(payload) = json_payload {
65 payload
66 } else {
67 let mut stdin_data = String::new();
68 std::io::stdin()
69 .read_to_string(&mut stdin_data)
70 .context("failed to read stdin")?;
71 stdin_data
72 };
73 (Some(arg.clone()), json)
74 }
75 } else {
76 let mut stdin_data = String::new();
78 std::io::stdin()
79 .read_to_string(&mut stdin_data)
80 .context("failed to read stdin")?;
81 (None, stdin_data)
82 };
83
84 let hook_data: serde_json::Value = if json_str.trim().is_empty() {
86 serde_json::json!({})
87 } else {
88 serde_json::from_str(&json_str).context("failed to parse hook JSON")?
89 };
90
91 let event_type_str = if let Some(ref et) = actual_event_type {
96 et.clone()
97 } else {
98 hook_data
100 .get("type")
101 .and_then(|v| v.as_str())
102 .map_or_else(|| "Unknown".to_string(), String::from)
103 };
104
105 let event_type: EventType = adapter.map_event_type(&event_type_str);
107
108 let parsed = adapter.parse_hook_input(&event_type_str, &hook_data);
110
111 let session_id = parsed
113 .session_id
114 .clone()
115 .unwrap_or_else(|| "unknown".to_string());
116
117 let process_info = find_framework_process();
128 let pid = process_info.as_ref().map(|info| info.pid);
129
130 let process_start_time = pid.and_then(prock::get_start_time);
135
136 let payload = build_payload(hook_data, &event_type, process_info.as_ref())?;
138
139 let config = Config::load().unwrap_or_default();
141 let machine_id = config.machine_id();
142
143 let now = Utc::now();
145 let timestamp_ms = now.timestamp_millis();
146
147 upsert_git_context_if_available(
152 storage,
153 &session_id,
154 &machine_id,
155 adapter.name(),
156 timestamp_ms,
157 &parsed,
158 );
159
160 let mut builder = EventBuilder::new(&machine_id, event_type.clone(), session_id.clone())
162 .framework(adapter.name())
163 .timestamp(now)
164 .tool_use_id_opt(parsed.tool_use_id.clone())
165 .spawned_agent_id_opt(parsed.spawned_agent_id.clone())
166 .tool_name_opt(parsed.tool_name.clone())
167 .subagent_type_opt(parsed.subagent_type.clone())
168 .permission_mode_opt(parsed.permission_mode.clone())
169 .transcript_path_opt(parsed.transcript_path.clone())
170 .model_opt(parsed.model.clone())
171 .duration_ms_opt(parsed.duration_ms)
172 .pid_opt(pid)
173 .process_start_time_opt(process_start_time)
174 .cwd_opt(parsed.cwd.clone())
175 .payload(payload)
176 .source("hook");
177
178 if let (Some(input), Some(output)) = (parsed.tokens_input, parsed.tokens_output) {
180 builder = builder.tokens(input, output);
181 }
182 if let (Some(read), Some(write)) = (parsed.tokens_cache_read, parsed.tokens_cache_write) {
183 builder = builder.cache_tokens(read, write);
184 }
185 if let Some(cost) = parsed.cost_usd {
186 builder = builder.cost(cost);
187 }
188
189 let event = builder.build();
190
191 storage.insert(&event).context("failed to insert event")?;
192
193 capture_git_branch_if_needed(storage, &event_type, &session_id, &parsed);
195
196 if rand::random::<u8>() < 3 {
198 let _ = storage.gc(config.history_duration());
199 }
200
201 if let Some(response) = adapter.hook_response(&event_type_str) {
204 println!("{}", response);
205 }
206
207 Ok(LogResult {
209 transcript_path: parsed.transcript_path.clone(),
210 machine_id,
211 session_id,
212 })
213}
214
215fn build_payload(
217 mut hook_data: serde_json::Value,
218 event_type: &EventType,
219 process_info: Option<&FrameworkProcessInfo>,
220) -> Result<String> {
221 let env_vars = [
223 ("CLAUDE_PROJECT_DIR", "project_dir"),
224 ("CLAUDE_FILE_PATHS", "file_paths"),
225 ("CLAUDE_TOOL_INPUT", "tool_input_env"),
226 ("CLAUDE_TOOL_OUTPUT", "tool_output_env"),
227 ("CLAUDE_NOTIFICATION", "notification_env"),
228 ("CLAUDE_CODE_REMOTE", "remote"),
229 ("CLAUDE_ENV_FILE", "env_file"),
230 ];
231
232 if !hook_data.is_object() {
234 hook_data = serde_json::json!({ "_raw": hook_data });
235 }
236
237 let Some(obj) = hook_data.as_object_mut() else {
238 return Ok(serde_json::to_string(&hook_data)?);
240 };
241
242 let mut env_data: HashMap<String, String> = HashMap::new();
244 for (env_var, key) in env_vars {
245 if let Ok(value) = std::env::var(env_var) {
246 env_data.insert(key.to_string(), value);
247 }
248 }
249
250 if !env_data.is_empty() {
251 obj.insert("_env".to_string(), serde_json::to_value(env_data)?);
252 }
253
254 if *event_type == EventType::SessionStart
256 && let Some(info) = process_info
257 {
258 obj.insert(
259 "_claude_process".to_string(),
260 serde_json::json!({
261 "pid": info.pid,
262 "comm": info.comm
263 }),
264 );
265 }
266
267 Ok(serde_json::to_string(&hook_data)?)
268}
269
270fn upsert_git_context_if_available<S: Storage>(
279 storage: &S,
280 session_id: &str,
281 machine_id: &str,
282 framework: &str,
283 timestamp_ms: i64,
284 parsed: &ParsedHookInput,
285) {
286 if let Some(ref cwd) = parsed.cwd {
287 let cwd_path = Path::new(cwd);
288
289 let local_git_dir = get_local_git_dir(cwd_path);
291 let github_repo = get_github_repo(cwd_path);
292
293 if local_git_dir.is_some() || github_repo.is_some() {
294 let _ = storage.upsert_session_git_context(
295 session_id,
296 machine_id,
297 framework,
298 timestamp_ms,
299 local_git_dir.as_deref(),
300 github_repo.as_deref(),
301 );
302 }
303 }
304}
305
306fn capture_git_branch_if_needed<S: Storage>(
317 storage: &S,
318 event_type: &EventType,
319 session_id: &str,
320 parsed: &ParsedHookInput,
321) {
322 match event_type {
323 EventType::SessionStart => {
324 if let Some(ref cwd) = parsed.cwd {
325 let cwd_path = Path::new(cwd);
326
327 if let Some(git_info) = get_branch_info(cwd_path) {
329 let _ = storage.update_session_git_info(session_id, &git_info);
330 }
331 }
332 }
333 EventType::PostToolUse => {
334 if parsed.tool_name.as_deref() == Some("Bash") {
336 if let Ok(tool_input) = std::env::var("CLAUDE_TOOL_INPUT")
338 && let Some(cmd) = extract_bash_command(&tool_input)
339 && is_branch_changing_command(&cmd)
340 && let Some(ref cwd) = parsed.cwd
341 && let Some(git_info) = get_branch_info(Path::new(cwd))
342 {
343 let _ = storage.update_session_git_info(session_id, &git_info);
344 }
345 }
346 }
347 _ => {}
348 }
349}
350
351fn extract_bash_command(tool_input: &str) -> Option<String> {
357 if let Ok(json) = serde_json::from_str::<serde_json::Value>(tool_input)
359 && let Some(cmd) = json.get("command").and_then(|v| v.as_str())
360 {
361 return Some(cmd.to_string());
362 }
363 Some(tool_input.to_string())
365}