From 5dae5dde8c4f7cbebfec363e04ecb6d320130ff7 Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Thu, 5 Feb 2026 09:17:21 -0300 Subject: [PATCH] Implement manpage generation for goose-cli This patch brings the capabilities of generating manpages using CLI descriptions. This process is not automated as it would probably not be included in every release or target, so a `just` target was created to easy the work whenever it is needed. Signed-off-by: Rodolfo Olivieri --- Cargo.lock | 17 ++ Justfile | 6 + crates/goose-cli/Cargo.toml | 6 + crates/goose-cli/src/bin/generate_manpages.rs | 177 ++++++++++++++++++ crates/goose-cli/src/cli.rs | 4 +- crates/goose-cli/src/lib.rs | 1 + 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 crates/goose-cli/src/bin/generate_manpages.rs diff --git a/Cargo.lock b/Cargo.lock index 18c4e2273b4d..d09b529e5354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,16 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clap_mangen" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "cliclack" version = "0.3.8" @@ -4300,6 +4310,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "cliclack", "console 0.16.2", "dotenvy", @@ -8118,6 +8129,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + [[package]] name = "ron" version = "0.12.0" diff --git a/Justfile b/Justfile index 04d84aa79144..57ac7af4af3e 100644 --- a/Justfile +++ b/Justfile @@ -195,6 +195,12 @@ generate-openapi: @echo "Generating frontend API..." cd ui/desktop && npx @hey-api/openapi-ts +# Generate manpages for the CLI +generate-manpages: + @echo "Generating manpages..." + cargo run -p goose-cli --bin generate_manpages + @echo "Manpages generated at target/man/" + # make GUI with latest binary lint-ui: cd ui/desktop && npm run lint:check diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 824225baa6fe..4f149e5bed2a 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -14,7 +14,12 @@ workspace = true name = "goose" path = "src/main.rs" +[[bin]] +name = "generate_manpages" +path = "src/bin/generate_manpages.rs" + [dependencies] +clap_mangen = "0.2.31" goose = { path = "../goose" } goose-acp = { path = "../goose-acp" } goose-bench = { path = "../goose-bench" } @@ -67,3 +72,4 @@ disable-update = [] [dev-dependencies] tempfile = "3" tokio = { workspace = true } + diff --git a/crates/goose-cli/src/bin/generate_manpages.rs b/crates/goose-cli/src/bin/generate_manpages.rs new file mode 100644 index 000000000000..5e2dbf8a13f1 --- /dev/null +++ b/crates/goose-cli/src/bin/generate_manpages.rs @@ -0,0 +1,177 @@ +//! Generate manpages for the goose CLI. +//! +//! This binary generates ROFF-formatted manpages from the clap CLI definitions. +//! Manpages are an essential part of the Linux/Unix ecosystem, providing users with +//! offline documentation accessible via the `man` command (e.g., `man goose`). +//! +//! When goose is packaged for Linux distributions (deb, rpm, etc.), the generated +//! manpages should be installed to `/usr/share/man/man1/` so users can access help +//! without an internet connection, following Unix conventions that have existed +//! since the 1970s. +//! +//! Usage: +//! cargo run -p goose-cli --bin generate_manpages +//! # or +//! just generate-manpages +//! +//! Output: target/man/goose.1, target/man/goose-session.1, etc. + +use clap::CommandFactory; +use clap_mangen::Man; +use goose_cli::Cli; +use std::env; +use std::fs; +use std::io::Result; +use std::path::PathBuf; + +fn main() -> Result<()> { + // Manpages are a Unix/Linux convention - skip generation on Windows + if cfg!(target_os = "windows") { + eprintln!("Skipping manpage generation on Windows (manpages are a Unix/Linux convention)"); + return Ok(()); + } + + let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let output_dir = PathBuf::from(package_dir) + .join("..") + .join("..") + .join("target") + .join("man"); + + fs::create_dir_all(&output_dir)?; + + let cmd = Cli::command(); + + // First pass: collect all command names for SEE ALSO sections + let mut all_commands: Vec = Vec::new(); + collect_command_names(&cmd, &mut all_commands, None); + + // Second pass: generate manpages with SEE ALSO sections + generate_manpages(&cmd, &output_dir, None, &all_commands)?; + + let canonical_path = output_dir.canonicalize()?; + eprintln!( + "Successfully generated manpages at {}", + canonical_path.display() + ); + + Ok(()) +} + +fn collect_command_names(cmd: &clap::Command, names: &mut Vec, parent_name: Option<&str>) { + let name = match parent_name { + Some(parent) => format!("{}-{}", parent, cmd.get_name()), + None => cmd.get_name().to_string(), + }; + names.push(name.clone()); + + for subcmd in cmd.get_subcommands() { + if subcmd.get_name() == "help" || subcmd.is_hide_set() { + continue; + } + collect_command_names(subcmd, names, Some(&name)); + } +} + +fn generate_manpages( + cmd: &clap::Command, + dir: &PathBuf, + parent_name: Option<&str>, + all_commands: &[String], +) -> Result<()> { + let name = match parent_name { + Some(parent) => format!("{}-{}", parent, cmd.get_name()), + None => cmd.get_name().to_string(), + }; + + // Generate the base manpage + let man = Man::new(cmd.clone()); + let mut buffer = Vec::new(); + man.render(&mut buffer)?; + + // Add SEE ALSO section + let see_also = generate_see_also(&name, parent_name, cmd, all_commands); + buffer.extend_from_slice(see_also.as_bytes()); + + let manpage_path = dir.join(format!("{}.1", name)); + fs::write(&manpage_path, buffer)?; + eprintln!(" Generated: {}.1", name); + + for subcmd in cmd.get_subcommands() { + if subcmd.get_name() == "help" || subcmd.is_hide_set() { + continue; + } + generate_manpages(subcmd, dir, Some(&name), all_commands)?; + } + + Ok(()) +} + +fn generate_see_also( + current_name: &str, + parent_name: Option<&str>, + cmd: &clap::Command, + all_commands: &[String], +) -> String { + let mut references: Vec = Vec::new(); + + // Always reference the main goose command if we're not it + if current_name != "goose" { + references.push("goose".to_string()); + } + + // Reference parent command if exists and not already added + if let Some(parent) = parent_name { + if parent != "goose" && !references.contains(&parent.to_string()) { + references.push(parent.to_string()); + } + } + + // For the main command, list immediate subcommands + // For subcommands, list sibling commands + if current_name == "goose" { + // Add all immediate subcommands (skip hidden ones) + for subcmd in cmd.get_subcommands() { + let subcmd_name = subcmd.get_name(); + if subcmd_name != "help" && !subcmd.is_hide_set() { + let full_name = format!("goose-{}", subcmd_name); + if !references.contains(&full_name) { + references.push(full_name); + } + } + } + } else if let Some(parent) = parent_name { + // Add sibling commands (other commands with same parent) + let prefix = format!("{}-", parent); + for cmd_name in all_commands { + if cmd_name.starts_with(&prefix) && cmd_name != current_name { + // Only add immediate siblings, not nested subcommands + let suffix = &cmd_name.strip_prefix(&prefix).unwrap_or(cmd_name); + if !suffix.contains('-') && !references.contains(cmd_name) { + references.push(cmd_name.clone()); + } + } + } + } + + // Sort references for consistent output + references.sort(); + + if references.is_empty() { + return String::new(); + } + + // Format as ROFF + let mut roff = String::from("\n.SH \"SEE ALSO\"\n"); + let formatted_refs: Vec = references + .iter() + .map(|r| { + let escaped = r.replace('-', "\\-"); + format!(".BR {} (1)", escaped) + }) + .collect(); + roff.push_str(&formatted_refs.join(",\n")); + roff.push('\n'); + + roff +} diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index babe728c602a..0858e81c2c75 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -41,8 +41,8 @@ use std::path::PathBuf; use tracing::warn; #[derive(Parser)] -#[command(author, version, display_name = "", about, long_about = None)] -struct Cli { +#[command(name = "goose", author, version, display_name = "", about, long_about = None)] +pub struct Cli { #[command(subcommand)] command: Option, } diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index 3996e0796d13..b5006389cbb9 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -8,4 +8,5 @@ pub mod session; pub mod signal; // Re-export commonly used types +pub use cli::Cli; pub use session::CliSession;