use color_eyre::eyre::Result;
struct Sub {
name: &'static str,
desc: &'static str,
args: &'static [&'static str],
}
const GLOBALS: &[&str] = &[
"-V",
"--version",
"-h",
"--help",
"--read-only",
"--demo",
"--control-socket",
];
const SUBS: &[Sub] = &[
Sub {
name: "envs",
desc: "List environments in the current profile / region",
args: &["--json"],
},
Sub {
name: "lint",
desc: "Run the diagnostic rule engine against one env or the fleet",
args: &[
"--env",
"--regions",
"--json",
"--severity",
"--rules",
"--quiet",
"--fix",
"--yes",
"--dry-run",
"--watch",
"--interval",
"--webhook",
"--probe-live",
"--baseline",
"--against-baseline",
],
},
Sub {
name: "drift",
desc: "Terraform drift report against live EB state",
args: &[
"--env",
"--regions",
"--tfstate",
"--tfdir",
"--json",
"--quiet",
"--no-redact",
],
},
Sub {
name: "action",
desc: "Run rebuild/restart/terminate/deploy/rollout on an env",
args: &[
"rebuild",
"restart",
"terminate",
"deploy",
"rollout",
"--env",
"--yes",
"--version",
"--wait-for-green",
"--auto-rollback",
"--regions",
"--profile",
"--parallel",
"--max-concurrency",
"--continue-on-fail",
"--staggered",
"--json",
],
},
Sub {
name: "ctl",
desc: "Talk to a running ebman via --control-socket",
args: &["screen", "key", "cmd", "state", "reload", "--socket"],
},
Sub {
name: "audit",
desc: "Read the local audit log (or `replay` a line)",
args: &[
"replay", "--tail", "--since", "--env", "--rule", "--action", "--json", "--yes",
],
},
Sub {
name: "mcp",
desc: "Stdio MCP server exposing the fleet to coding agents",
args: &["serve", "setup", "--demo", "--no-redact", "--allow-writes"],
},
Sub {
name: "explain",
desc: "LLM-backed explanation of a lint issue",
args: &["--env", "--json", "--dry-run", "--no-cache"],
},
Sub {
name: "versions",
desc: "List application versions for an env's app",
args: &["--env", "--json"],
},
Sub {
name: "completions",
desc: "Emit a shell completion script",
args: &["bash", "zsh", "fish"],
},
];
fn render_bash() -> String {
let subnames: Vec<&str> = SUBS.iter().map(|c| c.name).collect();
let mut s = String::new();
s.push_str("# bash completion for ebman — generated by `ebman completions bash`.\n");
s.push_str(
"# Install: ebman completions bash > ~/.local/share/bash-completion/completions/ebman\n",
);
s.push_str("# or: ebman completions bash | sudo tee /etc/bash_completion.d/ebman\n");
s.push_str("_ebman() {\n");
s.push_str(" local cur\n");
s.push_str(" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
s.push_str(&format!(" local subcmds=\"{}\"\n", subnames.join(" ")));
s.push_str(&format!(" local globals=\"{}\"\n", GLOBALS.join(" ")));
s.push_str(" if [[ $COMP_CWORD -eq 1 ]]; then\n");
s.push_str(" if [[ \"$cur\" == -* ]]; then\n");
s.push_str(" COMPREPLY=( $(compgen -W \"$globals\" -- \"$cur\") )\n");
s.push_str(" else\n");
s.push_str(" COMPREPLY=( $(compgen -W \"$subcmds\" -- \"$cur\") )\n");
s.push_str(" fi\n");
s.push_str(" return\n");
s.push_str(" fi\n");
s.push_str(" case \"${COMP_WORDS[1]}\" in\n");
for c in SUBS {
s.push_str(&format!(" {})\n", c.name));
s.push_str(&format!(
" COMPREPLY=( $(compgen -W \"{}\" -- \"$cur\") ) ;;\n",
c.args.join(" ")
));
}
s.push_str(" esac\n");
s.push_str("}\n");
s.push_str("complete -F _ebman ebman\n");
s
}
fn render_zsh() -> String {
let subnames: Vec<&str> = SUBS.iter().map(|c| c.name).collect();
let mut s = String::new();
s.push_str("#compdef ebman\n");
s.push_str("# zsh completion for ebman — generated by `ebman completions zsh`.\n");
s.push_str("# Install: ebman completions zsh > \"${fpath[1]}/_ebman\" (then restart zsh)\n");
s.push_str("local -a subcmds globals args\n");
s.push_str(&format!("subcmds=({})\n", subnames.join(" ")));
s.push_str(&format!("globals=({})\n", GLOBALS.join(" ")));
s.push_str("if (( CURRENT == 2 )); then\n");
s.push_str(" if [[ ${words[CURRENT]} == -* ]]; then\n");
s.push_str(" compadd -- $globals\n");
s.push_str(" else\n");
s.push_str(" compadd -- $subcmds\n");
s.push_str(" fi\n");
s.push_str(" return\n");
s.push_str("fi\n");
s.push_str("case ${words[2]} in\n");
for c in SUBS {
s.push_str(&format!(" {}) args=({}) ;;\n", c.name, c.args.join(" ")));
}
s.push_str("esac\n");
s.push_str("compadd -- $args\n");
s
}
fn fish_token(tok: &str) -> String {
if let Some(long) = tok.strip_prefix("--") {
format!("-l {long}")
} else if let Some(short) = tok.strip_prefix('-') {
format!("-s {short}")
} else {
format!("-a {tok}")
}
}
fn fish_esc(desc: &str) -> String {
desc.replace('\\', "\\\\").replace('\'', "\\'")
}
fn render_fish() -> String {
let mut s = String::new();
s.push_str("# fish completion for ebman — generated by `ebman completions fish`.\n");
s.push_str("# Install: ebman completions fish > ~/.config/fish/completions/ebman.fish\n");
for c in SUBS {
s.push_str(&format!(
"complete -c ebman -n __fish_use_subcommand -a {} -d '{}'\n",
c.name,
fish_esc(c.desc)
));
}
for g in GLOBALS {
s.push_str(&format!(
"complete -c ebman -n __fish_use_subcommand {}\n",
fish_token(g)
));
}
for c in SUBS {
for a in c.args {
s.push_str(&format!(
"complete -c ebman -n '__fish_seen_subcommand_from {}' {}\n",
c.name,
fish_token(a)
));
}
}
s
}
fn usage() -> ! {
eprintln!(
"ebman completions <bash|zsh|fish>\n\n\
Emit a shell completion script to stdout. Examples:\n \
ebman completions zsh > \"${{fpath[1]}}/_ebman\"\n \
ebman completions bash > ~/.local/share/bash-completion/completions/ebman\n \
ebman completions fish > ~/.config/fish/completions/ebman.fish"
);
std::process::exit(2);
}
pub async fn run(args: &[String]) -> Result<()> {
let script = match args.get(1).map(String::as_str) {
Some("bash") => render_bash(),
Some("zsh") => render_zsh(),
Some("fish") => render_fish(),
Some(other) => {
eprintln!("ebman completions: unknown shell '{other}' (expected: bash | zsh | fish)");
std::process::exit(2);
}
None => usage(),
};
print!("{script}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subs_names_match_the_canonical_cli_subcommand_list() {
use std::collections::BTreeSet;
let subs: BTreeSet<&str> = SUBS.iter().map(|c| c.name).collect();
let canonical: BTreeSet<&str> = crate::cli::SUBCOMMANDS.iter().copied().collect();
assert_eq!(
subs, canonical,
"completions SUBS drifted from cli::SUBCOMMANDS"
);
}
#[test]
fn every_shell_lists_all_subcommands() {
for (shell, script) in [
("bash", render_bash()),
("zsh", render_zsh()),
("fish", render_fish()),
] {
assert!(!script.is_empty(), "{shell} script must not be empty");
for c in SUBS {
assert!(
script.contains(c.name),
"{shell} script is missing subcommand '{}'",
c.name
);
}
}
}
#[test]
fn scripts_carry_their_registration_hook() {
assert!(render_bash().contains("complete -F _ebman ebman"));
assert!(render_zsh().starts_with("#compdef ebman"));
assert!(render_fish().contains("complete -c ebman -n __fish_use_subcommand -a envs"));
}
#[test]
fn per_subcommand_flags_render() {
let bash = render_bash();
assert!(bash.contains("--allow-writes")); assert!(bash.contains("--against-baseline")); let zsh = render_zsh();
assert!(zsh.contains("rollout")); assert!(zsh.contains("--tfstate")); }
#[test]
fn fish_token_classifies_long_short_and_positional() {
assert_eq!(fish_token("--json"), "-l json");
assert_eq!(fish_token("-V"), "-s V");
assert_eq!(fish_token("serve"), "-a serve");
}
#[test]
fn globals_and_subcommands_are_disjoint_nonempty() {
assert!(!GLOBALS.is_empty());
assert!(!SUBS.is_empty());
for c in SUBS {
assert!(
!GLOBALS.contains(&c.name),
"'{}' is both a subcommand and a global",
c.name
);
}
}
}