Skip to content
Open
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
42 changes: 38 additions & 4 deletions cli-engine/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion cli-engine/src/cli/tree_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion cli-engine/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
43 changes: 42 additions & 1 deletion cli-engine/tests/consumer_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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::<Value>(&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();
Expand Down