Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4d6b81
feat(cli): add shell completion scaffolding
rbalachandar Jul 30, 2026
25562c4
fix(cli): harden completion output and tests
rbalachandar Jul 30, 2026
6a17694
Update crates/aenv/src/commands/completion.rs
rbalachandar Jul 30, 2026
81cb641
test(cli): validate completion features via Cli metadata
rbalachandar Jul 30, 2026
be16e95
test(cli): cover completion write branches and add error context
rbalachandar Jul 30, 2026
55124bd
docs(cli): document shell completion
rbalachandar Jul 31, 2026
f8751d8
docs(cli): correct shell-completion install paths
rbalachandar Aug 1, 2026
d13bc78
feat(cli): install shell-completion loaders via the installers
rbalachandar Aug 1, 2026
205223d
Update .github/workflows/ci.yml
rbalachandar Aug 1, 2026
08be532
Update .github/workflows/ci.yml
rbalachandar Aug 1, 2026
eb4bbaf
fix(cli): harden shell-completion installer per review
rbalachandar Aug 1, 2026
6145167
Update scripts/tests/verify-shell-completion.sh
rbalachandar Aug 1, 2026
08622c1
Update scripts/tests/verify-shell-completion.sh
rbalachandar Aug 1, 2026
72d8e69
fix(cli): address second round of review on completion installers
rbalachandar Aug 2, 2026
8f196ab
refactor(cli): unify completion writes through one atomic-commit prim…
rbalachandar Aug 2, 2026
5768eed
Update crates/aenv/src/commands/completion.rs
rbalachandar Aug 2, 2026
2fa21b3
Update scripts/tests/verify-shell-completion.sh
rbalachandar Aug 2, 2026
a9078f3
Update scripts/tests/verify-shell-completion.sh
rbalachandar Aug 2, 2026
8eb8c91
fix(cli): propagate ownership-marker/upgrade rewrite + fix #compdef +…
rbalachandar Aug 2, 2026
fc358d3
fix(cli): round 5 — compile fix, portable resolve/chown, install-side…
rbalachandar Aug 2, 2026
5a03bb8
refactor(cli): split automatic completion installation
rbalachandar Aug 3, 2026
af6c267
test(cli): cover completion flush errors
rbalachandar Aug 3, 2026
a13c510
docs(cli): clarify manual completion activation
rbalachandar Aug 5, 2026
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
14 changes: 12 additions & 2 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 crates/aenv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ anyhow = "1"
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
Comment thread
rbalachandar marked this conversation as resolved.
crossterm = "0.28"
directories = "5"
envd = { path = "../../thirdparty/envd" }
Expand Down
229 changes: 229 additions & 0 deletions crates/aenv/src/commands/completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
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();
Comment thread
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,
Comment thread
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"
);
Comment thread
rbalachandar marked this conversation as resolved.
Comment thread
rbalachandar marked this conversation as resolved.
Comment thread
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 broken_pipe_on_flush_is_treated_as_success() {
let mut out = FailingWriter {
kind: std::io::ErrorKind::BrokenPipe,
fail_on_flush: true,
};
write_completion(Shell::Bash, &mut out)
.expect("BrokenPipe during completion flush 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}"
);
}

#[test]
fn other_io_error_on_flush_propagates() {
let mut out = FailingWriter {
kind: std::io::ErrorKind::Other,
fail_on_flush: true,
};
let err =
write_completion(Shell::Bash, &mut out).expect_err("flush errors should propagate");
assert!(
err.to_string()
.contains("writing completion script to stdout"),
"error should carry completion context; got: {err}"
);
}
}
1 change: 1 addition & 0 deletions crates/aenv/src/commands/mod.rs
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;
Expand Down
2 changes: 1 addition & 1 deletion crates/aenv/src/commands/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ enum Sub {
name: Option<String>,
},
/// List persistent snapshots
#[command(alias = "ls")]
#[command(visible_alias = "ls")]
List {
/// Filter snapshots by source sandbox ID
#[arg(long = "sandbox-id")]
Expand Down
4 changes: 2 additions & 2 deletions crates/aenv/src/commands/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ pub struct Args {
#[derive(Subcommand)]
enum Sub {
/// List all templates
#[command(alias = "ls")]
#[command(visible_alias = "ls")]
List {
#[arg(long, value_enum)]
output: Option<Format>,
},
/// Delete a template by ID or name
#[command(alias = "rm")]
#[command(visible_alias = "rm")]
Delete { template: String },
/// Watch a template build until it succeeds or fails
Watch { template: String },
Expand Down
13 changes: 8 additions & 5 deletions crates/aenv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,28 @@ enum Cmd {
Upload(commands::upload::Args),
/// Download a file from a sandbox
Download(commands::download::Args),
/// Generate shell completion scripts
Completion(commands::completion::Args),
/// Attach an interactive shell to a running sandbox
#[command(alias = "cn")]
#[command(visible_alias = "cn")]
Connect(commands::connect::Args),
/// Pause a running sandbox
Pause(commands::pause::Args),
/// Resume a paused sandbox
Resume(commands::resume::Args),
/// List sandboxes
#[command(alias = "ls")]
#[command(visible_alias = "ls")]
List(commands::list::Args),
/// Kill a sandbox
#[command(alias = "rm")]
#[command(visible_alias = "rm")]
Delete(commands::delete::Args),
/// Set the sandbox expiration (seconds from now)
Timeout(commands::timeout::Args),
/// Snapshot operations
#[command(alias = "snap")]
#[command(visible_alias = "snap")]
Snapshot(commands::snapshot::Args),
/// Template operations
#[command(alias = "templates")]
#[command(visible_alias = "templates")]
Template(commands::template::Args),
}

Expand All @@ -66,6 +68,7 @@ fn main() -> Result<()> {
Cmd::Exec(a) => commands::exec::run(a),
Cmd::Upload(a) => commands::upload::run(a),
Cmd::Download(a) => commands::download::run(a),
Cmd::Completion(a) => commands::completion::run(a),
Cmd::Connect(a) => commands::connect::run(a),
Cmd::Pause(a) => commands::pause::run(a),
Cmd::Resume(a) => commands::resume::run(a),
Expand Down
Loading
Loading