mi6_cli/
help.rs

1use clap::CommandFactory;
2use std::io::{IsTerminal, Write};
3
4/// Get ANSI escape codes, respecting TTY and NO_COLOR.
5fn ansi_codes() -> (&'static str, &'static str, &'static str, &'static str) {
6    // Disable colors if not a TTY or NO_COLOR is set
7    if !std::io::stdout().is_terminal() || std::env::var_os("NO_COLOR").is_some() {
8        return ("", "", "", "");
9    }
10    ("\x1b[0m", "\x1b[1m", "\x1b[32m", "\x1b[37m")
11}
12
13/// Capitalize the first letter of a string.
14fn capitalize(s: &str) -> String {
15    let mut chars = s.chars();
16    match chars.next() {
17        None => String::new(),
18        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
19    }
20}
21
22/// Print styled help for a command path (e.g., `["ingest"]` or `["ingest", "transcript"]`).
23pub fn print_command_help(path: &[&str]) -> bool {
24    let app = crate::Cli::command();
25
26    // Navigate to the command at the given path
27    let mut current_cmd = &app;
28    for name in path {
29        let Some(cmd) = current_cmd
30            .get_subcommands()
31            .find(|c| c.get_name() == *name)
32        else {
33            return false;
34        };
35        current_cmd = cmd;
36    }
37
38    let mut stdout = std::io::stdout();
39    let (reset, bold, green, white) = ansi_codes();
40
41    // Build the full command string (e.g., "mi6 ingest transcript")
42    let full_cmd = format!("mi6 {}", path.join(" "));
43
44    // Usage line
45    let _ = writeln!(
46        stdout,
47        "{bold}{green}usage:{reset} {bold}{white}{full_cmd} [options]{reset}"
48    );
49    let _ = writeln!(stdout);
50
51    // Description
52    if let Some(about) = current_cmd.get_about() {
53        let _ = writeln!(stdout, "{}", about);
54        let _ = writeln!(stdout);
55    }
56
57    // Check if this command has subcommands
58    let subcommands: Vec<_> = current_cmd
59        .get_subcommands()
60        .filter(|c| !c.is_hide_set())
61        .collect();
62    let has_subcommands = !subcommands.is_empty();
63
64    if has_subcommands {
65        // Use the last path component for the header (e.g., "Ingest Commands")
66        let cmd_name = capitalize(path.last().unwrap_or(&""));
67        let _ = writeln!(stdout, "{bold}{green}{cmd_name} Commands{reset}");
68        for sub in &subcommands {
69            let name = sub.get_name();
70            let desc = sub.get_about().map(|s| s.to_string()).unwrap_or_default();
71            let _ = writeln!(stdout, "    {bold}{white}{name:<20}{reset} {desc}");
72        }
73        let _ = writeln!(stdout);
74    }
75
76    // Arguments (positional)
77    let positionals: Vec<_> = current_cmd.get_positionals().collect();
78    let has_positionals = !positionals.is_empty();
79    if has_positionals {
80        // Build argument strings and calculate max width for alignment
81        let arg_entries: Vec<(String, String)> = positionals
82            .iter()
83            .map(|arg| {
84                let name = arg.get_id().as_str();
85                let arg_str = format!("<{name}>");
86                let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
87                (arg_str, help)
88            })
89            .collect();
90
91        let max_width = arg_entries.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
92
93        let _ = writeln!(stdout, "{bold}{green}Arguments{reset}");
94        for (arg_str, help) in arg_entries {
95            let _ = writeln!(
96                stdout,
97                "    {bold}{white}{arg_str:<max_width$}{reset}  {help}"
98            );
99        }
100    }
101
102    // Options
103    let options: Vec<_> = current_cmd
104        .get_opts()
105        .filter(|a| !a.is_hide_set() && !a.is_positional())
106        .collect();
107    let has_options = !options.is_empty();
108
109    // Add separator after Arguments if more content follows
110    if has_positionals && (has_options || has_subcommands) {
111        let _ = writeln!(stdout);
112    }
113
114    if has_options {
115        // Build option strings and calculate max width for alignment
116        let option_entries: Vec<(String, String)> = options
117            .iter()
118            .map(|opt| {
119                let short = opt
120                    .get_short()
121                    .map(|c| format!("-{c}, "))
122                    .unwrap_or_default();
123                let long = opt
124                    .get_long()
125                    .map_or_else(|| opt.get_id().to_string(), |l| l.to_string());
126                let aliases: Vec<&str> = opt.get_visible_aliases().unwrap_or_default();
127                let long_with_aliases = if aliases.is_empty() {
128                    format!("--{long}")
129                } else {
130                    let alias_str = aliases
131                        .iter()
132                        .map(|a| format!("--{a}"))
133                        .collect::<Vec<_>>()
134                        .join(", ");
135                    format!("--{long}, {alias_str}")
136                };
137                let opt_str = format!("{short}{long_with_aliases}");
138                let help = opt.get_help().map(|s| s.to_string()).unwrap_or_default();
139                (opt_str, help)
140            })
141            .collect();
142
143        let max_width = option_entries
144            .iter()
145            .map(|(s, _)| s.len())
146            .max()
147            .unwrap_or(0);
148
149        let _ = writeln!(stdout, "{bold}{green}Options{reset}");
150        for (opt_str, help) in option_entries {
151            let _ = writeln!(
152                stdout,
153                "    {bold}{white}{opt_str:<max_width$}{reset}  {help}"
154            );
155        }
156        // Only add blank line if footer follows
157        if has_subcommands {
158            let _ = writeln!(stdout);
159        }
160    }
161
162    // Footer for commands with subcommands
163    if has_subcommands {
164        let _ = writeln!(
165            stdout,
166            "See arguments for each command with {bold}{white}{full_cmd} <command> -h{reset}"
167        );
168    }
169
170    true
171}
172
173/// Print styled help for a subcommand (single level).
174pub fn print_subcommand_help(subcommand: &str) -> bool {
175    print_command_help(&[subcommand])
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub enum CommandCategory {
180    Inputs,
181    Outputs,
182    Meta,
183}
184
185impl CommandCategory {
186    pub fn name(&self) -> &str {
187        match self {
188            CommandCategory::Inputs => "Input Commands",
189            CommandCategory::Outputs => "Output Commands",
190            CommandCategory::Meta => "Meta Commands",
191        }
192    }
193}
194
195/// Map command names to their categories.
196///
197/// NOTE: When adding a new command to the CLI, update this function to assign
198/// it to the appropriate category. Unknown commands default to Meta.
199fn get_command_category(name: &str) -> CommandCategory {
200    match name {
201        // Inputs - commands that ingest data
202        "ingest" => CommandCategory::Inputs,
203
204        // Outputs - commands that display/output data
205        "session" | "tui" | "watch" => CommandCategory::Outputs,
206
207        // Meta - commands for setup/management
208        "enable" | "disable" | "status" => CommandCategory::Meta,
209
210        // Default fallback for unknown commands
211        _ => CommandCategory::Meta,
212    }
213}
214
215/// Print custom help matching jtool's style.
216pub fn print_help() {
217    let mut stdout = std::io::stdout();
218    let (reset, bold, green, white) = ansi_codes();
219
220    let _ = writeln!(
221        stdout,
222        "{bold}{green}usage:{reset} {bold}{white}mi6 <command> [<args>]{reset}"
223    );
224    let _ = writeln!(stdout);
225    let _ = writeln!(
226        stdout,
227        "Tool for monitoring and managing agentic coding sessions"
228    );
229    let _ = writeln!(stdout);
230
231    // Dynamically get all commands from the Commands enum using clap
232    let app = crate::Cli::command();
233    let mut commands_by_category: std::collections::HashMap<
234        CommandCategory,
235        Vec<(String, String)>,
236    > = std::collections::HashMap::new();
237
238    for subcommand in app.get_subcommands() {
239        // Skip hidden commands
240        if subcommand.is_hide_set() {
241            continue;
242        }
243
244        // Skip the help command (clap adds it automatically)
245        let name = subcommand.get_name();
246        if name == "help" {
247            continue;
248        }
249
250        let name = name.to_string();
251        let description = subcommand
252            .get_about()
253            .map(|s| s.to_string())
254            .unwrap_or_default();
255        let category = get_command_category(&name);
256
257        commands_by_category
258            .entry(category)
259            .or_default()
260            .push((name, description));
261    }
262
263    // Display commands grouped by category in a specific order
264    let categories = [
265        CommandCategory::Inputs,
266        CommandCategory::Outputs,
267        CommandCategory::Meta,
268    ];
269
270    let mut first = true;
271    for category in &categories {
272        if let Some(mut commands) = commands_by_category.remove(category) {
273            // Sort commands alphabetically within each category
274            commands.sort_by(|a, b| a.0.cmp(&b.0));
275
276            // Print blank line between categories (not before first)
277            if !first {
278                let _ = writeln!(stdout);
279            }
280            first = false;
281
282            let _ = writeln!(stdout, "{bold}{green}{}{reset}", category.name());
283
284            for (name, description) in commands {
285                let _ = writeln!(stdout, "    {bold}{white}{name:<20}{reset} {description}");
286            }
287        }
288    }
289
290    let _ = writeln!(stdout);
291    let _ = writeln!(
292        stdout,
293        "See arguments for each command with {bold}{white}mi6 <command> -h{reset}"
294    );
295}