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
6 changes: 6 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ stored rules in `validation_error` so an owner can remove and repair them.

## Commands

`buzz --help` prints this whole surface as an indented tree — every group with
its subcommands and their descriptions — so there is no need to run `--help`
once per group to find a command. `buzz -h` keeps the short group-level
summary, and `buzz <group> <subcommand> --help` has the flags and examples.
The table below mirrors that tree for readers who are not at a terminal.

| Group | Subcommand | Description |
|-------|-----------|-------------|
| `messages` | `send` | Send a message to a channel |
Expand Down
214 changes: 214 additions & 0 deletions crates/buzz-cli/src/help_tree.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Agent-friendly rendering of the `buzz` command tree.
//!
//! `buzz --help` lists every group *and* the subcommands under it, so a caller
//! learns the whole surface in one invocation instead of one `--help` call per
//! group. The tree is walked from clap's own command definition, so it cannot
//! drift from the commands that actually exist.

use clap::Command;

/// Column (0-indexed, in characters) where descriptions begin.
const DESC_COL: usize = 32;

/// Total line width. This matches clap's own default when its `wrap_help`
/// feature is off, which is how this crate builds clap.
const LINE_WIDTH: usize = 100;

/// Render the subcommand tree under `cmd`, descending at most `max_depth`
/// levels. `max_depth == 1` lists only the top-level groups.
///
/// The returned string has one entry per line and no trailing newline.
pub(crate) fn render(cmd: &Command, max_depth: usize) -> String {
let mut out = String::new();
write_level(&mut out, cmd, 1, max_depth);
out.truncate(out.trim_end().len());
out
}

fn write_level(out: &mut String, parent: &Command, depth: usize, max_depth: usize) {
if depth > max_depth {
return;
}
let mut children: Vec<&Command> = parent
.get_subcommands()
// clap's auto-generated `help` subcommand tells a reader nothing about
// what the CLI can do, and it repeats at every level of the tree.
.filter(|child| !child.is_hide_set() && child.get_name() != "help")
.collect();
// Match clap's own ordering: the derive assigns a display order per
// variant, so this preserves declaration order, with the name as tiebreak.
children.sort_by_key(|child| (child.get_display_order(), child.get_name()));

for child in children {
write_entry(out, child, depth);
write_level(out, child, depth + 1, max_depth);
}
}

fn write_entry(out: &mut String, cmd: &Command, depth: usize) {
let left = format!("{}{}", " ".repeat(depth), cmd.get_name());
let description = description_of(cmd);

if description.is_empty() {
out.push_str(&left);
out.push('\n');
return;
}

let pad = DESC_COL.saturating_sub(left.chars().count()).max(1);
let mut lines = wrap(&description, LINE_WIDTH.saturating_sub(DESC_COL)).into_iter();

// `wrap` returns at least one line for non-empty input, but fall back to
// the bare name rather than dropping the entry if that ever changes.
let Some(first) = lines.next() else {
out.push_str(&left);
out.push('\n');
return;
};
out.push_str(&left);
out.push_str(&" ".repeat(pad));
out.push_str(&first);
out.push('\n');

for line in lines {
out.push_str(&" ".repeat(DESC_COL));
out.push_str(&line);
out.push('\n');
}
}

/// A command's one-line description, prefixed with its visible aliases.
fn description_of(cmd: &Command) -> String {
let mut description = String::new();
let aliases: Vec<&str> = cmd.get_visible_aliases().collect();
if !aliases.is_empty() {
description.push_str(&format!("[alias: {}] ", aliases.join(", ")));
}
if let Some(about) = cmd.get_about() {
description.push_str(&about.to_string());
}
description.trim_end().to_string()
}

/// Greedy word wrap. Words longer than `width` overflow rather than break.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.chars().count() + 1 + word.chars().count() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}

#[cfg(test)]
mod tests {
use super::*;
use crate::build_command;

// These assertions are deliberately column-exact. clap runs
// `StyledStr::wrap` over `after_help`, which is a no-op only because this
// crate builds clap without the `wrap_help` feature; if workspace feature
// unification ever turns that on, clap reflows the tree and destroys the
// alignment. A looser `contains("send")` assertion would not notice.
#[test]
fn long_help_lists_each_group_with_its_subcommands() {
let help = build_command().render_long_help().to_string();

assert!(
help.contains(
"\n messages Send, read, search, and manage messages\n"
),
"group line missing or misaligned:\n{help}"
);
assert!(
help.contains("\n send Send a message to a channel\n"),
"subcommand line missing or misaligned:\n{help}"
);
assert!(
help.contains("\n draft-create Open a prefilled create-agent form in the owner's Buzz Desktop\n"),
"subcommand line missing or misaligned:\n{help}"
);
}

#[test]
fn long_help_descends_past_the_second_level() {
let help = build_command().render_long_help().to_string();

assert!(
help.contains("\n protect Manage branch and tag protection rules on one of your repositories\n"),
"nested group line missing or misaligned:\n{help}"
);
assert!(
help.contains(
"\n list List the repository's protection rules\n"
),
"third-level line missing or misaligned:\n{help}"
);
}

#[test]
fn long_help_wraps_long_descriptions_into_the_description_column() {
let help = build_command().render_long_help().to_string();

assert!(
help.contains(
"\n emoji Manage your custom emoji set (workspace palette is the union of all\n members' sets)\n"
),
"wrapped description missing or misaligned:\n{help}"
);
}

#[test]
fn short_help_stays_at_the_group_level() {
let help = build_command().render_help().to_string();

assert!(
help.contains(
"\n messages Send, read, search, and manage messages\n"
),
"group line missing from short help:\n{help}"
);
assert!(
!help.contains("\n send "),
"short help should not descend into subcommands:\n{help}"
);
}

#[test]
fn tree_omits_claps_generated_help_subcommand() {
let tree = render(&build_command(), usize::MAX);

assert!(!tree.lines().any(|line| line.trim() == "help"));
assert!(!tree.contains("Print this message or the help of the given subcommand"));
}

#[test]
fn depth_one_lists_groups_only() {
let cmd = build_command();
let groups = render(&cmd, 1);

assert!(groups.contains("\n messages "));
assert!(!groups.contains("\n send "));
}

#[test]
fn wrap_keeps_words_intact_and_respects_width() {
assert_eq!(wrap("", 10), Vec::<String>::new());
assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
assert_eq!(
wrap("supercalifragilistic x", 5),
vec!["supercalifragilistic", "x"]
);
}
}
40 changes: 38 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ pub mod agent_management;
mod client;
mod commands;
mod error;
mod help_tree;
mod links;
mod validate;

use clap::{Parser, Subcommand};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use client::BuzzClient;
use error::CliError;
use nostr::Keys;
Expand Down Expand Up @@ -38,7 +39,7 @@ where
// double-install returns Err and is harmless.
let _ = rustls::crypto::ring::default_provider().install_default();

let cli = match Cli::try_parse_from(args) {
let cli = match parse_args(args) {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr() {
Expand All @@ -60,6 +61,41 @@ where
}
}

/// Root help layout. Identical to clap's default except that `{subcommands}`
/// is dropped and the command tree arrives through `{after-help}` instead, so
/// the group list is not printed twice. `{options}` and `{subcommands}` emit no
/// heading of their own — clap writes those from `write_all_args`, which this
/// template bypasses — so the headings are spelled out here.
const ROOT_HELP_TEMPLATE: &str = "\
{before-help}{about-with-newline}
{usage-heading} {usage}{after-help}

Options:
{options}";

/// The root command with the agent-friendly command tree installed.
///
/// clap picks `after_long_help` for `--help` and falls back to `after_help`
/// for `-h`, which is what gives the two depths: `-h` keeps the group-level
/// summary, `--help` shows every subcommand under every group.
fn build_command() -> clap::Command {
let cmd = Cli::command();
let groups = help_tree::render(&cmd, 1);
let full = help_tree::render(&cmd, usize::MAX);
cmd.help_template(ROOT_HELP_TEMPLATE)
.after_help(format!("Commands:\n{groups}"))
.after_long_help(format!("Commands:\n{full}"))
}

fn parse_args<I, S>(args: I) -> Result<Cli, clap::Error>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
let matches = build_command().try_get_matches_from(args)?;
Cli::from_arg_matches(&matches)
}

#[derive(Parser)]
#[command(
name = "buzz",
Expand Down
Loading