Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
latest_version: 3.2.18
latest_version: 3.2.19
released: 2026-05-29
---

Expand All @@ -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 <SHELL>` — 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.
Expand Down
18 changes: 14 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/onebrain-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions crates/onebrain-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)
// ─────────────────────────────────────────────────────────────────────────
Expand Down
115 changes: 115 additions & 0 deletions crates/onebrain-cli/src/commands/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! `onebrain completions <SHELL>` — 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<Shell> {
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<Shell>) -> String {
match detected {
Some(shell) => format!("💡 Shell completions (optional):\n onebrain completions {shell}"),
None => "💡 Shell completions (optional):\n onebrain completions <shell> \
(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("<shell>"),
"should not show placeholder when detected"
);
}

#[test]
fn hint_falls_back_to_placeholder() {
let h = hint_line(None);
assert!(h.contains("onebrain completions <shell>"), "got: {h}");
assert!(h.contains("bash"), "fallback lists shells");
}
}
11 changes: 11 additions & 0 deletions crates/onebrain-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
1 change: 1 addition & 0 deletions crates/onebrain-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod checkpoint;
pub mod completions;
pub mod doctor;
pub mod harness;
pub mod harness_run;
Expand Down
4 changes: 4 additions & 0 deletions crates/onebrain-cli/src/v31/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions crates/onebrain-cli/tests/completions.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading