1mod 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
17pub(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
25pub(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
323pub(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
520pub(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
532pub(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
556pub(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
568pub(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
579pub(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}