-
Notifications
You must be signed in to change notification settings - Fork 267
feat(cli): add shell completion scaffolding #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
b4d6b81
25562c4
6a17694
81cb641
be16e95
55124bd
f8751d8
d13bc78
205223d
08be532
eb4bbaf
6145167
08622c1
72d8e69
8f196ab
5768eed
2fa21b3
a9078f3
8eb8c91
fc358d3
5a03bb8
af6c267
a13c510
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| use anyhow::Context; | ||
| use anyhow::Result; | ||
| use clap::Args as ClapArgs; | ||
| use clap::CommandFactory; | ||
| use clap::ValueEnum; | ||
| use clap_complete::Shell as ClapShell; | ||
| use std::io::Write; | ||
|
|
||
| /// Shell to generate completion for. | ||
| /// | ||
| #[derive(Copy, Clone, Debug, ValueEnum)] | ||
| pub enum Shell { | ||
| Bash, | ||
| Zsh, | ||
| Fish, | ||
| } | ||
|
|
||
| impl From<Shell> for ClapShell { | ||
| fn from(shell: Shell) -> Self { | ||
| match shell { | ||
| Shell::Bash => ClapShell::Bash, | ||
| Shell::Zsh => ClapShell::Zsh, | ||
| Shell::Fish => ClapShell::Fish, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[derive(ClapArgs)] | ||
| pub struct Args { | ||
| /// Shell to generate completion for. | ||
| #[arg(value_enum)] | ||
| pub shell: Shell, | ||
| } | ||
|
|
||
| /// Generate a completion script for the requested shell and write it to stdout. | ||
| pub fn run(args: Args) -> Result<()> { | ||
| write_completion(args.shell, &mut std::io::stdout().lock()) | ||
| } | ||
|
|
||
| /// Generate the completion script for `shell` and write it to `out`. | ||
| /// | ||
| /// Generation goes through an in-memory buffer first: clap_complete's | ||
| /// generators panic on write errors (`Generator::generate` calls `.expect`), | ||
| /// so writing straight to `out` would turn a closed downstream pipe into a | ||
| /// panic. The buffer cannot fail, so generation is infallible; only the | ||
| /// explicit write below can. A `BrokenPipe` there (e.g. `aenv completion bash | ||
| /// | head`) is normal and treated as success; any other error propagates with | ||
| /// context. Split out from `run` so the write branches are unit-testable. | ||
| fn write_completion<W: Write>(shell: Shell, out: &mut W) -> Result<()> { | ||
| let mut cmd = crate::Cli::command(); | ||
|
rbalachandar marked this conversation as resolved.
|
||
| let mut script = Vec::new(); | ||
| clap_complete::generate(ClapShell::from(shell), &mut cmd, "aenv", &mut script); | ||
| match out.write_all(&script).and_then(|_| out.flush()) { | ||
| Ok(()) => Ok(()), | ||
| Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), | ||
| Err(err) => Err(err).context("writing completion script to stdout"), | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn generate_for(shell: Shell) -> String { | ||
| let mut buf = Vec::new(); | ||
| write_completion(shell, &mut buf).expect("writing to a Vec cannot fail"); | ||
| String::from_utf8(buf).expect("completion output is valid UTF-8") | ||
| } | ||
|
|
||
| /// Writer that always fails with a configured error kind, for exercising | ||
| /// `write_completion`'s error branches. | ||
| struct FailingWriter { | ||
| kind: std::io::ErrorKind, | ||
| fail_on_flush: bool, | ||
|
rbalachandar marked this conversation as resolved.
|
||
| } | ||
|
|
||
| impl std::io::Write for FailingWriter { | ||
| fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| if self.fail_on_flush { | ||
| Ok(buf.len()) | ||
| } else { | ||
| Err(std::io::Error::from(self.kind)) | ||
| } | ||
| } | ||
|
|
||
| fn flush(&mut self) -> std::io::Result<()> { | ||
| Err(std::io::Error::from(self.kind)) | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn bash_has_compdef_or_complete_f() { | ||
| let s = generate_for(Shell::Bash); | ||
| assert!( | ||
| s.contains("complete -F") || s.contains("compdef"), | ||
| "bash output should register the binary; got:\n{s}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn zsh_has_compdef_header() { | ||
| let s = generate_for(Shell::Zsh); | ||
| assert!( | ||
| s.starts_with("#compdef"), | ||
| "zsh output should start with a #compdef header; got:\n{s}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fish_has_complete_calls() { | ||
| let s = generate_for(Shell::Fish); | ||
| assert!( | ||
| s.contains("complete "), | ||
| "fish output should contain `complete` invocations; got:\n{s}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn connect_exposes_cn_alias() { | ||
| // Assert on the Command tree, not on clap_complete's generated bash | ||
| // dispatch format: the internal `aenv,cn)` / `__subcmd__` naming is an | ||
| // implementation detail a compatible clap_complete upgrade could rename | ||
| // even though completion still works. If `connect` declares `cn` as a | ||
| // visible alias, clap_complete emits it — that contract is ours. | ||
| let cmd = crate::Cli::command(); | ||
| let connect = cmd | ||
| .find_subcommand("connect") | ||
| .expect("`connect` command exists"); | ||
| assert!( | ||
| connect.get_visible_aliases().any(|a| a == "cn"), | ||
| "`connect` should declare `cn` as a visible alias" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_exposes_create_subcommand() { | ||
| // See `connect_exposes_cn_alias`: assert on the Command tree, not on | ||
| // clap_complete's internal bash helper naming. | ||
| let cmd = crate::Cli::command(); | ||
| let snapshot = cmd | ||
| .find_subcommand("snapshot") | ||
| .expect("`snapshot` command exists"); | ||
| assert!( | ||
| snapshot.find_subcommand("create").is_some(), | ||
| "`snapshot` should expose a `create` subcommand" | ||
| ); | ||
|
rbalachandar marked this conversation as resolved.
rbalachandar marked this conversation as resolved.
rbalachandar marked this conversation as resolved.
|
||
| } | ||
|
|
||
| #[test] | ||
| fn output_arg_offers_table_and_json() { | ||
| // Assert on the argument metadata: the exact `compgen -W "table json"` | ||
| // string is clap_complete's bash formatting, which a compatible upgrade | ||
| // could change. The possible values are defined on the `--output` arg. | ||
| let cmd = crate::Cli::command(); | ||
| let list = cmd.find_subcommand("list").expect("`list` command exists"); | ||
| let output = list | ||
| .get_arguments() | ||
| .find(|a| a.get_long() == Some("output")) | ||
| .expect("`list` should declare an --output argument"); | ||
| let possible = output.get_possible_values(); | ||
| let names: Vec<&str> = possible.iter().map(|v| v.get_name()).collect(); | ||
| assert!( | ||
| names.contains(&"table") && names.contains(&"json"), | ||
| "--output should offer table and json; got {names:?}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn write_completion_succeeds_into_buffer() { | ||
| let mut buf: Vec<u8> = Vec::new(); | ||
| write_completion(Shell::Bash, &mut buf).expect("Vec write succeeds"); | ||
| assert!( | ||
| !buf.is_empty(), | ||
| "a completion script should have been written" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn broken_pipe_is_treated_as_success() { | ||
| // `aenv completion bash | head` closes the pipe early; that must exit | ||
| // cleanly rather than error or panic. | ||
| let mut out = FailingWriter { | ||
| kind: std::io::ErrorKind::BrokenPipe, | ||
| fail_on_flush: false, | ||
| }; | ||
| write_completion(Shell::Bash, &mut out) | ||
| .expect("BrokenPipe during completion output should not error"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn other_io_error_propagates() { | ||
| let mut out = FailingWriter { | ||
| kind: std::io::ErrorKind::Other, | ||
| fail_on_flush: false, | ||
| }; | ||
| let err = write_completion(Shell::Bash, &mut out) | ||
| .expect_err("non-BrokenPipe I/O errors should propagate"); | ||
| assert!( | ||
| err.to_string() | ||
| .contains("writing completion script to stdout"), | ||
| "error should carry completion context; got: {err}" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| pub mod auth; | ||
| pub mod build; | ||
| pub mod completion; | ||
| pub mod connect; | ||
| pub mod delete; | ||
| pub mod download; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -274,3 +274,52 @@ aenv snapshot list --sandbox-id <sandbox-id> | |
| The table output includes an `IMAGE REF` column (`-` when no image was published); JSON output includes the optional `imageRef` field. | ||
|
|
||
| To delete a snapshot, use `aenv template delete <snapshot-id>` or `aenv template delete <name>` — snapshots share the same underlying store as templates and are deleted through the same command. | ||
|
|
||
| --- | ||
|
|
||
| ## Shell completion | ||
|
|
||
| `aenv completion <shell>` prints a shell-completion script for the `aenv` CLI to stdout. | ||
|
|
||
| ### Generate a script | ||
|
|
||
| ```bash | ||
| aenv completion bash | ||
| aenv completion zsh | ||
| aenv completion fish | ||
| ``` | ||
|
|
||
| Each command writes the matching script to stdout, so redirect it into the standard per-user completion directory for your shell: | ||
|
|
||
| ```bash | ||
| aenv completion bash > ~/.local/share/bash-completion/completions/aenv | ||
| aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv | ||
| aenv completion fish > ~/.config/fish/completions/aenv.fish | ||
| ``` | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The redirect examples assume that all parent directories already exist. On a fresh environment, commands such as: aenv completion bash > ~/.local/share/bash-completion/completions/aenvfail with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit a13c510: each Bash, Zsh, and Fish example now creates its parent directory with mkdir -p before redirecting the generated completion file. |
||
|
|
||
| bash and fish auto-load completions from those directories. zsh autoloads `_cmdname` functions from directories on `fpath`; `~/.local/share/zsh/site-functions` is not on `fpath` by default, so if completion does not load, add the directory to `fpath` before `compinit`: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, “bash auto-loads completions from this directory” is slightly misleading: this behavior is provided by
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit a13c510: the Bash section now explains that loading is provided by bash-completion and requires bash-completion to be installed and initialized. |
||
|
|
||
| ```bash | ||
| fpath=(~/.local/share/zsh/site-functions $fpath) | ||
| autoload -Uz compinit | ||
| compinit | ||
| ``` | ||
|
|
||
| ### Activate it | ||
|
|
||
| For a one-session test, evaluate the script in the current shell: | ||
|
|
||
| ```bash | ||
| source <(aenv completion bash) # bash | ||
| eval "$(aenv completion zsh)" # zsh | ||
| aenv completion fish | source # fish | ||
| ``` | ||
|
|
||
| Once loaded, completion covers the CLI surface: | ||
|
|
||
| ```bash | ||
| aenv <TAB> # top-level commands | ||
| aenv snapshot <TAB> # nested subcommands (create, list, ...) | ||
| aenv list --output <TAB> # enum values: table, json | ||
| aenv build ./<TAB> # local path arguments | ||
| ``` | ||
Uh oh!
There was an error while loading. Please reload this page.