Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -67,3 +72,4 @@ disable-update = []
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true }

177 changes: 177 additions & 0 deletions crates/goose-cli/src/bin/generate_manpages.rs
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Command>,
}
Expand Down
1 change: 1 addition & 0 deletions crates/goose-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pub mod session;
pub mod signal;

// Re-export commonly used types
pub use cli::Cli;
pub use session::CliSession;