From 5a1ca46feff82a805ded6d238fec35b825a9da7c Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:41:22 +0700 Subject: [PATCH 1/9] build(cli): add clap_complete dependency --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + crates/onebrain-cli/Cargo.toml | 1 + 3 files changed, 12 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e82cf84..cb3f202 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" @@ -1007,6 +1016,7 @@ dependencies = [ "assert_cmd", "chrono", "clap", + "clap_complete", "dirs", "flate2", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index 8297315..7dcfcb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } From e95fdf5bfc4d60333b79734a8b506e932d21bb08 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:42:40 +0700 Subject: [PATCH 2/9] feat(cli): hidden completions subcommand (clap_complete) --- crates/onebrain-cli/src/cli.rs | 10 ++++++++++ .../onebrain-cli/src/commands/completions.rs | 19 +++++++++++++++++++ crates/onebrain-cli/src/commands/mod.rs | 1 + crates/onebrain-cli/src/v31/dispatch.rs | 4 ++++ crates/onebrain-cli/tests/completions.rs | 12 ++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 crates/onebrain-cli/src/commands/completions.rs create mode 100644 crates/onebrain-cli/tests/completions.rs 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..de3fdf5 --- /dev/null +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -0,0 +1,19 @@ +//! `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_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 +} 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..58bc62c --- /dev/null +++ b/crates/onebrain-cli/tests/completions.rs @@ -0,0 +1,12 @@ +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")); +} From 66fa5a0f8a64dc7108d0dc29dc177c3fb5536b03 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:43:09 +0700 Subject: [PATCH 3/9] test(cli): bash/fish completion markers + unknown-shell rejection --- crates/onebrain-cli/tests/completions.rs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/onebrain-cli/tests/completions.rs b/crates/onebrain-cli/tests/completions.rs index 58bc62c..5b5d943 100644 --- a/crates/onebrain-cli/tests/completions.rs +++ b/crates/onebrain-cli/tests/completions.rs @@ -10,3 +10,32 @@ fn completions_zsh_emits_compdef_marker() { .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(); +} From b0a0e7e0d58d00121b187f1181d47cd717918f42 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:44:01 +0700 Subject: [PATCH 4/9] feat(cli): login-shell detection helper for completions hint --- .../onebrain-cli/src/commands/completions.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/onebrain-cli/src/commands/completions.rs b/crates/onebrain-cli/src/commands/completions.rs index de3fdf5..2384110 100644 --- a/crates/onebrain-cli/src/commands/completions.rs +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -4,6 +4,7 @@ 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 @@ -17,3 +18,40 @@ pub fn run(shell: Shell) -> i32 { 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.rsplit('/').next().unwrap_or(path); + // `ValueEnum::from_str` reuses clap's own shell-name parsing (case-insensitive). + Shell::from_str(name, true).ok() +} + +#[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); + } +} From 2d3c13a83f643f29d6706c636f558ad23dace72f Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:44:38 +0700 Subject: [PATCH 5/9] feat(cli): shell-aware completions hint builder --- .../onebrain-cli/src/commands/completions.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/onebrain-cli/src/commands/completions.rs b/crates/onebrain-cli/src/commands/completions.rs index 2384110..c5d31df 100644 --- a/crates/onebrain-cli/src/commands/completions.rs +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -31,6 +31,21 @@ pub fn detect_login_shell_from(shell_env: Option<&str>) -> Option { 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::*; @@ -54,4 +69,18 @@ mod tests { fn returns_none_for_unknown_shell() { assert_eq!(detect_login_shell_from(Some("/bin/tcsh")), None); } + + #[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"); + } } From 1bc5ad733a3ed2fa61d27f09c4ed4ebbdd9425b0 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:45:19 +0700 Subject: [PATCH 6/9] feat(cli): optional shell-completions hint after interactive init --- crates/onebrain-cli/src/commands/init.rs | 11 +++++++++++ crates/onebrain-cli/tests/completions.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+) 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/tests/completions.rs b/crates/onebrain-cli/tests/completions.rs index 5b5d943..24551af 100644 --- a/crates/onebrain-cli/tests/completions.rs +++ b/crates/onebrain-cli/tests/completions.rs @@ -39,3 +39,15 @@ fn completions_rejects_unknown_shell() { .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()); +} From 146d8f653c67f7d1bc4ec6ea279eea583ec4f106 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:46:35 +0700 Subject: [PATCH 7/9] =?UTF-8?q?chore(cli):=20bump=20v3.2.19=20=E2=80=94=20?= =?UTF-8?q?shell=20completions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 ++++++++- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) 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 cb3f202..64cf745 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,7 +997,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onebrain-cache" -version = "3.2.18" +version = "3.2.19" dependencies = [ "chrono", "onebrain-core", @@ -1010,7 +1010,7 @@ dependencies = [ [[package]] name = "onebrain-cli" -version = "3.2.18" +version = "3.2.19" dependencies = [ "anyhow", "assert_cmd", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "onebrain-core" -version = "3.2.18" +version = "3.2.19" dependencies = [ "chrono", "indexmap", @@ -1055,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 7dcfcb5..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"] From c0ea90b10b9b3fcd71a54fe56c1842a2b5c92dd2 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:52:27 +0700 Subject: [PATCH 8/9] refactor(cli): harden login-shell detection + edge-case tests --- .../onebrain-cli/src/commands/completions.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/onebrain-cli/src/commands/completions.rs b/crates/onebrain-cli/src/commands/completions.rs index c5d31df..91e8562 100644 --- a/crates/onebrain-cli/src/commands/completions.rs +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -26,8 +26,8 @@ pub fn run(shell: Shell) -> i32 { /// 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.rsplit('/').next().unwrap_or(path); - // `ValueEnum::from_str` reuses clap's own shell-name parsing (case-insensitive). + 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() } @@ -70,6 +70,22 @@ mod tests { 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)); From f46d43cfe74d8b118d6a0eec8af899729d3ac26a Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 29 May 2026 20:54:33 +0700 Subject: [PATCH 9/9] style(cli): cargo fmt completions module --- .../onebrain-cli/src/commands/completions.rs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/onebrain-cli/src/commands/completions.rs b/crates/onebrain-cli/src/commands/completions.rs index 91e8562..7d96a46 100644 --- a/crates/onebrain-cli/src/commands/completions.rs +++ b/crates/onebrain-cli/src/commands/completions.rs @@ -26,7 +26,13 @@ pub fn run(shell: Shell) -> i32 { /// 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(); + 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() } @@ -37,9 +43,7 @@ pub fn detect_login_shell_from(shell_env: Option<&str>) -> Option { /// shell list is shown. pub fn hint_line(detected: Option) -> String { match detected { - Some(shell) => format!( - "💡 Shell completions (optional):\n onebrain completions {shell}" - ), + Some(shell) => format!("💡 Shell completions (optional):\n onebrain completions {shell}"), None => "💡 Shell completions (optional):\n onebrain completions \ (bash · zsh · fish · powershell · elvish)" .to_string(), @@ -57,7 +61,10 @@ mod tests { #[test] fn detects_bash_from_usr_path() { - assert_eq!(detect_login_shell_from(Some("/usr/bin/bash")), Some(Shell::Bash)); + assert_eq!( + detect_login_shell_from(Some("/usr/bin/bash")), + Some(Shell::Bash) + ); } #[test] @@ -83,14 +90,20 @@ mod tests { #[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)); + 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"); + assert!( + !h.contains(""), + "should not show placeholder when detected" + ); } #[test]