Skip to main content

bot_forge/cli/
mod.rs

1//! Authoritative CLI command metadata and generated raw artifacts.
2//!
3//! This module is also the application boundary for the `bot-forge` binary. [`main_entry`]
4//! composes configuration, planning, execution, state, reporting, and terminal presentation;
5//! lower-level modules do not expose command-specific entry points.
6
7mod actions;
8mod apt_mirror_actions;
9mod launcher;
10mod state_actions;
11mod typed;
12
13pub use crate::cli::actions::main_entry;
14
15pub use crate::model::{InstallOptions, Profile};
16
17/// Detailed metadata for a command help page.
18pub(crate) struct CommandHelp {
19    pub usage: String,
20    pub about: &'static str,
21    pub children: Vec<(String, String)>,
22    pub sections: &'static [HelpSection],
23}
24
25/// Semantic group of related command options.
26pub(crate) struct HelpSection {
27    pub title: &'static str,
28    pub rows: &'static [(&'static str, &'static str)],
29}
30
31const NO_CHILDREN: &[(&str, &str)] = &[];
32const NO_SECTIONS: &[HelpSection] = &[];
33const PLAN_ARGUMENTS: &[(&str, &str)] = &[(
34    "[PROFILE]",
35    "Installation profile; defaults to standard when omitted",
36)];
37const RESUME_ARGUMENTS: &[(&str, &str)] = &[(
38    "[PROFILE]",
39    "Original profile; uses the journal value when omitted (required for legacy journals)",
40)];
41const REMOVE_ARGUMENTS: &[(&str, &str)] = &[("<NAME>", "Managed tool or skill name to remove")];
42const STATUS_ARGUMENTS: &[(&str, &str)] = &[(
43    "[PROFILE]",
44    "Installation profile; defaults to standard when omitted",
45)];
46const CONFIG_OPTIONS: &[(&str, &str)] = &[
47    (
48        "--config <FILE>",
49        "Configuration file; defaults to bot-forge.toml",
50    ),
51    (
52        "--overlay <FILE>",
53        "Apply a configuration overlay file; repeat as needed",
54    ),
55];
56const INIT_EXECUTION: &[(&str, &str)] = &[("-f, --force", "Overwrite an existing configuration")];
57const INIT_OUTPUT: &[(&str, &str)] = &[(
58    "--output <FILE>",
59    "Write the generated configuration to FILE",
60)];
61const CACHE_STATUS_OPTIONS: &[(&str, &str)] =
62    &[("--format <FORMAT>", "Output format: human or json")];
63const APT_MIRROR_OPTIONS: &[(&str, &str)] = &[
64    (
65        "--config <FILE>",
66        "Configuration file; defaults to bot-forge.toml",
67    ),
68    (
69        "--overlay <FILE>",
70        "Apply a configuration overlay file; repeat as needed",
71    ),
72];
73
74const PLAN_CONFIG: &[(&str, &str)] = &[
75    (
76        "--config <FILE>",
77        "Configuration file; defaults to bot-forge.toml",
78    ),
79    (
80        "--overlay <FILE>",
81        "Apply a configuration overlay file; repeat as needed",
82    ),
83];
84const PLAN_SELECTION: &[(&str, &str)] = &[
85    (
86        "--only <NAME>",
87        "Include only a component; repeat as needed",
88    ),
89    ("--exclude <NAME>", "Exclude a component; repeat as needed"),
90];
91const PLAN_OUTPUT: &[(&str, &str)] = &[
92    ("--format <FORMAT>", "Output format: human or json"),
93    ("--why", "Explain plan decisions"),
94];
95const INSTALL_CONFIG: &[(&str, &str)] = PLAN_CONFIG;
96const INSTALL_SELECTION: &[(&str, &str)] = PLAN_SELECTION;
97const INSTALL_EXECUTION: &[(&str, &str)] = &[("-y, --yes", "Skip confirmation prompts")];
98const INSTALL_OUTPUT: &[(&str, &str)] = &[
99    ("--format <FORMAT>", "Output format: human, json, or jsonl"),
100    ("-q, --quiet", "Suppress human output"),
101];
102const RESUME_TRANSACTION: &[(&str, &str)] = &[
103    ("--run <ID>", "Resume the selected run"),
104    ("--abandon <ID>", "Abandon the selected run"),
105];
106const RESUME_CONFIGURATION: &[(&str, &str)] = &[
107    (
108        "--config <FILE>",
109        "Configuration file; defaults to bot-forge.toml",
110    ),
111    (
112        "--overlay <FILE>",
113        "Apply a configuration overlay file; repeat as needed",
114    ),
115];
116const RESUME_SELECTION: &[(&str, &str)] = &[
117    (
118        "--only <NAME>",
119        "Include only a component; repeat as needed",
120    ),
121    ("--exclude <NAME>", "Exclude a component; repeat as needed"),
122];
123const REMOVE_SELECTION: &[(&str, &str)] = &[("--kind <KIND>", "Limit removal to tool or skill")];
124const REMOVE_EXECUTION: &[(&str, &str)] = &[
125    ("--dry-run", "Preview changes without applying them"),
126    ("-y, --yes", "Skip confirmation prompts"),
127];
128const STATUS_CONFIGURATION: &[(&str, &str)] = &[
129    (
130        "--config <FILE>",
131        "Configuration file; defaults to bot-forge.toml",
132    ),
133    (
134        "--overlay <FILE>",
135        "Apply a configuration overlay file; repeat as needed",
136    ),
137];
138const STATUS_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
139const DOCTOR_CONFIGURATION: &[(&str, &str)] = &[(
140    "--config <FILE>",
141    "Configuration file; defaults to bot-forge.toml",
142)];
143const DOCTOR_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
144const EFFECTIVE_CONFIGURATION: &[(&str, &str)] = &[
145    (
146        "--config <FILE>",
147        "Configuration file; defaults to bot-forge.toml",
148    ),
149    (
150        "--overlay <FILE>",
151        "Apply a configuration overlay file; repeat as needed",
152    ),
153];
154const EFFECTIVE_OUTPUT: &[(&str, &str)] = &[
155    (
156        "-v, --verbose",
157        "Allow --show-sensitive to reveal sensitive values",
158    ),
159    (
160        "--show-sensitive",
161        "Include sensitive values; requires --verbose",
162    ),
163];
164const CACHE_GC_SELECTION: &[(&str, &str)] =
165    &[("--max-age-days <DAYS>", "Remove entries older than DAYS")];
166const CACHE_GC_EXECUTION: &[(&str, &str)] =
167    &[("--dry-run", "Preview changes without applying them")];
168const CACHE_GC_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
169
170const PLAN_SECTIONS: &[HelpSection] = &[
171    HelpSection {
172        title: "Arguments",
173        rows: PLAN_ARGUMENTS,
174    },
175    HelpSection {
176        title: "Configuration",
177        rows: PLAN_CONFIG,
178    },
179    HelpSection {
180        title: "Selection",
181        rows: PLAN_SELECTION,
182    },
183    HelpSection {
184        title: "Output",
185        rows: PLAN_OUTPUT,
186    },
187];
188const INSTALL_SECTIONS: &[HelpSection] = &[
189    HelpSection {
190        title: "Arguments",
191        rows: PLAN_ARGUMENTS,
192    },
193    HelpSection {
194        title: "Configuration",
195        rows: INSTALL_CONFIG,
196    },
197    HelpSection {
198        title: "Selection",
199        rows: INSTALL_SELECTION,
200    },
201    HelpSection {
202        title: "Execution",
203        rows: INSTALL_EXECUTION,
204    },
205    HelpSection {
206        title: "Output",
207        rows: INSTALL_OUTPUT,
208    },
209];
210const RESUME_SECTIONS: &[HelpSection] = &[
211    HelpSection {
212        title: "Arguments",
213        rows: RESUME_ARGUMENTS,
214    },
215    HelpSection {
216        title: "Transaction",
217        rows: RESUME_TRANSACTION,
218    },
219    HelpSection {
220        title: "Configuration",
221        rows: RESUME_CONFIGURATION,
222    },
223    HelpSection {
224        title: "Selection",
225        rows: RESUME_SELECTION,
226    },
227];
228const REMOVE_SECTIONS: &[HelpSection] = &[
229    HelpSection {
230        title: "Arguments",
231        rows: REMOVE_ARGUMENTS,
232    },
233    HelpSection {
234        title: "Selection",
235        rows: REMOVE_SELECTION,
236    },
237    HelpSection {
238        title: "Execution",
239        rows: REMOVE_EXECUTION,
240    },
241];
242const STATUS_SECTIONS: &[HelpSection] = &[
243    HelpSection {
244        title: "Arguments",
245        rows: STATUS_ARGUMENTS,
246    },
247    HelpSection {
248        title: "Configuration",
249        rows: STATUS_CONFIGURATION,
250    },
251    HelpSection {
252        title: "Output",
253        rows: STATUS_OUTPUT,
254    },
255];
256const DOCTOR_SECTIONS: &[HelpSection] = &[
257    HelpSection {
258        title: "Configuration",
259        rows: DOCTOR_CONFIGURATION,
260    },
261    HelpSection {
262        title: "Output",
263        rows: DOCTOR_OUTPUT,
264    },
265];
266const CONFIG_SECTIONS: &[HelpSection] = &[HelpSection {
267    title: "Configuration",
268    rows: CONFIG_OPTIONS,
269}];
270const EFFECTIVE_SECTIONS: &[HelpSection] = &[
271    HelpSection {
272        title: "Configuration",
273        rows: EFFECTIVE_CONFIGURATION,
274    },
275    HelpSection {
276        title: "Output",
277        rows: EFFECTIVE_OUTPUT,
278    },
279];
280const INIT_SECTIONS: &[HelpSection] = &[
281    HelpSection {
282        title: "Output",
283        rows: INIT_OUTPUT,
284    },
285    HelpSection {
286        title: "Execution",
287        rows: INIT_EXECUTION,
288    },
289];
290const CACHE_STATUS_SECTIONS: &[HelpSection] = &[HelpSection {
291    title: "Output",
292    rows: CACHE_STATUS_OPTIONS,
293}];
294const CACHE_GC_SECTIONS: &[HelpSection] = &[
295    HelpSection {
296        title: "Selection",
297        rows: CACHE_GC_SELECTION,
298    },
299    HelpSection {
300        title: "Execution",
301        rows: CACHE_GC_EXECUTION,
302    },
303    HelpSection {
304        title: "Output",
305        rows: CACHE_GC_OUTPUT,
306    },
307];
308const APT_MIRROR_SECTIONS: &[HelpSection] = &[HelpSection {
309    title: "Configuration",
310    rows: APT_MIRROR_OPTIONS,
311}];
312const APT_MIRROR_WRITE_SECTIONS: &[HelpSection] = &[
313    HelpSection {
314        title: "Configuration",
315        rows: APT_MIRROR_OPTIONS,
316    },
317    HelpSection {
318        title: "Confirmation",
319        rows: &[("-y, --yes", "Skip the confirmation prompt")],
320    },
321];
322
323/// Return the help metadata for a command path, excluding universal help/version flags.
324pub(crate) fn command_help(path: &[&str]) -> Option<CommandHelp> {
325    let (about, children, sections): (&str, &[(&str, &str)], &[HelpSection]) = match path {
326        ["config"] => (
327            "Create, validate, and inspect configuration",
328            &[],
329            NO_SECTIONS,
330        ),
331        ["config", "init"] => ("Create bot-forge.toml", &[], INIT_SECTIONS),
332        ["config", "validate"] => ("Validate configuration", &[], CONFIG_SECTIONS),
333        ["config", "effective"] => (
334            "Print effective configuration",
335            NO_CHILDREN,
336            EFFECTIVE_SECTIONS,
337        ),
338        ["config", "explain"] => (
339            "Explain configuration sources",
340            NO_CHILDREN,
341            CONFIG_SECTIONS,
342        ),
343        ["config", "help"] => (
344            "Print this message or the help of the given subcommand(s)",
345            NO_CHILDREN,
346            NO_SECTIONS,
347        ),
348        ["plan"] => (
349            "Create or explain an installation plan",
350            NO_CHILDREN,
351            PLAN_SECTIONS,
352        ),
353        ["install"] => (
354            "Execute an installation plan",
355            NO_CHILDREN,
356            INSTALL_SECTIONS,
357        ),
358        ["resume"] => (
359            "Resume or abandon an unfinished transaction",
360            NO_CHILDREN,
361            RESUME_SECTIONS,
362        ),
363        ["remove"] => (
364            "Remove a managed installation",
365            NO_CHILDREN,
366            REMOVE_SECTIONS,
367        ),
368        ["status"] => (
369            "Show managed installation status",
370            NO_CHILDREN,
371            STATUS_SECTIONS,
372        ),
373        ["cache"] => ("Inspect or reclaim the cache", NO_CHILDREN, NO_SECTIONS),
374        ["cache", "status"] => ("Show cache status", NO_CHILDREN, CACHE_STATUS_SECTIONS),
375        ["cache", "gc"] => (
376            "Reclaim expired cache entries",
377            NO_CHILDREN,
378            CACHE_GC_SECTIONS,
379        ),
380        ["cache", "help"] => (
381            "Print this message or the help of the given subcommand(s)",
382            NO_CHILDREN,
383            NO_SECTIONS,
384        ),
385        ["doctor"] => ("Run system diagnostics", NO_CHILDREN, DOCTOR_SECTIONS),
386        ["apt-mirror"] => ("Manage APT mirrors", NO_CHILDREN, NO_SECTIONS),
387        ["apt-mirror", "show"] => (
388            "Preview the configured mirror",
389            NO_CHILDREN,
390            APT_MIRROR_SECTIONS,
391        ),
392        ["apt-mirror", "check"] => (
393            "Validate the configured mirror",
394            NO_CHILDREN,
395            APT_MIRROR_SECTIONS,
396        ),
397        ["apt-mirror", "apply"] => (
398            "Apply the configured mirror",
399            NO_CHILDREN,
400            APT_MIRROR_WRITE_SECTIONS,
401        ),
402        ["apt-mirror", "restore"] => (
403            "Restore the previous mirror",
404            NO_CHILDREN,
405            APT_MIRROR_WRITE_SECTIONS,
406        ),
407        ["apt-mirror", "help"] => (
408            "Print this message or the help of the given subcommand(s)",
409            NO_CHILDREN,
410            NO_SECTIONS,
411        ),
412        ["generate"] => (
413            "Generate protocol artifacts or documentation",
414            NO_CHILDREN,
415            NO_SECTIONS,
416        ),
417        [
418            "generate",
419            "completion" | "man" | "schema" | "json" | "jsonl",
420        ] => ("Generate a protocol artifact", NO_CHILDREN, NO_SECTIONS),
421        _ => return None,
422    };
423    let usage = match path {
424        ["config"] => "bot-forge config <COMMAND>".to_string(),
425        ["config", "init"] => "bot-forge config init [OPTIONS]".to_string(),
426        ["config", "validate"] => "bot-forge config validate [OPTIONS]".to_string(),
427        ["config", "effective"] => "bot-forge config effective [OPTIONS]".to_string(),
428        ["config", "explain"] => "bot-forge config explain [OPTIONS]".to_string(),
429        ["config", "help"] => "bot-forge config help [COMMAND]".to_string(),
430        ["plan"] => "bot-forge plan [OPTIONS] [PROFILE]".to_string(),
431        ["install"] => "bot-forge install [OPTIONS] [PROFILE]".to_string(),
432        ["resume"] => "bot-forge resume [OPTIONS] [PROFILE]".to_string(),
433        ["remove"] => "bot-forge remove [OPTIONS] <NAME>".to_string(),
434        ["status"] => "bot-forge status [OPTIONS] [PROFILE]".to_string(),
435        ["cache"] => "bot-forge cache <COMMAND>".to_string(),
436        ["cache", "status"] => "bot-forge cache status [OPTIONS]".to_string(),
437        ["cache", "gc"] => "bot-forge cache gc [OPTIONS]".to_string(),
438        ["cache", "help"] => "bot-forge cache help [COMMAND]".to_string(),
439        ["doctor"] => "bot-forge doctor [OPTIONS]".to_string(),
440        ["apt-mirror"] => "bot-forge apt-mirror <COMMAND>".to_string(),
441        ["apt-mirror", "help"] => "bot-forge apt-mirror help [COMMAND]".to_string(),
442        ["apt-mirror", action] => format!("bot-forge apt-mirror {action} [OPTIONS]"),
443        ["generate"] => "bot-forge generate <FORMAT>".to_string(),
444        ["generate", format] => format!("bot-forge generate {format}"),
445        _ => format!("bot-forge {}", path.join(" ")),
446    };
447    let children = if matches!(path, ["config"] | ["cache"] | ["apt-mirror"]) {
448        typed_children(path)
449    } else if path == ["generate"] {
450        typed_generate_formats()
451    } else {
452        children
453            .iter()
454            .map(|(name, description)| ((*name).to_owned(), (*description).to_owned()))
455            .collect()
456    };
457    Some(CommandHelp {
458        usage,
459        about,
460        children,
461        sections,
462    })
463}
464
465fn typed_generate_formats() -> Vec<(String, String)> {
466    let command = typed::command();
467    let Some(generate) = command
468        .get_subcommands()
469        .find(|command| command.get_name() == "generate")
470    else {
471        return Vec::new();
472    };
473    generate
474        .get_arguments()
475        .find(|argument| argument.get_id() == "format")
476        .map(|argument| {
477            argument
478                .get_possible_values()
479                .into_iter()
480                .map(|value| {
481                    (
482                        value.get_name().to_owned(),
483                        value
484                            .get_help()
485                            .map_or_else(String::new, |help| help.to_string()),
486                    )
487                })
488                .collect()
489        })
490        .unwrap_or_default()
491}
492
493fn typed_children(path: &[&str]) -> Vec<(String, String)> {
494    let mut command = typed::command();
495    for name in path {
496        let Some(next) = command
497            .get_subcommands()
498            .find(|candidate| candidate.get_name() == *name)
499            .cloned()
500        else {
501            return Vec::new();
502        };
503        command = next;
504    }
505    let mut children = command
506        .get_subcommands()
507        .filter_map(|child| {
508            child
509                .get_about()
510                .map(|about| (child.get_name().to_owned(), about.to_string()))
511        })
512        .collect::<Vec<_>>();
513    children.push((
514        "help".to_owned(),
515        "Print this message or the help of the given subcommand(s)".to_owned(),
516    ));
517    children
518}
519
520/// Return top-level command names and descriptions from the authoritative Clap tree.
521pub(crate) fn top_level_commands() -> Vec<(String, String)> {
522    typed::command()
523        .get_subcommands()
524        .filter_map(|command| {
525            command
526                .get_about()
527                .map(|about| (command.get_name().to_owned(), about.to_string()))
528        })
529        .collect()
530}
531
532/// Generate top-level human-readable command help for `version`.
533pub(crate) fn help_text(version: &str) -> String {
534    let commands = top_level_commands();
535    let mut text = format!(
536        "BotForge CLI {version} | Configurable Rust tool installer\n\nUsage: bot-forge [OPTIONS] <COMMAND>\n\nCommands:\n"
537    );
538    let width = commands
539        .iter()
540        .map(|(name, _)| name.len())
541        .max()
542        .unwrap_or(0)
543        + 2;
544    for (name, about) in commands {
545        text.push_str(&format!("  {name:<width$}{about}\n"));
546    }
547    text.push_str(
548        "\nEnvironment:\n  BOT_FORGE_HOME  Override the data directory for state, cache, logs, and artifacts.\n\nData directory defaults:\n  Windows  %LOCALAPPDATA%\\bot-forge\n  Linux    $XDG_DATA_HOME/bot-forge or ~/.local/share/bot-forge\n  macOS    ~/Library/Application Support/bot-forge\n",
549    );
550    text.push_str(
551        "\nNotes:\n  Run `bot-forge generate completion|man|schema` to create raw artifacts.\n  Run `bot-forge config validate` before installing from a custom configuration.\n",
552    );
553    text
554}
555
556/// Generate a shell completion definition from the authoritative Clap tree.
557pub(crate) fn completion() -> String {
558    format!(
559        "complete -W \"{}\" bot-forge\n",
560        top_level_commands()
561            .iter()
562            .map(|(name, _)| name.as_str())
563            .collect::<Vec<_>>()
564            .join(" ")
565    )
566}
567
568/// Generate a roff man page from the authoritative Clap tree.
569pub(crate) fn man_page(version: &str) -> String {
570    let mut text = format!(
571        ".TH BOT-FORGE 1\n.SH NAME\nbot-forge - configurable installer\n.SH VERSION\n{version}\n.SH COMMANDS\n"
572    );
573    for (name, about) in top_level_commands() {
574        text.push_str(&format!(".TP\n.B {name}\n{about}\n"));
575    }
576    text
577}
578
579/// Return the embedded JSON Schema for bot-forge configuration.
580pub(crate) fn schema() -> &'static str {
581    include_str!("../../schema/config.json")
582}
583
584#[cfg(test)]
585mod tests {
586    use crate::cli::command_help;
587
588    #[test]
589    fn nested_command_groups_expose_the_standard_help_command() {
590        for path in [
591            ["config"].as_slice(),
592            ["cache"].as_slice(),
593            ["apt-mirror"].as_slice(),
594        ] {
595            let commands = command_help(path).expect("group help").children;
596            assert!(commands.iter().any(|(name, _)| name == "help"));
597            let mut help_path = path.to_vec();
598            help_path.push("help");
599            assert!(command_help(&help_path).is_some());
600        }
601    }
602}