mi6_cli/
lib.rs

1// CLI crate needs stdout/stderr for user interaction
2#![allow(clippy::print_stdout, clippy::print_stderr)]
3
4mod args;
5pub mod commands;
6pub mod display;
7mod error;
8mod help;
9pub mod process;
10
11pub use help::{print_command_help, print_help, print_subcommand_help};
12
13use anyhow::{Context, Result};
14use mi6_core::{CodexSessionScanner, Storage, StorageError, TranscriptScanner};
15
16pub use args::{Cli, Commands, IngestCommands, OtelCommands, TuiArgs, UpgradeMethod};
17pub use commands::{UpdateInfo, check_for_update};
18
19/// Result of running a CLI command.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RunResult {
22    /// Command completed successfully
23    Success,
24    /// Command requests the process to exit with the given code
25    ExitWithCode(i32),
26    /// Caller should launch the TUI with the provided arguments
27    RunTui(TuiArgs),
28}
29
30/// Run the CLI with a storage factory.
31///
32/// The factory is only called when a command needs storage.
33/// Returns `RunResult::RunTui` when the TUI should be launched (caller handles this).
34pub fn run<S, F>(cli: Cli, storage_factory: F) -> Result<RunResult>
35where
36    S: Storage,
37    F: FnOnce() -> Result<S, StorageError>,
38{
39    // Handle TUI mode: either no command or explicit `tui` subcommand
40    let command = match cli.command {
41        None => return Ok(RunResult::RunTui(cli.tui_args)),
42        Some(Commands::Tui(args)) => return Ok(RunResult::RunTui(args)),
43        Some(cmd) => cmd,
44    };
45
46    match command {
47        Commands::Tui(_) => unreachable!("handled above"),
48
49        Commands::Enable {
50            frameworks,
51            local,
52            settings_local,
53            print,
54            db_only,
55            hooks_only,
56            otel,
57            otel_port,
58            no_otel,
59        } => {
60            let result = commands::run_enable(commands::EnableCliOptions {
61                frameworks,
62                local,
63                settings_local,
64                print,
65                db_only,
66                hooks_only,
67                otel,
68                otel_port,
69                no_otel,
70            })?;
71
72            if result.should_init_db
73                && let Some(ref db_path) = result.db_path
74            {
75                let db_existed = db_path.exists();
76                let _ = storage_factory().context("failed to initialize storage")?;
77                if !db_existed {
78                    eprintln!("Database initialized at: {}", db_path.display());
79                }
80            }
81
82            if !print {
83                eprintln!("\nSessions may need to be restarted to use the new configuration.");
84            }
85
86            Ok(RunResult::Success)
87        }
88
89        Commands::Disable {
90            frameworks,
91            local,
92            settings_local,
93            print,
94        } => {
95            commands::run_disable(commands::DisableOptions {
96                frameworks,
97                local,
98                settings_local,
99                print,
100            })?;
101            Ok(RunResult::Success)
102        }
103
104        Commands::Ingest(ingest_cmd) => run_ingest(ingest_cmd, storage_factory),
105
106        Commands::Session {
107            session_or_pid,
108            machine,
109            json,
110            fields,
111        } => {
112            let storage = storage_factory().context("failed to open storage")?;
113            commands::run_session(&storage, session_or_pid, machine, json, fields)?;
114            Ok(RunResult::Success)
115        }
116
117        Commands::Status {
118            json,
119            verbose,
120            check,
121        } => {
122            commands::run_status_command(json, verbose, check)?;
123            Ok(RunResult::Success)
124        }
125
126        Commands::Watch {
127            session,
128            event_type,
129            mode,
130            framework,
131            poll_ms,
132        } => {
133            let storage = storage_factory().context("failed to open storage")?;
134            commands::run_watch(
135                &storage,
136                commands::WatchOptions {
137                    session,
138                    event_type,
139                    permission_mode: mode,
140                    framework,
141                    poll_ms,
142                },
143            )?;
144            Ok(RunResult::Success)
145        }
146
147        Commands::Cleanup { dry_run } => {
148            let storage = storage_factory().context("failed to open storage")?;
149            commands::run_cleanup(&storage, dry_run)?;
150            Ok(RunResult::Success)
151        }
152
153        Commands::Otel(otel_cmd) => run_otel(otel_cmd, storage_factory),
154
155        Commands::Upgrade {
156            version,
157            yes,
158            dry_run,
159            method,
160            source_path,
161        } => {
162            commands::run_upgrade(commands::UpgradeOptions {
163                version,
164                yes,
165                dry_run,
166                method,
167                source_path,
168            })?;
169            Ok(RunResult::Success)
170        }
171
172        Commands::Uninstall {
173            yes,
174            keep_data,
175            dry_run,
176        } => {
177            commands::run_uninstall(commands::UninstallOptions {
178                yes,
179                keep_data,
180                dry_run,
181            })?;
182            Ok(RunResult::Success)
183        }
184    }
185}
186
187/// Handle ingest subcommands.
188fn run_ingest<S, F>(cmd: IngestCommands, storage_factory: F) -> Result<RunResult>
189where
190    S: Storage,
191    F: FnOnce() -> Result<S, StorageError>,
192{
193    match cmd {
194        IngestCommands::Event {
195            event_type,
196            json_payload,
197            framework,
198            ping,
199        } => {
200            // Handle ping mode: just verify the command can be invoked, then exit
201            if ping {
202                println!("pong");
203                return Ok(RunResult::Success);
204            }
205
206            // The event ingest is called by AI assistant hooks during tool execution.
207            // It must always exit 0 to avoid blocking the workflow, even if logging fails.
208            // Errors are printed to stderr for debugging but don't affect the exit code.
209            let result: Result<(), anyhow::Error> = (|| {
210                let storage = storage_factory().context("failed to open storage")?;
211                let log_result =
212                    commands::run_log(&storage, event_type, json_payload, framework.clone())?;
213
214                // Scan transcript file after every hook (if path is available)
215                // This is cheap due to incremental position tracking
216                if let Some(ref path) = log_result.transcript_path {
217                    let scanner = TranscriptScanner::new(&storage, &log_result.machine_id);
218                    if let Err(e) = scanner.scan_file(std::path::Path::new(path)) {
219                        eprintln!("mi6: warning: failed to scan transcript: {e:#}");
220                    }
221
222                    // Backfill initial prompt if the UserPromptSubmit hook didn't fire
223                    // (e.g., when Claude is started with an inline prompt like `claude 'prompt'`)
224                    if let Err(e) = scanner
225                        .backfill_initial_prompt(&log_result.session_id, std::path::Path::new(path))
226                    {
227                        eprintln!("mi6: warning: failed to backfill initial prompt: {e:#}");
228                    }
229                }
230
231                // Scan Codex session file after every Codex hook event
232                // This fills in token counts, tool calls, and other data that hooks don't provide
233                if framework.as_deref() == Some("codex") {
234                    let scanner = CodexSessionScanner::new(&storage, &log_result.machine_id);
235                    if let Err(e) = scanner.scan_session(&log_result.session_id) {
236                        eprintln!("mi6: warning: failed to scan codex session: {e:#}");
237                    }
238                }
239
240                Ok(())
241            })();
242
243            if let Err(e) = result {
244                eprintln!("mi6: {:#}", e);
245            }
246            Ok(RunResult::ExitWithCode(0))
247        }
248
249        IngestCommands::Transcript { path } => {
250            let storage = storage_factory().context("failed to open storage")?;
251            commands::run_scan(&storage, &path)?;
252            Ok(RunResult::Success)
253        }
254
255        IngestCommands::CodexSession {
256            session_or_path,
257            file,
258        } => {
259            let storage = storage_factory().context("failed to open storage")?;
260            if file {
261                commands::run_codex_session_scan_file(
262                    &storage,
263                    std::path::Path::new(&session_or_path),
264                )?;
265            } else {
266                commands::run_codex_session_scan_by_id(&storage, &session_or_path)?;
267            }
268            Ok(RunResult::Success)
269        }
270
271        IngestCommands::Otel => {
272            let storage = storage_factory().context("failed to open storage")?;
273            commands::run_otel_ingest(&storage)?;
274            Ok(RunResult::Success)
275        }
276    }
277}
278
279/// Handle otel subcommands.
280fn run_otel<S, F>(cmd: OtelCommands, storage_factory: F) -> Result<RunResult>
281where
282    S: Storage,
283    F: FnOnce() -> Result<S, StorageError>,
284{
285    match cmd {
286        OtelCommands::Start { port, mode } => {
287            if commands::ensure_running(port, false, mode)? {
288                eprintln!("OTel server is running on port {}", port);
289            }
290            Ok(RunResult::Success)
291        }
292        OtelCommands::Stop { port } => {
293            commands::stop_server(port)?;
294            Ok(RunResult::Success)
295        }
296        OtelCommands::Restart { port, mode } => {
297            if commands::ensure_running(port, true, mode)? {
298                eprintln!("OTel server restarted on port {}", port);
299            }
300            Ok(RunResult::Success)
301        }
302        OtelCommands::Status { port } => {
303            commands::get_status(port)?;
304            Ok(RunResult::Success)
305        }
306        OtelCommands::Run { port, mode } => {
307            let storage = storage_factory().context("failed to open storage")?;
308            commands::run_server(&storage, port, mode)?;
309            Ok(RunResult::Success)
310        }
311    }
312}