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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
latest_version: 3.2.16
latest_version: 3.2.17
released: 2026-05-29
---

Expand All @@ -12,6 +12,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

## [3.2.17] — 2026-05-29 — `onebrain update`: refresh Homebrew tap before upgrade + dedicated npm channel

- **Fix: `onebrain update` on a Homebrew install now refreshes the `onebrain-ai/onebrain` tap before `brew upgrade`.** `brew upgrade` does not fetch new formulae, so running `onebrain update` right after a release found a stale local formula, no-op'd ("already installed"), and the post-install version guard then reported the upgrade "may not have taken effect" (a confusing false-ish failure that forced a manual `brew update && brew upgrade onebrain`). The fix git-pulls **only** our tap (not a full `brew update` — stays fast) so the freshly-published formula is visible and the upgrade applies in one `onebrain update`. Best-effort + non-fatal: a refresh failure falls through to `brew upgrade` exactly as before.
- **Feat: `onebrain update` now has a dedicated npm channel.** A binary installed via `npm i -g @onebrain-ai/cli` previously fell through to the Direct path, which swaps the file in place and desyncs npm's metadata (the same divergence the Homebrew path avoids). It's now detected by the `@onebrain-ai` `node_modules` scope and updated via `npm install -g @onebrain-ai/cli@<version>`. This completes the "delegate to the package manager, never swap a managed binary" model across all three channels — **Homebrew · npm · Direct download**.

## [3.2.16] — 2026-05-29 — plugin-cache doctor check + orphan cleanup + post-update reload hint

- **Fix: stale plugin-cache orphans no longer silently shadow the vault-local plugin.** A leftover `~/.claude/plugins/cache/<mkt>/onebrain/<version>/` (orphaned by a marketplace install that predated a vault-local update) could make Claude Code load OLD skills while `INSTRUCTIONS.md` came from the current copy. vault-sync Step 9 already pruned these during a sync; this adds proactive detection so an orphan created outside an update is caught on the next `doctor` run.
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

2 changes: 1 addition & 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.16"
version = "3.2.17"
edition = "2021"
license = "AGPL-3.0-only"
authors = ["OneBrain Contributors"]
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The CLI is **cross-harness**: paired with the [OneBrain plugin](https://github.c
Point an AI agent at a vault and it improvises — a different pile of `grep` / `ls` / `find` / `sed` each time, behaving differently on each harness and re-derived every session: slow, token-hungry, non-portable, sometimes wrong. **OneBrain CLI replaces that improvisation with one deterministic binary.**

- **Same behavior on every harness & model** — Claude Code and Gemini CLI both run `onebrain <noun> <verb>` and get identical output; switch harness without re-testing how your vault gets touched.
- **Cross-platform, one command** — the *same* `onebrain <noun> <verb>` runs on macOS, Linux, and Windows (Apple Silicon & Intel, x86_64 & ARM down to a Pi Zero) and returns the *same* typed result on every OS. Write a hook or script once; it behaves identically everywhere — no per-platform shell quirks (`sed`/`find`/path-separator differences) to work around.
- **Yours to extend, no waiting** — add a capability the harness/LLM doesn't have yet and every agent can use it immediately; they only learn the command, not implement the feature.
- **No re-deriving solved workflows** — search, capture, consolidate, checkpoint live in the binary, so the agent calls one command instead of re-reasoning the recipe each session. Fewer tokens, no drift.
- **Deterministic & safe** — a typed command with a frozen `Envelope` can't half-finish or quietly differ like an ad-hoc `rm` / `sed` pipeline. Same input → same output, scriptable by hooks.
Expand All @@ -61,7 +62,7 @@ brew install onebrain-ai/onebrain/onebrain

# 2. Verify
onebrain --version
# → onebrain 3.1.5
# → onebrain 3.2.17

# 3. Scaffold a vault and let init pull the OneBrain plugin
mkdir my-vault && cd my-vault
Expand Down Expand Up @@ -122,9 +123,13 @@ onebrain update --plan # machine-readable JSON plan

On an interactive terminal, `update` shows a framed `🧠 OneBrain Update` header and a braille spinner while it checks for (and downloads) a new version; piped / `--json` / `--plan` runs stay plain.

The install path resolves the current target triple at runtime, downloads the matching GitHub Release tarball over HTTPS (rustls TLS), and atomically swaps the running binary (Unix single-rename; Windows rustup-style two-step with rollback on failure). No package-manager middleware.
`onebrain update` auto-detects how the binary was installed and uses the right path so package-manager metadata never desyncs:

> **Homebrew users:** since v3.1.4, `onebrain update` auto-detects a brew-managed install (binary under the Cellar) and delegates to `brew upgrade onebrain`, so it stays in sync with brew's metadata — no manual step needed.
- **Homebrew** (binary under the Cellar) — refreshes the `onebrain-ai/onebrain` tap, then runs `brew upgrade onebrain`. The tap refresh (added v3.2.17) means a freshly-released version applies in one `onebrain update` with no manual `brew update`.
- **npm** (under `node_modules/@onebrain-ai/`) — runs `npm install -g @onebrain-ai/cli@<version>` (added v3.2.17).
- **Direct download** (a plain file we own) — resolves the current target triple, downloads the matching GitHub Release tarball over HTTPS (rustls TLS), verifies its SHA-256, and atomically swaps the running binary (Unix single-rename; Windows rustup-style two-step with rollback on failure).

After any path, a post-install guard runs `onebrain --version` from PATH and confirms the upgrade actually took effect.

### Build from source

Expand Down
153 changes: 147 additions & 6 deletions crates/onebrain-fs/src/update/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,11 @@ fn download_text(url: &str) -> Result<String, UpdateError> {
pub(crate) enum InstallChannel {
/// Homebrew-managed: lives in the Cellar behind a `brew` symlink.
Homebrew,
/// npm-managed: the `@onebrain-ai/cli` global package — the binary lives
/// under `node_modules/@onebrain-ai/cli`. Hand off to `npm install -g` so
/// npm's metadata stays in sync; swapping the file in place would desync it
/// (the same divergence the Homebrew path avoids).
Npm,
/// Direct download / `cargo-binstall` / manual — a file we own and can
/// safely swap in place.
Direct,
Expand Down Expand Up @@ -460,20 +465,69 @@ pub(crate) fn detect_install_channel(current_exe: &Path) -> InstallChannel {
/// never matches and always resolves to `Direct` (the Windows update path is
/// unwired anyway — see `AssetInfo::extract_binary`).
fn classify_path(resolved: &Path) -> InstallChannel {
if resolved.to_string_lossy().contains("/Cellar/onebrain/") {
let s = resolved.to_string_lossy();
if s.contains("/Cellar/onebrain/") {
InstallChannel::Homebrew
} else if s.contains("/node_modules/@onebrain-ai/") {
// The npm wrapper's native binary canonicalizes to
// `<prefix>/lib/node_modules/@onebrain-ai/cli/bin/onebrain`; pnpm/yarn
// and `bun add -g` nest it under `.../node_modules/@onebrain-ai/cli`
// too (bun via its bin symlink). The scoped `@onebrain-ai` segment is
// the precise signal — a bare `node_modules` check would over-match
// unrelated binaries. Unix path separator: Windows npm globals use
// backslashes and fall through to Direct (Windows update is unwired —
// see `AssetInfo::extract_binary`).
InstallChannel::Npm
} else {
InstallChannel::Direct
}
}

/// Delegate the update to Homebrew. `brew upgrade onebrain` refreshes the tap
/// formula and is idempotent (a no-op when already current); stdio is
/// inherited so the user sees brew's own output. We never swap a Cellar
/// binary in place — that would leave brew's metadata pointing at a version
/// it no longer manages.
/// The Homebrew tap that ships the `onebrain` formula.
const ONEBRAIN_TAP: &str = "onebrain-ai/onebrain";

/// Best-effort, quiet refresh of the `onebrain` Homebrew tap so a
/// freshly-published formula is visible to `brew upgrade`.
///
/// `brew upgrade` does NOT fetch new formulae — that's `brew update`, which
/// refreshes EVERY tap and can be slow. We scope the refresh to just our tap by
/// git-pulling its checkout (resolved via `brew --repository <tap>`). All
/// failures are swallowed: this is a convenience that removes the need for a
/// manual `brew update`, never a hard requirement — `brew_upgrade` proceeds and
/// the post-install version guard still catches a genuine no-op.
fn refresh_onebrain_tap() {
use std::process::Command;
let Ok(out) = Command::new("brew")
.args(["--repository", ONEBRAIN_TAP])
.output()
else {
return;
};
if !out.status.success() {
return;
}
let tap_dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
if tap_dir.is_empty() {
return;
}
// `--ff-only` keeps it safe (never a merge commit on a read-only tap);
// `--quiet` keeps the framed update report clean.
let _ = Command::new("git")
.args(["-C", &tap_dir, "pull", "--ff-only", "--quiet"])
.status();
}

/// Delegate the update to Homebrew. We refresh the onebrain tap FIRST (see
/// [`refresh_onebrain_tap`]) so `brew upgrade` sees a just-published formula —
/// without it, running `onebrain update` right after a release found a stale
/// local formula, no-op'd ("already installed"), and the post-install version
/// guard then flagged the mismatch. `brew upgrade` itself is idempotent (a
/// no-op when already current); stdio is inherited so the user sees brew's own
/// output. We never swap a Cellar binary in place — that would leave brew's
/// metadata pointing at a version it no longer manages.
pub(crate) fn brew_upgrade() -> Result<(), UpdateError> {
use std::process::Command;
refresh_onebrain_tap();
let status = Command::new("brew")
.args(["upgrade", "onebrain"])
.status()
Expand All @@ -494,6 +548,52 @@ pub(crate) fn brew_upgrade() -> Result<(), UpdateError> {
}
}

/// Build the npm install spec for a target version. Pins to the exact version
/// the update flow resolved (not `@latest`) so the install matches what was
/// already decided, and strips any leading `v` — npm specs are bare semver, so
/// an unstripped tag would yield `@onebrain-ai/cli@v3.2.17`, which npm rejects.
/// Extracted as a pure fn because it's the one silently-breakable bit of
/// [`npm_update`] (the rest just shells out).
fn npm_spec(version: &str) -> String {
format!("@onebrain-ai/cli@{}", version.trim_start_matches('v'))
}

/// Delegate the update to npm for the `@onebrain-ai/cli` global package.
/// `npm install -g @onebrain-ai/cli@<version>` re-runs the wrapper's binary
/// download and keeps npm's metadata in sync. We never swap the file in place —
/// that would desync npm (the same divergence the Homebrew path avoids). stdio
/// is inherited so the user sees npm's own progress.
///
/// `npm` is resolved from the ambient PATH, so the install targets whatever
/// node prefix is active in this process (relevant under nvm / Volta / fnm — a
/// different active node could install into a different prefix than the running
/// binary, surfacing as a no-op caught by the post-install version guard, not a
/// corruption). Bun users (`bun add -g`) canonicalize into this same Npm arm
/// via their `node_modules` symlink, but would want `bun add -g` instead — Bun
/// is not a v3 canonical install channel, so the npm path + guard is the
/// safe-enough fallback.
pub(crate) fn npm_update(version: &str) -> Result<(), UpdateError> {
use std::process::Command;
let spec = npm_spec(version);
let status = Command::new("npm")
.args(["install", "-g", &spec])
.status()
.map_err(|e| {
UpdateError::Install(format!(
"npm install detected but `npm` is not runnable ({e}). \
Run `npm install -g {spec}` manually."
))
})?;
if status.success() {
Ok(())
} else {
Err(UpdateError::Install(format!(
"`npm install -g {spec}` failed (exit {}).",
status.code().unwrap_or(-1)
)))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -675,4 +775,45 @@ mod tests {
InstallChannel::Direct
);
}

#[test]
fn classify_path_detects_npm_global() {
use std::path::Path;
// npm global: the native binary canonicalizes to the package's `bin/`
// (per the wrapper's postinstall) — the REAL production path.
assert_eq!(
classify_path(Path::new(
"/opt/homebrew/lib/node_modules/@onebrain-ai/cli/bin/onebrain"
)),
InstallChannel::Npm
);
// system-node npm prefix layout, same scope.
assert_eq!(
classify_path(Path::new(
"/usr/local/lib/node_modules/@onebrain-ai/cli/bin/onebrain"
)),
InstallChannel::Npm
);
// pnpm nests it under the @onebrain-ai scope too.
assert_eq!(
classify_path(Path::new(
"/Users/x/Library/pnpm/global/5/.pnpm/@onebrain-ai+cli@3.2.17/node_modules/@onebrain-ai/cli/onebrain"
)),
InstallChannel::Npm
);
// An UNSCOPED node_modules binary must NOT match — the `@onebrain-ai`
// scope is required, so a bare node_modules path stays Direct.
assert_eq!(
classify_path(Path::new("/proj/node_modules/.bin/onebrain")),
InstallChannel::Direct
);
}

#[test]
fn npm_spec_strips_v_prefix() {
// The one silently-breakable bit of npm_update: a stray `v` would yield
// `@onebrain-ai/cli@v3.2.17`, which npm rejects.
assert_eq!(npm_spec("3.2.17"), "@onebrain-ai/cli@3.2.17");
assert_eq!(npm_spec("v3.2.17"), "@onebrain-ai/cli@3.2.17");
}
}
8 changes: 5 additions & 3 deletions crates/onebrain-fs/src/update/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,12 @@ pub fn default_install_binary(version: &str) -> Result<(), UpdateError> {
let current_exe = std::env::current_exe()
.map_err(|e| UpdateError::Install(format!("could not resolve current binary path: {e}")))?;
match install::detect_install_channel(&current_exe) {
// Homebrew-managed installs live in the Cellar behind a `brew` symlink.
// Swapping that file in place would desync brew's metadata from disk
// (the dual-install divergence), so hand off to `brew upgrade` instead.
// Homebrew- and npm-managed installs live behind their package
// manager's metadata; swapping the file in place would desync it (the
// dual-install divergence). Hand off to the package manager instead.
// Only a Direct install is a plain file we own and can safely swap.
install::InstallChannel::Homebrew => install::brew_upgrade(),
install::InstallChannel::Npm => install::npm_update(version),
install::InstallChannel::Direct => install::fetch_and_swap_binary(version, &current_exe),
}
}
Expand Down
Loading