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
4 changes: 1 addition & 3 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,11 @@ The framework registers built-in commands for common CLI behavior:
| --- | --- | --- |
| `help` | Always | Displays usage for root, groups, and commands. |
| `tree` | Always | Displays the full command hierarchy. |
| `completion [shell] [--install]` | Always | Prints or installs a shell completion script generated from the command tree via `clap_complete`; see [Shell Completion](completion.md). |
| `auth login` / `auth status` / `auth logout` | Auth providers are registered or a default provider is configured | Manages credentials. |
| `guide [topic]` | Guides are registered | Lists and displays embedded guides. |
| `flags list` / `flags info <key>` | Always, unless a consumer module already registers a top-level `flags` group (the built-in group yields to it) | Inspects declared feature flags and the active policy; see [Feature Flags & Stages](#feature-flags--stages). |

`guide` accepts zero or one topic. Additional positional arguments are rejected before guide content
is rendered.

Application `pre_run` hooks run for executable commands, including bare command groups that render
group help, `help`, `tree`, `guide`, and auth commands. `init_deps` is narrower: it initializes
runtime dependencies for real command execution and auth provider loading, but search/schema
Expand Down
24 changes: 23 additions & 1 deletion cli-engine/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod completion;
mod help;
mod tree_render;

use clap::{Arg, ArgMatches, Command};
use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};

use crate::{
ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
Expand Down Expand Up @@ -1342,6 +1342,7 @@ impl Cli {
if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
self.root = self.root.clone().subcommand(guide_command());
}
self.sync_guide_topic_values();
self.refresh_root_long();
self
}
Expand All @@ -1361,10 +1362,31 @@ impl Cli {
if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
self.root = self.root.clone().subcommand(guide_command());
}
self.sync_guide_topic_values();
self.refresh_root_long();
self
}

/// Re-attaches the `guide` subcommand's `topic` arg possible values from
/// the current [`Self::guide_entries`], so shell completion knows about
/// guide names, which are not all registered up front.
fn sync_guide_topic_values(&mut self) {
if self.guide_entries.is_empty() {
return;
}
let names = self
.guide_entries
.iter()
.map(|entry| entry.name.clone())
.collect::<Vec<_>>();
if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") {
let taken = std::mem::replace(guide_cmd, Command::new("guide"));
*guide_cmd = taken.mut_arg("topic", |arg| {
arg.value_parser(PossibleValuesParser::new(names))
});
}
}

/// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline.
///
/// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument
Expand Down
17 changes: 15 additions & 2 deletions cli-engine/src/cli/builtins.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use clap::{Arg, ArgAction, ArgMatches, Command};
use clap::{Arg, ArgAction, ArgMatches, Command, builder::PossibleValuesParser};
use serde_json::Value;

use crate::{
Expand Down Expand Up @@ -42,7 +42,20 @@ pub(crate) fn guide_args(matches: &ArgMatches) -> ValueMap {
pub(crate) fn completion_command() -> Command {
Command::new("completion")
.about("Generate or install shell completion scripts")
.arg(Arg::new("shell").value_name("shell").num_args(0..=1))
.arg(
Arg::new("shell")
.value_name("shell")
.num_args(0..=1)
.ignore_case(true)
.value_parser(PossibleValuesParser::new([
"bash",
"zsh",
"fish",
"powershell",
"pwsh",
"elvish",
])),
)
.arg(
Arg::new("install")
.long("install")
Expand Down
1 change: 1 addition & 0 deletions cli-engine/src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub fn build_root_long(intro: &str, entries: &[ModuleHelpEntry], has_guide: bool
"Search all commands and guides by keyword",
),
("tree", "Display full command tree"),
("completion", "Generate or install shell completion scripts"),
];
if has_guide {
find_commands.push(("guide", "Built-in guides for AI agents and developers"));
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 @@ -67,7 +67,7 @@ pub fn build_tree_from_clap(command: &Command) -> TreeNode {
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")
.filter(|child| !child.is_hide_set())
.map(|child| {
let child_path = format!("{path} {}", child.get_name());
build_tree_from_clap_with_path(child, child_path)
Expand Down
39 changes: 39 additions & 0 deletions cli-engine/tests/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,45 @@ mod completion_integration {
);
}

// =========================================================================
// (a.1) The `shell` positional declares possible values so
// `<bin> completion <TAB>` suggests real shell names; parsing still
// accepts them case-insensitively and via the `pwsh` alias, matching
// `parse_shell`.
// =========================================================================

#[tokio::test]
async fn completion_shell_arg_advertises_possible_values_in_generated_script() {
let cli = demo_cli();
let out = cli.run(["demo", "completion", "bash"]).await;
assert_eq!(out.exit_code, 0, "bash: {}", out.rendered);
for shell in ["bash", "zsh", "fish", "powershell", "pwsh", "elvish"] {
assert!(
out.rendered.contains(shell),
"generated script should list {shell} as a completable value; got: {}",
out.rendered
);
}
}

#[tokio::test]
async fn completion_print_accepts_uppercase_shell_name() {
let cli = demo_cli();
let out = cli.run(["demo", "completion", "BASH"]).await;
assert_eq!(
out.exit_code, 0,
"possible-values arg must stay case-insensitive: {}",
out.rendered
);
}

#[tokio::test]
async fn completion_print_accepts_pwsh_alias() {
let cli = demo_cli();
let out = cli.run(["demo", "completion", "pwsh"]).await;
assert_eq!(out.exit_code, 0, "pwsh: {}", out.rendered);
}

// =========================================================================
// (b) Auto-detect: set $SHELL, call `completion` with no shell arg.
// =========================================================================
Expand Down
53 changes: 48 additions & 5 deletions cli-engine/tests/foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2366,13 +2366,56 @@ async fn cli_runtime_guide_command_errors_with_valid_topics() {

let output = cli.run(["my-cli", "guide", "missing"]).await;

assert_eq!(output.exit_code, 1);
assert_eq!(
output.rendered,
"unknown guide topic \"missing\" — valid topics: deploy"
// The `topic` arg declares its guide names as clap possible values (so
// `guide <TAB>` completes them), which makes clap itself reject an
// unrecognized topic at parse time — exit code 2, clap's own message —
// rather than reaching `guide::guide_content`'s custom error.
assert_eq!(output.exit_code, 2);
assert!(
output.rendered.contains("invalid value 'missing'") && output.rendered.contains("deploy"),
"{}",
output.rendered
);
}

#[tokio::test]
async fn cli_runtime_guide_topics_are_completable_and_stay_in_sync_across_add_guides_calls() {
let mut cli = Cli::new(CliConfig {
name: "my-cli".to_owned(),
short: "Developer tooling".to_owned(),
..CliConfig::default()
});
cli.add_guides([GuideEntry {
name: "deploy".to_owned(),
summary: "Deploy safely".to_owned(),
content: "# Deploy\n".to_owned(),
}]);
// A second, later call (e.g. a module contributing its own guides) must
// also be reflected — not just the first guide-entries transition.
cli.add_guides([GuideEntry {
name: "rollback".to_owned(),
summary: "Roll back safely".to_owned(),
content: "# Rollback\n".to_owned(),
}]);

let script = cli.run(["my-cli", "completion", "bash"]).await;
assert_eq!(script.exit_code, 0, "{}", script.rendered);
for topic in ["deploy", "rollback"] {
assert!(
script.rendered.contains(topic),
"generated script should list {topic} as a completable guide topic; got: {}",
script.rendered
);
}

// Both topics still resolve correctly through the normal dispatch path.
let rollback = cli
.run(["my-cli", "guide", "rollback", "--output", "json"])
.await;
assert_eq!(rollback.exit_code, 0, "{}", rollback.rendered);
assert_eq!(rollback.rendered, "# Rollback\n");
}

#[tokio::test]
async fn cli_runtime_guide_command_rejects_extra_args_preserves_parser_maximum_one_arg() {
let mut cli = Cli::new(CliConfig {
Expand Down Expand Up @@ -10143,7 +10186,7 @@ fn tree_node_json_shape_and_human_rendering_match_source_contract() {
.iter()
.map(|child| child.name.as_str())
.collect::<Vec<_>>(),
vec!["visible"]
vec!["visible", "completion"]
);
}

Expand Down