mi6_cli/commands/
log.rs

1use std::collections::HashMap;
2use std::io::Read;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use mi6_core::{
7    Config, EventBuilder, EventType, FrameworkAdapter, ParsedHookInput, Storage, default_adapter,
8    detect_all_frameworks, detect_framework, get_adapter, get_branch_info,
9    is_branch_changing_command,
10};
11
12use crate::process::{ClaudeProcessInfo, find_claude_process, get_parent_pid};
13
14/// Result of running the log command.
15pub struct LogResult {
16    /// Transcript path to scan, if available
17    pub transcript_path: Option<String>,
18    /// Machine ID for transcript parsing
19    pub machine_id: String,
20    /// Session ID for potential backfill operations
21    pub session_id: String,
22}
23
24/// Run the log command. This must be fast (<10ms) and never block the calling framework.
25///
26/// Returns information needed for optional transcript scanning.
27pub fn run_log<S: Storage>(
28    storage: &S,
29    event_type_arg: Option<String>,
30    json_payload: Option<String>,
31    framework_name: Option<String>,
32) -> Result<LogResult> {
33    // Determine the framework adapter:
34    // 1. Use explicit --framework flag if provided
35    // 2. Otherwise, try to auto-detect from environment variables
36    // 3. Fall back to default (claude)
37    let adapter: &dyn FrameworkAdapter = if let Some(ref name) = framework_name {
38        get_adapter(name).ok_or_else(|| anyhow::anyhow!("unknown framework: {}", name))?
39    } else {
40        // Warn if multiple frameworks detected (to stderr, doesn't affect hook)
41        let detected = detect_all_frameworks();
42        if detected.len() > 1 {
43            let names: Vec<_> = detected.iter().map(|a| a.name()).collect();
44            eprintln!(
45                "mi6: warning: multiple frameworks detected ({}), using {}",
46                names.join(", "),
47                detected[0].name()
48            );
49        }
50        detect_framework().unwrap_or_else(default_adapter)
51    };
52
53    // Handle argument parsing:
54    // - Claude/Gemini: `mi6 ingest event SessionStart` with JSON on stdin
55    // - Codex: `mi6 ingest event --framework codex '{"type":"agent-turn-complete",...}'`
56    //
57    // Codex passes JSON as the only positional arg, which clap captures as event_type_arg.
58    // We detect this by checking if event_type_arg looks like JSON (starts with '{').
59    let (actual_event_type, json_str) = if let Some(ref arg) = event_type_arg {
60        if arg.trim().starts_with('{') {
61            // First positional arg is JSON (Codex format) - extract event type from it
62            (None, arg.clone())
63        } else {
64            // First positional arg is event type (Claude/Gemini format)
65            let json = if let Some(payload) = json_payload {
66                payload
67            } else {
68                let mut stdin_data = String::new();
69                std::io::stdin()
70                    .read_to_string(&mut stdin_data)
71                    .context("failed to read stdin")?;
72                stdin_data
73            };
74            (Some(arg.clone()), json)
75        }
76    } else {
77        // No positional args - read JSON from stdin
78        let mut stdin_data = String::new();
79        std::io::stdin()
80            .read_to_string(&mut stdin_data)
81            .context("failed to read stdin")?;
82        (None, stdin_data)
83    };
84
85    // Parse the hook JSON
86    let hook_data: serde_json::Value = if json_str.trim().is_empty() {
87        serde_json::json!({})
88    } else {
89        serde_json::from_str(&json_str).context("failed to parse hook JSON")?
90    };
91
92    // Determine event type:
93    // 1. Use explicit CLI argument if provided
94    // 2. Otherwise extract from JSON payload's "type" field (Codex CLI format)
95    // 3. Fall back to "Unknown" if neither available
96    let event_type_str = if let Some(ref et) = actual_event_type {
97        et.clone()
98    } else {
99        // Extract from JSON "type" field (Codex CLI format)
100        hook_data
101            .get("type")
102            .and_then(|v| v.as_str())
103            .map_or_else(|| "Unknown".to_string(), String::from)
104    };
105
106    // Map the event type using the adapter (handles framework-specific event names)
107    let event_type: EventType = adapter.map_event_type(&event_type_str);
108
109    // Use adapter to parse hook input into normalized fields
110    let parsed = adapter.parse_hook_input(&event_type_str, &hook_data);
111
112    // Extract session_id (required field, default to "unknown")
113    let session_id = parsed
114        .session_id
115        .clone()
116        .unwrap_or_else(|| "unknown".to_string());
117
118    // For SessionStart, find calling process once and reuse (avoids duplicate ps calls)
119    let process_info = if event_type == EventType::SessionStart {
120        find_claude_process()
121    } else {
122        None
123    };
124
125    // Capture PID - use cached process info for SessionStart, parent PID otherwise
126    let pid = process_info
127        .as_ref()
128        .map(|info| info.pid)
129        .or_else(get_parent_pid);
130
131    // Build payload: merge hook JSON with environment variables and process info
132    let payload = build_payload(hook_data, &event_type, process_info.as_ref())?;
133
134    // Load config and get machine_id
135    let config = Config::load().unwrap_or_default();
136    let machine_id = config.machine_id();
137
138    // Create and insert event
139    let event = EventBuilder::new(&machine_id, event_type.clone(), session_id.clone())
140        .framework(adapter.name())
141        .tool_use_id_opt(parsed.tool_use_id.clone())
142        .spawned_agent_id_opt(parsed.spawned_agent_id.clone())
143        .tool_name_opt(parsed.tool_name.clone())
144        .subagent_type_opt(parsed.subagent_type.clone())
145        .permission_mode_opt(parsed.permission_mode.clone())
146        .transcript_path_opt(parsed.transcript_path.clone())
147        .pid_opt(pid)
148        .cwd_opt(parsed.cwd.clone())
149        .payload(payload)
150        .source("hook")
151        .build();
152
153    storage.insert(&event).context("failed to insert event")?;
154
155    // Capture git branch info for relevant event types
156    // This is done after insert to ensure the session exists
157    capture_git_info_if_needed(storage, &event_type, &session_id, &parsed);
158
159    // Opportunistic GC (~1.2% of calls: 3/256)
160    if rand::random::<u8>() < 3 {
161        let _ = storage.gc(config.retention_duration());
162    }
163
164    // Return info for optional transcript scanning
165    Ok(LogResult {
166        transcript_path: parsed.transcript_path.clone(),
167        machine_id,
168        session_id,
169    })
170}
171
172/// Build the payload JSON by merging hook data with environment variables
173fn build_payload(
174    mut hook_data: serde_json::Value,
175    event_type: &EventType,
176    process_info: Option<&ClaudeProcessInfo>,
177) -> Result<String> {
178    // Environment variables to capture
179    let env_vars = [
180        ("CLAUDE_PROJECT_DIR", "project_dir"),
181        ("CLAUDE_FILE_PATHS", "file_paths"),
182        ("CLAUDE_TOOL_INPUT", "tool_input_env"),
183        ("CLAUDE_TOOL_OUTPUT", "tool_output_env"),
184        ("CLAUDE_NOTIFICATION", "notification_env"),
185        ("CLAUDE_CODE_REMOTE", "remote"),
186        ("CLAUDE_ENV_FILE", "env_file"),
187    ];
188
189    // Ensure we have an object to work with
190    if !hook_data.is_object() {
191        hook_data = serde_json::json!({ "_raw": hook_data });
192    }
193
194    let Some(obj) = hook_data.as_object_mut() else {
195        // We just ensured it's an object above, so this is unreachable
196        return Ok(serde_json::to_string(&hook_data)?);
197    };
198
199    // Add environment variables to the payload
200    let mut env_data: HashMap<String, String> = HashMap::new();
201    for (env_var, key) in env_vars {
202        if let Ok(value) = std::env::var(env_var) {
203            env_data.insert(key.to_string(), value);
204        }
205    }
206
207    if !env_data.is_empty() {
208        obj.insert("_env".to_string(), serde_json::to_value(env_data)?);
209    }
210
211    // For SessionStart, add process info to payload (using cached info)
212    if *event_type == EventType::SessionStart
213        && let Some(info) = process_info
214    {
215        obj.insert(
216            "_claude_process".to_string(),
217            serde_json::json!({
218                "pid": info.pid,
219                "comm": info.comm
220            }),
221        );
222    }
223
224    Ok(serde_json::to_string(&hook_data)?)
225}
226
227/// Capture git branch info for relevant events.
228///
229/// This function handles:
230/// - SessionStart: Captures initial git branch from session's cwd
231/// - PostToolUse (Bash): Detects git branch-changing commands and updates branch info
232///
233/// The function is designed to be fast and never fail - errors are silently ignored
234/// since git info is supplementary data and should not block event processing.
235fn capture_git_info_if_needed<S: Storage>(
236    storage: &S,
237    event_type: &EventType,
238    session_id: &str,
239    parsed: &ParsedHookInput,
240) {
241    match event_type {
242        EventType::SessionStart => {
243            // Capture initial git branch from session's cwd
244            if let Some(ref cwd) = parsed.cwd
245                && let Some(git_info) = get_branch_info(Path::new(cwd))
246            {
247                let _ = storage.update_session_git_info(session_id, &git_info);
248            }
249        }
250        EventType::PostToolUse => {
251            // Check if this is a Bash tool call with a git branch-changing command
252            if parsed.tool_name.as_deref() == Some("Bash") {
253                // Get command from CLAUDE_TOOL_INPUT environment variable
254                if let Ok(tool_input) = std::env::var("CLAUDE_TOOL_INPUT")
255                    && let Some(cmd) = extract_bash_command(&tool_input)
256                    && is_branch_changing_command(&cmd)
257                    && let Some(ref cwd) = parsed.cwd
258                    && let Some(git_info) = get_branch_info(Path::new(cwd))
259                {
260                    let _ = storage.update_session_git_info(session_id, &git_info);
261                }
262            }
263        }
264        _ => {}
265    }
266}
267
268/// Extract the bash command from tool_input.
269///
270/// The tool_input can be either:
271/// - Raw command string
272/// - JSON object with a "command" field
273fn extract_bash_command(tool_input: &str) -> Option<String> {
274    // First try to parse as JSON
275    if let Ok(json) = serde_json::from_str::<serde_json::Value>(tool_input)
276        && let Some(cmd) = json.get("command").and_then(|v| v.as_str())
277    {
278        return Some(cmd.to_string());
279    }
280    // Otherwise, assume it's a raw command
281    Some(tool_input.to_string())
282}