diff --git a/CHANGELOG.md b/CHANGELOG.md index e620e53..932e50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ --- -latest_version: 3.2.18 +latest_version: 3.2.19 released: 2026-05-29 --- @@ -12,6 +12,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [3.2.19] — 2026-05-29 — shell completions + +- feat(cli): `onebrain completions ` — hidden subcommand emitting a shell + completion script (bash · zsh · fish · powershell · elvish) via clap_complete. +- feat(cli): optional shell-aware hint after interactive `onebrain init` (detects + `$SHELL`); enables Homebrew formula completion auto-install (tap PR follows). + ## [3.2.18] — 2026-05-29 — dependency + size cleanup (reqwest→ureq · serde_yaml_ng · async-stack drop) - **Perf/size: `reqwest` → `ureq` (blocking sync HTTP).** The 4 GitHub/tarball fetch sites (`update` · `vault-sync`) now use `ureq`, which carries no async runtime. This removes the entire async stack — `tokio` · `hyper` · `h2` · `tower` · `tower-http` · `hyper-rustls` · `tokio-rustls` · `hyper-util` — from the release binary (all pulled in only by reqwest). TLS stays rustls. Result: **−342 KB** binary (3.34 → 3.01 MB) · **−54 crates** (178 → 124) · **~12% faster clean build** (41.0s → 36.2s). Runtime of everyday commands is unchanged — they never made HTTP calls, and these fetch paths are network-bound. diff --git a/Cargo.lock b/Cargo.lock index e82cf84..64cf745 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -988,7 +997,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onebrain-cache" -version = "3.2.18" +version = "3.2.19" dependencies = [ "chrono", "onebrain-core", @@ -1001,12 +1010,13 @@ dependencies = [ [[package]] name = "onebrain-cli" -version = "3.2.18" +version = "3.2.19" dependencies = [ "anyhow", "assert_cmd", "chrono", "clap", + "clap_complete", "dirs", "flate2", "indicatif", @@ -1029,7 +1039,7 @@ dependencies = [ [[package]] name = "onebrain-core" -version = "3.2.18" +version = "3.2.19" dependencies = [ "chrono", "indexmap", @@ -1045,7 +1055,7 @@ dependencies = [ [[package]] name = "onebrain-fs" -version = "3.2.18" +version = "3.2.19" dependencies = [ "chrono", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 8297315..ffe5078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/*"] resolver = "2" [workspace.package] -version = "3.2.18" +version = "3.2.19" edition = "2021" license = "AGPL-3.0-only" authors = ["OneBrain Contributors"] @@ -25,6 +25,7 @@ thiserror = "1" anyhow = "1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } +clap_complete = "4" # Filesystem (Slice 1 partial · used by count_unembedded) walkdir = "2" diff --git a/crates/onebrain-cli/Cargo.toml b/crates/onebrain-cli/Cargo.toml index 38b32a9..8a680f7 100644 --- a/crates/onebrain-cli/Cargo.toml +++ b/crates/onebrain-cli/Cargo.toml @@ -16,6 +16,7 @@ onebrain-core = { workspace = true } onebrain-fs = { workspace = true } onebrain-cache = { workspace = true } clap = { workspace = true } +clap_complete = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } serde_json = { workspace = true } diff --git a/crates/onebrain-cli/src/cli.rs b/crates/onebrain-cli/src/cli.rs index 779369b..226df14 100644 --- a/crates/onebrain-cli/src/cli.rs +++ b/crates/onebrain-cli/src/cli.rs @@ -82,6 +82,10 @@ pub enum Cmd { /// Diagnose system (vault + plugin + CLI · includes harness). #[command(display_order = 3)] Doctor(DoctorArgs), + /// Generate a shell completion script (hidden · used by the Homebrew + /// formula and the post-init hint). Writes to stdout. + #[command(hide = true)] + Completions(CompletionsArgs), // ───── Resource groups (24 · alphabetical) ───────────────────────── // v3.1.0 UX: groups whose every verb still returns `E_NOT_IMPLEMENTED` @@ -202,6 +206,12 @@ pub struct DoctorArgs { pub json: bool, } +#[derive(Args, Debug, Clone)] +pub struct CompletionsArgs { + /// Target shell (bash · zsh · fish · powershell · elvish). + pub shell: clap_complete::Shell, +} + // ───────────────────────────────────────────────────────────────────────── // Resource group: avatar (forward-compat, all verbs unimplemented in v3.1) // ───────────────────────────────────────────────────────────────────────── diff --git a/crates/onebrain-cli/src/commands/completions.rs b/crates/onebrain-cli/src/commands/completions.rs new file mode 100644 index 0000000..7d96a46 --- /dev/null +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -0,0 +1,115 @@ +//! `onebrain completions ` — emit a shell completion script to stdout. +//! Hidden from `--help`; invoked by the Homebrew formula and surfaced by the +//! post-`init` hint. + +use crate::cli::Cli; +use clap::CommandFactory; +use clap::ValueEnum; +use clap_complete::{generate, Shell}; + +/// Generate the completion script for `shell` to stdout. Returns the process +/// exit code (always 0 — clap already validated `shell` into a known variant). +pub fn run(shell: Shell) -> i32 { + // `CommandFactory::command()` reconstructs the clap `Command` that the + // derive macro built for `Cli`. `generate` needs `&mut Command` because it + // finalizes the command tree (propagating help/version) before walking it. + let mut cmd = Cli::command(); + let bin = cmd.get_name().to_string(); + generate(shell, &mut cmd, bin, &mut std::io::stdout()); + 0 +} + +/// Map a login-shell path (the value of `$SHELL`, e.g. `/bin/zsh`) to a +/// `clap_complete::Shell`. Returns `None` when unset or unrecognized. +/// +/// Takes the path as an argument (rather than reading the env itself) so the +/// detection logic is unit-testable without mutating process env. +pub fn detect_login_shell_from(shell_env: Option<&str>) -> Option { + let path = shell_env?; + let name = path + .trim() + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(path) + .trim(); + // 2-arg form selects ValueEnum::from_str (ignore_case=true), NOT std FromStr (1-arg, case-sensitive). + Shell::from_str(name, true).ok() +} + +/// Build the optional "Shell completions" hint shown after interactive +/// `onebrain init`. When the login shell is detected it is dropped straight +/// into a copy-pasteable command; otherwise a placeholder + the supported +/// shell list is shown. +pub fn hint_line(detected: Option) -> String { + match detected { + Some(shell) => format!("💡 Shell completions (optional):\n onebrain completions {shell}"), + None => "💡 Shell completions (optional):\n onebrain completions \ + (bash · zsh · fish · powershell · elvish)" + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_zsh_from_login_path() { + assert_eq!(detect_login_shell_from(Some("/bin/zsh")), Some(Shell::Zsh)); + } + + #[test] + fn detects_bash_from_usr_path() { + assert_eq!( + detect_login_shell_from(Some("/usr/bin/bash")), + Some(Shell::Bash) + ); + } + + #[test] + fn returns_none_when_unset() { + assert_eq!(detect_login_shell_from(None), None); + } + + #[test] + fn returns_none_for_unknown_shell() { + assert_eq!(detect_login_shell_from(Some("/bin/tcsh")), None); + } + + #[test] + fn detects_bare_name_without_path() { + assert_eq!(detect_login_shell_from(Some("zsh")), Some(Shell::Zsh)); + } + + #[test] + fn detects_case_insensitively() { + assert_eq!(detect_login_shell_from(Some("/bin/ZSH")), Some(Shell::Zsh)); + } + + #[test] + fn tolerates_trailing_slash_and_whitespace() { + assert_eq!(detect_login_shell_from(Some("/bin/zsh/")), Some(Shell::Zsh)); + assert_eq!( + detect_login_shell_from(Some(" /bin/fish ")), + Some(Shell::Fish) + ); + } + + #[test] + fn hint_uses_detected_shell() { + let h = hint_line(Some(Shell::Zsh)); + assert!(h.contains("onebrain completions zsh"), "got: {h}"); + assert!( + !h.contains(""), + "should not show placeholder when detected" + ); + } + + #[test] + fn hint_falls_back_to_placeholder() { + let h = hint_line(None); + assert!(h.contains("onebrain completions "), "got: {h}"); + assert!(h.contains("bash"), "fallback lists shells"); + } +} diff --git a/crates/onebrain-cli/src/commands/init.rs b/crates/onebrain-cli/src/commands/init.rs index 887d0c4..803809e 100644 --- a/crates/onebrain-cli/src/commands/init.rs +++ b/crates/onebrain-cli/src/commands/init.rs @@ -43,6 +43,17 @@ pub fn run( }; let result = run_init(opts)?; + + // Optional discovery hint for non-brew installs (npm/cargo). Brew users + // already had completions auto-installed by the formula. Suppressed for + // --yes and structured/JSON modes to keep machine output clean. + if result.exit_code == 0 && !yes && !structured_output { + let detected = crate::commands::completions::detect_login_shell_from( + std::env::var("SHELL").ok().as_deref(), + ); + println!("\n{}", crate::commands::completions::hint_line(detected)); + } + Ok(result.exit_code) } diff --git a/crates/onebrain-cli/src/commands/mod.rs b/crates/onebrain-cli/src/commands/mod.rs index 7d50408..a5fa9d1 100644 --- a/crates/onebrain-cli/src/commands/mod.rs +++ b/crates/onebrain-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod checkpoint; +pub mod completions; pub mod doctor; pub mod harness; pub mod harness_run; diff --git a/crates/onebrain-cli/src/v31/dispatch.rs b/crates/onebrain-cli/src/v31/dispatch.rs index 3c1b0e8..281cd04 100644 --- a/crates/onebrain-cli/src/v31/dispatch.rs +++ b/crates/onebrain-cli/src/v31/dispatch.rs @@ -81,6 +81,10 @@ pub fn dispatch(cli: Cli) -> Result<()> { commands::doctor::run(a.fix, a.json, a.yes, vault_flag.clone(), &mode, quiet)?; std::process::exit(code); } + Cmd::Completions(a) => { + let code = commands::completions::run(a.shell); + std::process::exit(code); + } // ───── Session ────────────────────────────────────────────── Cmd::Session(SessionCmd { verb }) => match verb { diff --git a/crates/onebrain-cli/tests/completions.rs b/crates/onebrain-cli/tests/completions.rs new file mode 100644 index 0000000..24551af --- /dev/null +++ b/crates/onebrain-cli/tests/completions.rs @@ -0,0 +1,53 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn completions_zsh_emits_compdef_marker() { + Command::cargo_bin("onebrain") + .unwrap() + .args(["completions", "zsh"]) + .assert() + .success() + .stdout(predicate::str::contains("#compdef onebrain")); +} + +#[test] +fn completions_bash_emits_function_marker() { + Command::cargo_bin("onebrain") + .unwrap() + .args(["completions", "bash"]) + .assert() + .success() + .stdout(predicate::str::contains("_onebrain()")); +} + +#[test] +fn completions_fish_emits_complete_marker() { + Command::cargo_bin("onebrain") + .unwrap() + .args(["completions", "fish"]) + .assert() + .success() + .stdout(predicate::str::contains("complete -c onebrain")); +} + +#[test] +fn completions_rejects_unknown_shell() { + Command::cargo_bin("onebrain") + .unwrap() + .args(["completions", "tcsh"]) + .assert() + .failure(); +} + +#[test] +fn init_yes_does_not_print_completions_hint() { + let tmp = tempfile::tempdir().unwrap(); + Command::cargo_bin("onebrain") + .unwrap() + .args(["init", "--yes", "--no-sync"]) + .current_dir(tmp.path()) + .assert() + .success() + .stdout(predicate::str::contains("Shell completions").not()); +}