Skip to content

Commit 58e86d5

Browse files
authored
Implement manpage generation for goose-cli (#6980)
Signed-off-by: Rodolfo Olivieri <rodolfo.olivieri3@gmail.com>
1 parent 72601fd commit 58e86d5

6 files changed

Lines changed: 209 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Justfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ generate-openapi:
196196
@echo "Generating frontend API..."
197197
cd ui/desktop && npx @hey-api/openapi-ts
198198

199+
# Generate manpages for the CLI
200+
generate-manpages:
201+
@echo "Generating manpages..."
202+
cargo run -p goose-cli --bin generate_manpages
203+
@echo "Manpages generated at target/man/"
204+
199205
# make GUI with latest binary
200206
lint-ui:
201207
cd ui/desktop && npm run lint:check

crates/goose-cli/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ workspace = true
1414
name = "goose"
1515
path = "src/main.rs"
1616

17+
[[bin]]
18+
name = "generate_manpages"
19+
path = "src/bin/generate_manpages.rs"
20+
1721
[dependencies]
22+
clap_mangen = "0.2.31"
1823
goose = { path = "../goose" }
1924
goose-acp = { path = "../goose-acp" }
2025
goose-mcp = { path = "../goose-mcp" }
@@ -66,3 +71,4 @@ disable-update = []
6671
[dev-dependencies]
6772
tempfile = "3"
6873
tokio = { workspace = true }
74+
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! Generate manpages for the goose CLI.
2+
//!
3+
//! This binary generates ROFF-formatted manpages from the clap CLI definitions.
4+
//! Manpages are an essential part of the Linux/Unix ecosystem, providing users with
5+
//! offline documentation accessible via the `man` command (e.g., `man goose`).
6+
//!
7+
//! When goose is packaged for Linux distributions (deb, rpm, etc.), the generated
8+
//! manpages should be installed to `/usr/share/man/man1/` so users can access help
9+
//! without an internet connection, following Unix conventions that have existed
10+
//! since the 1970s.
11+
//!
12+
//! Usage:
13+
//! cargo run -p goose-cli --bin generate_manpages
14+
//! # or
15+
//! just generate-manpages
16+
//!
17+
//! Output: target/man/goose.1, target/man/goose-session.1, etc.
18+
19+
use clap::CommandFactory;
20+
use clap_mangen::Man;
21+
use goose_cli::Cli;
22+
use std::env;
23+
use std::fs;
24+
use std::io::Result;
25+
use std::path::PathBuf;
26+
27+
fn main() -> Result<()> {
28+
// Manpages are a Unix/Linux convention - skip generation on Windows
29+
if cfg!(target_os = "windows") {
30+
eprintln!("Skipping manpage generation on Windows (manpages are a Unix/Linux convention)");
31+
return Ok(());
32+
}
33+
34+
let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
35+
let output_dir = PathBuf::from(package_dir)
36+
.join("..")
37+
.join("..")
38+
.join("target")
39+
.join("man");
40+
41+
fs::create_dir_all(&output_dir)?;
42+
43+
let cmd = Cli::command();
44+
45+
// First pass: collect all command names for SEE ALSO sections
46+
let mut all_commands: Vec<String> = Vec::new();
47+
collect_command_names(&cmd, &mut all_commands, None);
48+
49+
// Second pass: generate manpages with SEE ALSO sections
50+
generate_manpages(&cmd, &output_dir, None, &all_commands)?;
51+
52+
let canonical_path = output_dir.canonicalize()?;
53+
eprintln!(
54+
"Successfully generated manpages at {}",
55+
canonical_path.display()
56+
);
57+
58+
Ok(())
59+
}
60+
61+
fn collect_command_names(cmd: &clap::Command, names: &mut Vec<String>, parent_name: Option<&str>) {
62+
let name = match parent_name {
63+
Some(parent) => format!("{}-{}", parent, cmd.get_name()),
64+
None => cmd.get_name().to_string(),
65+
};
66+
names.push(name.clone());
67+
68+
for subcmd in cmd.get_subcommands() {
69+
if subcmd.get_name() == "help" || subcmd.is_hide_set() {
70+
continue;
71+
}
72+
collect_command_names(subcmd, names, Some(&name));
73+
}
74+
}
75+
76+
fn generate_manpages(
77+
cmd: &clap::Command,
78+
dir: &PathBuf,
79+
parent_name: Option<&str>,
80+
all_commands: &[String],
81+
) -> Result<()> {
82+
let name = match parent_name {
83+
Some(parent) => format!("{}-{}", parent, cmd.get_name()),
84+
None => cmd.get_name().to_string(),
85+
};
86+
87+
// Generate the base manpage
88+
let man = Man::new(cmd.clone());
89+
let mut buffer = Vec::new();
90+
man.render(&mut buffer)?;
91+
92+
// Add SEE ALSO section
93+
let see_also = generate_see_also(&name, parent_name, cmd, all_commands);
94+
buffer.extend_from_slice(see_also.as_bytes());
95+
96+
let manpage_path = dir.join(format!("{}.1", name));
97+
fs::write(&manpage_path, buffer)?;
98+
eprintln!(" Generated: {}.1", name);
99+
100+
for subcmd in cmd.get_subcommands() {
101+
if subcmd.get_name() == "help" || subcmd.is_hide_set() {
102+
continue;
103+
}
104+
generate_manpages(subcmd, dir, Some(&name), all_commands)?;
105+
}
106+
107+
Ok(())
108+
}
109+
110+
fn generate_see_also(
111+
current_name: &str,
112+
parent_name: Option<&str>,
113+
cmd: &clap::Command,
114+
all_commands: &[String],
115+
) -> String {
116+
let mut references: Vec<String> = Vec::new();
117+
118+
// Always reference the main goose command if we're not it
119+
if current_name != "goose" {
120+
references.push("goose".to_string());
121+
}
122+
123+
// Reference parent command if exists and not already added
124+
if let Some(parent) = parent_name {
125+
if parent != "goose" && !references.contains(&parent.to_string()) {
126+
references.push(parent.to_string());
127+
}
128+
}
129+
130+
// For the main command, list immediate subcommands
131+
// For subcommands, list sibling commands
132+
if current_name == "goose" {
133+
// Add all immediate subcommands (skip hidden ones)
134+
for subcmd in cmd.get_subcommands() {
135+
let subcmd_name = subcmd.get_name();
136+
if subcmd_name != "help" && !subcmd.is_hide_set() {
137+
let full_name = format!("goose-{}", subcmd_name);
138+
if !references.contains(&full_name) {
139+
references.push(full_name);
140+
}
141+
}
142+
}
143+
} else if let Some(parent) = parent_name {
144+
// Add sibling commands (other commands with same parent)
145+
let prefix = format!("{}-", parent);
146+
for cmd_name in all_commands {
147+
if cmd_name.starts_with(&prefix) && cmd_name != current_name {
148+
// Only add immediate siblings, not nested subcommands
149+
let suffix = &cmd_name.strip_prefix(&prefix).unwrap_or(cmd_name);
150+
if !suffix.contains('-') && !references.contains(cmd_name) {
151+
references.push(cmd_name.clone());
152+
}
153+
}
154+
}
155+
}
156+
157+
// Sort references for consistent output
158+
references.sort();
159+
160+
if references.is_empty() {
161+
return String::new();
162+
}
163+
164+
// Format as ROFF
165+
let mut roff = String::from("\n.SH \"SEE ALSO\"\n");
166+
let formatted_refs: Vec<String> = references
167+
.iter()
168+
.map(|r| {
169+
let escaped = r.replace('-', "\\-");
170+
format!(".BR {} (1)", escaped)
171+
})
172+
.collect();
173+
roff.push_str(&formatted_refs.join(",\n"));
174+
roff.push('\n');
175+
176+
roff
177+
}

crates/goose-cli/src/cli.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ use std::path::PathBuf;
3535
use tracing::warn;
3636

3737
#[derive(Parser)]
38-
#[command(author, version, display_name = "", about, long_about = None)]
39-
struct Cli {
38+
#[command(name = "goose", author, version, display_name = "", about, long_about = None)]
39+
pub struct Cli {
4040
#[command(subcommand)]
4141
command: Option<Command>,
4242
}

crates/goose-cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ pub mod session;
88
pub mod signal;
99

1010
// Re-export commonly used types
11+
pub use cli::Cli;
1112
pub use session::CliSession;

0 commit comments

Comments
 (0)