Implement manpage generation for goose-cli (#6980)

Signed-off-by: Rodolfo Olivieri <rodolfo.olivieri3@gmail.com>
This commit is contained in:
Rodolfo Olivieri 2026-02-10 12:30:12 -03:00 committed by GitHub
parent 72601fd3cb
commit 58e86d58ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 209 additions and 2 deletions

17
Cargo.lock generated
View file

@ -1833,6 +1833,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"
@ -4253,6 +4263,7 @@ dependencies = [
"chrono",
"clap",
"clap_complete",
"clap_mangen",
"cliclack",
"console 0.16.2",
"dotenvy",
@ -8027,6 +8038,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"

View file

@ -196,6 +196,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

View file

@ -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-mcp = { path = "../goose-mcp" }
@ -66,3 +71,4 @@ disable-update = []
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true }

View file

@ -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<String> = 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<String>, 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<String> = 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<String> = references
.iter()
.map(|r| {
let escaped = r.replace('-', "\\-");
format!(".BR {} (1)", escaped)
})
.collect();
roff.push_str(&formatted_refs.join(",\n"));
roff.push('\n');
roff
}

View file

@ -35,8 +35,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<Command>,
}

View file

@ -8,4 +8,5 @@ pub mod session;
pub mod signal;
// Re-export commonly used types
pub use cli::Cli;
pub use session::CliSession;