From c6f3ca69ea785f9d0a5542ee49dd04a5def11eb1 Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 18:45:47 -0500 Subject: [PATCH 1/3] feat(command): Implement render_bare_group_discovery for bare group tree --- cli-engine/src/cli.rs | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index 594748f..f860de8 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1818,10 +1818,11 @@ impl Cli { &self.config.app_id, )); } - return self.finish_run(CliRunOutput { - exit_code: 0, - rendered: group.clone().render_long_help().to_string(), - }); + return self.finish_run(self.render_bare_group_discovery( + group, + &command_path, + &middleware, + )); } if command_path.is_empty() && let Some(root_next_actions) = &self.root_next_actions @@ -2000,6 +2001,39 @@ impl Cli { } } + /// Renders a bare group invocation (no subcommand given). + /// + /// Human output keeps the existing clap help text; every other format, + /// explicit `--output json`/`--toon`, or the non-TTY default an agent + /// sees with no `--output` flag at all — gets an explicit JSON + /// command-tree subset scoped to this group, built with the same + /// [`crate::tree`] machinery as the top-level `tree` command. + fn render_bare_group_discovery( + &self, + group: &Command, + command_path: &str, + middleware: &Middleware, + ) -> CliRunOutput { + let format: crate::output::OutputFormat = match middleware.output_format.parse() { + Ok(format) => format, + Err(err) => { + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + }; + if format == crate::output::OutputFormat::Human { + return CliRunOutput { + exit_code: 0, + rendered: group.clone().render_long_help().to_string(), + }; + } + let path = format!("{} {}", self.config.name, command_path.replace(':', " ")); + let tree = crate::tree::build_tree_from_clap_with_path(group, path); + tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format) + } + fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput { let format: crate::output::OutputFormat = match output_format.parse() { Ok(format) => format, From 08d702d455c2bd752f39eb14939f44a4179adb74 Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 18:47:01 -0500 Subject: [PATCH 2/3] chore(command): Wire Up and Expose required functions --- cli-engine/src/cli/tree_render.rs | 15 ++++++++++++++- cli-engine/src/tree.rs | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cli-engine/src/cli/tree_render.rs b/cli-engine/src/cli/tree_render.rs index 3db43e9..b5a11c9 100644 --- a/cli-engine/src/cli/tree_render.rs +++ b/cli-engine/src/cli/tree_render.rs @@ -4,7 +4,7 @@ use crate::{ CliRunOutput, Envelope, Middleware, error::exit_code_for_error, output::{OutputFormat, render}, - tree::{build_tree_from_clap, render_tree_human}, + tree::{TreeNode, build_tree_from_clap, render_tree_human}, }; pub(crate) fn render_tree(root: &Command, app_id: &str, middleware: &Middleware) -> CliRunOutput { @@ -24,6 +24,19 @@ pub(crate) fn render_tree(root: &Command, app_id: &str, middleware: &Middleware) rendered: render_tree_human(&tree), }; } + render_tree_envelope(tree, app_id, middleware, format) +} + +/// Renders a [`TreeNode`] as a JSON/TOON envelope. Shared by [`render_tree`] +/// (the full-CLI `tree` command) and the bare-group discovery fallback, +/// which scopes the same tree-building machinery to a single group's +/// subtree instead of the whole CLI. +pub(crate) fn render_tree_envelope( + tree: TreeNode, + app_id: &str, + middleware: &Middleware, + format: OutputFormat, +) -> CliRunOutput { let envelope = Envelope::success(tree, app_id.to_owned()).prepare_for_render(&middleware.verbose); match render(format, &envelope) { diff --git a/cli-engine/src/tree.rs b/cli-engine/src/tree.rs index 75799a6..2d08aad 100644 --- a/cli-engine/src/tree.rs +++ b/cli-engine/src/tree.rs @@ -64,7 +64,7 @@ pub fn build_tree_from_clap(command: &Command) -> TreeNode { build_tree_from_clap_with_path(command, command.get_name().to_owned()) } -fn build_tree_from_clap_with_path(command: &Command, path: String) -> TreeNode { +pub(crate) fn build_tree_from_clap_with_path(command: &Command, path: String) -> TreeNode { let children = command .get_subcommands() .filter(|child| !child.is_hide_set() && child.get_name() != "completion") From e4665a56c522e0c1335df478b1ba69dbabad2e06 Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 18:47:20 -0500 Subject: [PATCH 3/3] chore(tests): Modify and extend tests with new behavior --- cli-engine/tests/consumer_cli.rs | 43 +++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/cli-engine/tests/consumer_cli.rs b/cli-engine/tests/consumer_cli.rs index e08a011..a564807 100644 --- a/cli-engine/tests/consumer_cli.rs +++ b/cli-engine/tests/consumer_cli.rs @@ -209,7 +209,10 @@ async fn bare_invocation_human_shows_help_with_next_actions() { async fn group_help_keeps_subcommands_but_drops_global_options() { let cli = consumer_cli(); - let group = cli.run(["my-cli", "project"]).await; + // `--human` forces the interactive format (under `cargo test` stdout is + // not a TTY, so the default would otherwise resolve to json — see + // `bare_group_discovery_defaults_to_json_tree_off_a_tty` below). + let group = cli.run(["my-cli", "project", "--human"]).await; assert_eq!(group.exit_code, 0); // Group page lists its child commands... assert!(group.rendered.contains("Commands:"), "{}", group.rendered); @@ -223,6 +226,44 @@ async fn group_help_keeps_subcommands_but_drops_global_options() { assert!(leaf.rendered.contains("--fields"), "{}", leaf.rendered); } +#[tokio::test] +async fn bare_group_discovery_defaults_to_json_tree_off_a_tty() { + let cli = consumer_cli(); + + // No explicit `--output`/`--human`/`--json`: under `cargo test` stdout is + // not a TTY, so this resolves to the same JSON-tree discovery an agent + // gets by default. + let group = cli.run(["my-cli", "project"]).await; + assert_eq!(group.exit_code, 0, "{}", group.rendered); + let data = + &serde_json::from_str::(&group.rendered).expect("expected a JSON envelope")["data"]; + assert_eq!(data["name"], "project"); + assert_eq!(data["path"], "my-cli project"); + let children = data["children"].as_array().expect("children array"); + assert!( + children + .iter() + .any(|child| child["name"] == "list" && child["description"] == "List projects"), + "{}", + group.rendered + ); + assert!( + children.iter().any(|child| child["name"] == "delete"), + "{}", + group.rendered + ); +} + +#[tokio::test] +async fn bare_group_discovery_explicit_json_matches_tty_default() { + let cli = consumer_cli(); + + let explicit = cli.run(["my-cli", "project", "--json"]).await; + let default = cli.run(["my-cli", "project"]).await; + assert_eq!(explicit.exit_code, 0, "{}", explicit.rendered); + assert_eq!(explicit.rendered, default.rendered); +} + #[tokio::test] async fn bare_invocation_emits_discovery_envelope_for_explicit_json() { let cli = consumer_cli_with_root_actions();