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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ url = { version = "2.5.4", optional = true }
zeroize = { version = "1.8", optional = true, features = ["derive"] }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5.53", features = ["derive", "std", "string"] }
clap_complete = "4"
jmespath = "0.5.0"
reqwest = { version = "0.12.24", default-features = false, features = ["json", "multipart", "rustls-tls"] }
regex = "1.12.2"
Expand Down
49 changes: 49 additions & 0 deletions docs/completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Shell Completion

`cli-engine` provides a built-in `completion` command that enables tab-completion for your CLI.
This completion is automatically generated from your CLI's command tree using `clap_complete`, ensuring it stays in sync with your commands, flags, and arguments.

## Usage

The `completion` command is a reserved built-in, similar to `help`, `tree`, and `guide`.
It cannot be overridden by consumer-defined commands.

### Generate Completion Script

To print the completion script for a specific shell to stdout:

```bash
<bin> completion [bash|zsh|fish|elvish|powershell]
```

### Install Completion

To automatically install the completion script and configure your shell, use the `--install` flag:

```bash
<bin> completion --install [bash|zsh|fish|elvish|powershell]
```

This command is **idempotent**. Re-running it replaces any existing managed completion block in your shell configuration. There is no `--uninstall` flag; completion scripts can be removed by deleting the managed block from your shell configuration file.

## Shell Install Locations

The completion command manages the installation by appending a block to your shell configuration file. This block is wrapped in managed markers so you can identify and edit it if needed:

`# >>> <bin> completion (managed) >>>`
`# <<< <bin> completion (managed) <<<`

| Shell | Script Location | Shell Configuration File |
| --- | --- | --- |
| **bash** | `$XDG_DATA_HOME/bash-completion/completions/<bin>` | `~/.bashrc` |
| **zsh** | `~/.zfunc/_<bin>` | `~/.zshrc` |
| **fish** | `$XDG_CONFIG_HOME/fish/completions/<bin>.fish` | None (auto-loaded) |
| **elvish** | `$XDG_CONFIG_HOME/elvish/lib/<bin>-completion.elv` | `$XDG_CONFIG_HOME/elvish/rc.elv` |
| **powershell** | `~/Documents/PowerShell/<bin>-completion.ps1` | `$PROFILE` |

### Notes

- **bash**: Adds a managed `source "<script_path>"` line to `~/.bashrc` that sources the generated completion script directly.
- **zsh**: Adds the script directory to your `fpath` and calls `autoload -Uz compinit && compinit`.
- **fish**: Files placed in the completions directory are auto-loaded by fish; no shell configuration edit is required.
- **powershell**: Adds the dot-source command to your PowerShell profile.
108 changes: 103 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::{
};

mod builtins;
mod completion;
mod help;
mod tree_render;

Expand Down Expand Up @@ -38,7 +39,9 @@ use crate::{
search::{SearchDocument, SearchIndex},
};

use builtins::{guide_args, guide_command, help_args, help_command};
use builtins::{
completion_args, completion_command, guide_args, guide_command, help_args, help_command,
};
use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};

Expand Down Expand Up @@ -181,6 +184,11 @@ pub enum Argv0LinkMethod {
Script,
}

/// Top-level subcommand names that are reserved by the engine and must not be
/// used as module group names. [`Cli::add_module_group`] rejects a group whose
/// name matches a reserved name so the engine's built-in command always wins.
pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 4] = ["help", "guide", "tree", "completion"];

/// Declarative configuration for a CLI application.
///
/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for
Expand Down Expand Up @@ -398,13 +406,22 @@ impl CliConfig {
}

/// Adds one domain module.
///
/// # Reserved group names
///
/// The top-level group names `help`, `guide`, `tree`, and `completion` are
/// reserved by the engine. A module whose root group uses one of these
/// names will be rejected at registration time (logged as a warning) so
/// the engine's own built-in always takes precedence in the command tree.
#[must_use]
pub fn with_module(mut self, module: Module) -> Self {
self.modules.push(module);
self
}

/// Adds several domain modules.
///
/// See [`with_module`](Self::with_module) for the list of reserved group names.
#[must_use]
pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
self.modules.extend(modules);
Expand Down Expand Up @@ -792,7 +809,8 @@ impl Cli {
root = register_global_flags(root)
.subcommand(help_command())
.subcommand(guide_command())
.subcommand(Command::new("tree").about("Display full command tree"));
.subcommand(Command::new("tree").about("Display full command tree"))
.subcommand(completion_command());
if let Some(register_flags) = &config.register_flags {
root = register_flags(root);
}
Expand Down Expand Up @@ -1020,6 +1038,16 @@ impl Cli {
category: impl Into<String>,
group: RuntimeGroupSpec,
) -> &mut Self {
// Prevent consumer modules from shadowing engine built-ins in the clap
// command tree. A reserved group name would override the engine's own
// subcommand (last-writer-wins in clap) and corrupt the dispatch path.
if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
tracing::warn!(
name = %group.group.name,
"module group name is reserved by cli-engine built-ins; the group will not be registered"
);
return self;
}
let category = category.into();
if !group.group.hidden {
self.module_entries.push(ModuleHelpEntry {
Expand Down Expand Up @@ -1471,6 +1499,51 @@ impl Cli {
}
return self.finish_run(self.render_guide(&matches));
}
if command_path == "completion" {
let args = completion_args(&matches);
if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let install = args
.get("install")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let shell_opt = args
.get("shell")
.and_then(|v| v.as_str())
.map(str::to_owned);
if install {
use crate::cli::completion::{detect_shell, parse_shell};
let shell = match shell_opt {
Some(ref s) => match parse_shell(s) {
Ok(s) => s,
Err(e) => {
return self.finish_run(render_cli_error(
&middleware,
&e,
&self.config.app_id,
));
}
},
None => match detect_shell() {
Ok(s) => s,
Err(e) => {
return self.finish_run(render_cli_error(
&middleware,
&e,
&self.config.app_id,
));
}
},
};
return self.finish_run(
completion::install(&self.root, &self.config.name, shell)
.await
.unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
);
}
return self.finish_run(self.render_completion_print(shell_opt, &middleware));
}
let Some(command) = self.commands.get(&command_path) else {
if !command_path.is_empty()
&& let Some(group) = find_command_by_colon_path(&self.root, &command_path)
Expand Down Expand Up @@ -1825,6 +1898,31 @@ impl Cli {
}
}

fn render_completion_print(
&self,
shell_opt: Option<String>,
middleware: &Middleware,
) -> CliRunOutput {
use crate::cli::completion::{detect_shell, generate_script, parse_shell};
let shell = match shell_opt {
Some(s) => match parse_shell(&s) {
Ok(s) => s,
Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
},
None => match detect_shell() {
Ok(s) => s,
Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
},
};
match generate_script(&self.root, &self.config.name, shell) {
Ok(script) => CliRunOutput {
exit_code: 0,
rendered: script,
},
Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
}
}

fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
let leaf = leaf_matches(matches);
let parts = leaf
Expand Down Expand Up @@ -1868,7 +1966,7 @@ impl Cli {
// neither categorized nor an engine built-in, listed under a generic
// "Commands" section. This keeps every command discoverable once clap's
// auto subcommand list is suppressed by the root help template.
const BUILTINS: [&str; 4] = ["help", "guide", "tree", "completion"];
let builtins = BUILTIN_COMMAND_NAMES;
let categorized: BTreeSet<&str> = self
.module_entries
.iter()
Expand All @@ -1878,7 +1976,7 @@ impl Cli {
.root
.get_subcommands()
.filter(|command| !command.is_hide_set())
.filter(|command| !BUILTINS.contains(&command.get_name()))
.filter(|command| !builtins.contains(&command.get_name()))
.filter(|command| !categorized.contains(command.get_name()))
.map(|command| ModuleHelpEntry {
category: "Commands".to_owned(),
Expand Down Expand Up @@ -2331,7 +2429,7 @@ fn collect_command_search_documents(
aliases: &mut Vec<String>,
docs: &mut Vec<SearchDocument>,
) {
if command.is_hide_set() || command.get_name() == "completion" {
if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
return;
}
if command.get_subcommands().next().is_some() {
Expand Down
61 changes: 60 additions & 1 deletion src/cli/builtins.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use clap::{Arg, ArgMatches, Command};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::Value;

use crate::{
Expand Down Expand Up @@ -38,3 +38,62 @@ pub(crate) fn guide_args(matches: &ArgMatches) -> ValueMap {
value_map([("topic", Value::String(topic.clone()))])
})
}

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("install")
.long("install")
.action(ArgAction::SetTrue)
.help("Install completion script into shell config"),
)
}

pub(crate) fn completion_args(matches: &ArgMatches) -> ValueMap {
let leaf = leaf_matches(matches);
let shell = leaf.get_one::<String>("shell").cloned();
let install = leaf.get_flag("install");
let mut map = value_map([("install", Value::Bool(install))]);
if let Some(s) = shell {
map.insert("shell".to_owned(), Value::String(s));
}
map
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

#[test]
fn completion_command_parses_shell() {
let m = completion_command()
.try_get_matches_from(["completion", "zsh"])
.unwrap();
let leaf = leaf_matches(&m);
assert_eq!(
leaf.get_one::<String>("shell").map(String::as_str),
Some("zsh")
);
}

#[test]
fn completion_command_parses_install() {
let m = completion_command()
.try_get_matches_from(["completion", "--install"])
.unwrap();
let leaf = leaf_matches(&m);
assert!(leaf.get_flag("install"));
}

#[test]
fn completion_command_rejects_unknown_flag() {
assert!(
completion_command()
.try_get_matches_from(["completion", "--bogusflag"])
.is_err()
);
}
}
Loading