Nubo on your Mac
macOS on Apple Silicon, with Claude Code installed. Windows is coming later.
Install
Run this in your terminal
curl -fsSL https://nubo.work/install | shWhy the terminal? Nubo isn't signed by Apple yet, and files downloaded with curl aren't quarantined, so macOS doesn't block it. Installing from the terminal is the way that works today.
What the script does
- Checks you are on an Apple Silicon Mac. On Intel or Linux it stops and writes nothing.
- Reads the latest release from the release manifest and downloads it over https only.
- Verifies the download against the sha256 checksum from that manifest. This protects you from a broken download. It doesn't protect against a compromised server, because the script doesn't check a signature.
- Puts Nubo.app in /Applications, or in ~/Applications if /Applications isn't writable for you (if an unwritable Nubo.app is already in /Applications, it stops and tells you instead of installing a second copy). Also puts a
nubocommand in /usr/local/bin or ~/.local/bin. It never uses sudo, prints a PATH hint if needed, and leaves an existingnubofile that isn't a symlink alone. - Ends by suggesting the next step,
nubo install, which sets up Nubo for Claude Code. It doesn't run that for you. - Run it again to update. If Nubo is running, it asks you to quit and reopen it.
Read the full script
#!/bin/sh
# Nubo installer for macOS (Apple Silicon).
#
# curl -fsSL https://nubo.work/install | sh
#
# What this does:
# 1. Checks you're on macOS with an Apple Silicon (arm64) Mac.
# 2. Downloads the release manifest from dl.nubo.work and reads the
# darwin-aarch64 entry (other platforms in the manifest are ignored).
# 3. Downloads Nubo into a temporary directory and verifies its SHA-256.
# 4. Replaces Nubo.app in /Applications (old copy is moved aside first and
# removed only after the new one is in place). If /Applications isn't
# writable for you, it installs to ~/Applications instead (and says so).
# If an unwritable /Applications/Nubo.app already exists, it stops rather
# than leave you with two copies.
# 5. Symlinks the `nubo` CLI into /usr/local/bin, or ~/.local/bin if
# /usr/local/bin isn't writable.
# 6. Suggests the next step: run `nubo install` to set up Nubo for Claude
# Code. It does not run that for you.
#
# It never uses sudo, and nothing outside the temp directory is touched until
# the download has been verified and unpacked. All work happens inside `main`,
# which is called on the very last line, so a partially downloaded script
# does nothing.
#
# Environment overrides (for testing only):
# NUBO_MANIFEST_URL release manifest (default https://dl.nubo.work/releases/latest.json)
# NUBO_APP_DIR where Nubo.app goes (default /Applications, with the
# ~/Applications fallback; when set, no fallback happens)
# NUBO_DEFAULT_APP_DIR replaces /Applications as the default dir, so tests
# can simulate an unwritable /Applications (test-only)
# NUBO_BIN_DIR where the nubo symlink goes (default /usr/local/bin,
# fallback $HOME/.local/bin)
# NUBO_UNAME_S pretend `uname -s` output
# NUBO_UNAME_M pretend `uname -m` output
# NUBO_SYSCTL_ARM64 pretend `sysctl -n hw.optional.arm64` output
set -eu
MANIFEST_URL="${NUBO_MANIFEST_URL:-https://dl.nubo.work/releases/latest.json}"
DEFAULT_APP_DIR="${NUBO_DEFAULT_APP_DIR:-/Applications}"
APP_DIR="${NUBO_APP_DIR:-$DEFAULT_APP_DIR}"
BIN_DIR="${NUBO_BIN_DIR:-/usr/local/bin}"
PLATFORM="darwin-aarch64"
TMP_DIR=""
STAGED_APP=""
OLD_APP=""
say() {
printf '%s\n' "$*"
}
warn() {
printf 'Warning: %s\n' "$*" >&2
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
cleanup() {
# If we stopped halfway through swapping the app, put the old one back.
# If the new one did land, the old copy is just leftover: remove it.
if [ -n "$OLD_APP" ] && [ -d "$OLD_APP" ] && [ ! -e "$APP_DIR/Nubo.app" ]; then
mv "$OLD_APP" "$APP_DIR/Nubo.app" 2>/dev/null || true
elif [ -n "$OLD_APP" ] && [ -e "$APP_DIR/Nubo.app" ]; then
rm -rf "$OLD_APP" 2>/dev/null || true
fi
if [ -n "$STAGED_APP" ] && [ -d "$STAGED_APP" ]; then
rm -rf "$STAGED_APP"
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
# Read one value from the JSON manifest. plutil ships with macOS and reads
# JSON, so there's no dependency on jq or python3.
manifest_value() {
/usr/bin/plutil -extract "$1" raw -o - "$TMP_DIR/latest.json" 2>/dev/null
}
check_system() {
os="${NUBO_UNAME_S:-$(uname -s)}"
arch="${NUBO_UNAME_M:-$(uname -m)}"
if [ "$os" != "Darwin" ]; then
die "Nubo runs on macOS only. This system reports '$os'."
fi
if [ "$arch" = "x86_64" ]; then
# Under Rosetta, uname reports x86_64 even on Apple Silicon.
arm64="${NUBO_SYSCTL_ARM64:-$(sysctl -n hw.optional.arm64 2>/dev/null || true)}"
if [ "$arm64" = "1" ]; then
die "This Terminal is running under Rosetta, so it reports 'x86_64', but your Mac has Apple Silicon. Open Terminal natively (Finder > Applications > Utilities > Terminal > Get Info, untick 'Open using Rosetta') and run the command again. Nothing was installed."
fi
fi
if [ "$arch" != "arm64" ]; then
die "Nubo needs a Mac with Apple Silicon (M1 or newer). This Mac reports '$arch'."
fi
for tool in curl shasum tar; do
command -v "$tool" >/dev/null 2>&1 || die "'$tool' is required but wasn't found."
done
[ -x /usr/bin/plutil ] || die "/usr/bin/plutil is required but wasn't found."
if ! command -v claude >/dev/null 2>&1; then
warn "Claude Code ('claude') wasn't found on your PATH. Nubo works alongside Claude Code; install it from https://claude.com/claude-code."
fi
}
check_app_dir() {
if [ -z "${NUBO_APP_DIR:-}" ] && { [ ! -d "$APP_DIR" ] || [ ! -w "$APP_DIR" ]; }; then
# The default dir isn't usable. Never leave a second copy next to an
# existing one we can't replace.
if [ -e "$APP_DIR/Nubo.app" ] || [ -L "$APP_DIR/Nubo.app" ]; then
die "$APP_DIR/Nubo.app already exists, but you can't write to $APP_DIR, so it can't be replaced. Nothing was installed. Remove or replace it as an admin (rm -rf $APP_DIR/Nubo.app), or run the installer as a user who can write to $APP_DIR."
fi
fallback="$HOME/Applications"
say "$APP_DIR isn't writable, so Nubo will be installed to $fallback instead."
mkdir -p "$fallback" || die "Couldn't create $fallback."
APP_DIR="$fallback"
fi
[ -d "$APP_DIR" ] || die "$APP_DIR doesn't exist."
if [ ! -w "$APP_DIR" ]; then
die "Can't write to $APP_DIR. Run this as a user who can install apps (an admin account)."
fi
}
download() {
# Only https, also after redirects. file:// is allowed only when the
# manifest URL is overridden (tests).
proto="=https"
if [ -n "${NUBO_MANIFEST_URL:-}" ]; then
proto="=https,file"
fi
curl -fsSL --proto "$proto" --proto-redir =https -o "$2" "$1"
}
# Remove staging copies left behind by an earlier run that was killed.
# A stale old copy is only removed when a Nubo.app is in place.
remove_stale_copies() {
for stale in "$APP_DIR"/.Nubo.app.new.*; do
[ -e "$stale" ] && rm -rf "$stale"
done
if [ -e "$APP_DIR/Nubo.app" ]; then
for stale in "$APP_DIR"/.Nubo.app.old.*; do
[ -e "$stale" ] && rm -rf "$stale"
done
fi
return 0
}
path_hint() {
say ""
say "$1 is not on your PATH."
# The tilde is meant literally: it is printed for the user to run.
# shellcheck disable=SC2088
case "$(basename "${SHELL:-}")" in
zsh) rc="~/.zshrc" ;;
bash) rc="~/.bash_profile" ;;
*) rc="" ;;
esac
if [ -n "$rc" ]; then
say "Add it by running:"
say " echo 'export PATH=\"$1:\$PATH\"' >> $rc && source $rc"
else
say "Add this line to your shell's startup file:"
say " export PATH=\"$1:\$PATH\""
fi
}
main() {
: "${HOME:?HOME is not set}"
check_system
check_app_dir
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nubo-install.XXXXXX")"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
say "Fetching the latest Nubo release..."
download "$MANIFEST_URL" "$TMP_DIR/latest.json" ||
die "Couldn't download the release manifest from $MANIFEST_URL."
version="$(manifest_value version)" || die "The release manifest has no version."
url="$(manifest_value "platforms.$PLATFORM.url")" ||
die "The release manifest has no download for $PLATFORM."
expected="$(manifest_value "platforms.$PLATFORM.sha256")" ||
die "The release manifest has no checksum for $PLATFORM."
[ -n "$version" ] && [ -n "$url" ] && [ -n "$expected" ] ||
die "The release manifest is incomplete."
say "Downloading Nubo $version..."
download "$url" "$TMP_DIR/nubo.tar.gz" || die "Couldn't download $url."
actual="$(shasum -a 256 "$TMP_DIR/nubo.tar.gz" | awk '{print $1}')"
expected="$(printf '%s' "$expected" | tr 'A-F' 'a-f')"
if [ "$actual" != "$expected" ]; then
die "Checksum mismatch for the download (expected $expected, got $actual). Nothing was installed."
fi
# Unpacking with tar means no quarantine attribute is set (curl doesn't add
# one either), so Gatekeeper won't block the ad-hoc signed app.
mkdir "$TMP_DIR/unpacked"
tar -xzf "$TMP_DIR/nubo.tar.gz" -C "$TMP_DIR/unpacked" ||
die "Couldn't unpack the download."
[ -x "$TMP_DIR/unpacked/Nubo.app/Contents/MacOS/nubo" ] ||
die "The download doesn't contain Nubo.app with the nubo CLI."
remove_stale_copies
# Copy into the target directory under a temporary name first (mv across
# volumes is a copy), then swap with two quick renames.
STAGED_APP="$APP_DIR/.Nubo.app.new.$$"
mv "$TMP_DIR/unpacked/Nubo.app" "$STAGED_APP" ||
die "Couldn't copy Nubo.app into $APP_DIR."
if [ -e "$APP_DIR/Nubo.app" ]; then
OLD_APP="$APP_DIR/.Nubo.app.old.$$"
mv "$APP_DIR/Nubo.app" "$OLD_APP" || die "Couldn't move the old Nubo.app aside."
fi
mv "$STAGED_APP" "$APP_DIR/Nubo.app" || die "Couldn't move Nubo.app into place."
STAGED_APP=""
if [ -n "$OLD_APP" ]; then
rm -rf "$OLD_APP" || warn "Couldn't remove the old copy at $OLD_APP. You can delete it yourself."
OLD_APP=""
fi
# Link the CLI. Prefer BIN_DIR; fall back to ~/.local/bin if it's missing
# or not writable (we never create system directories or use sudo).
bin="$BIN_DIR"
if [ ! -d "$bin" ] || [ ! -w "$bin" ]; then
bin="$HOME/.local/bin"
mkdir -p "$bin" || die "Couldn't create $bin."
fi
link="$bin/nubo"
nubo_cmd="nubo"
on_path=1
if [ -e "$link" ] && [ ! -L "$link" ]; then
warn "$link already exists and is not a symlink, so it was left alone. The nubo CLI is at $APP_DIR/Nubo.app/Contents/MacOS/nubo."
nubo_cmd="$APP_DIR/Nubo.app/Contents/MacOS/nubo"
else
ln -sf "$APP_DIR/Nubo.app/Contents/MacOS/nubo" "$link" ||
die "Couldn't create the symlink $link."
case ":${PATH:-}:" in
*":$bin:"*) ;;
*)
path_hint "$bin"
on_path=0
# Show the tilde form when the dir is under $HOME.
case "$bin" in
"$HOME"/*)
# shellcheck disable=SC2088
nubo_cmd="~${bin#"$HOME"}/nubo"
;;
*) nubo_cmd="$link" ;;
esac
;;
esac
fi
# The bundle's executable is Contents/MacOS/nubo (lowercase), so match on
# the path rather than a process name.
if pgrep -f "$APP_DIR/Nubo.app/Contents/MacOS/" >/dev/null 2>&1; then
say ""
say "Nubo is running. Quit and reopen it to use the new version."
fi
case "$APP_DIR" in
"$DEFAULT_APP_DIR") where="Applications" ;;
"$HOME/Applications") where="Applications in your home folder (~/Applications)" ;;
*) where="$APP_DIR" ;;
esac
say ""
say "Nubo $version installed. Open Nubo from $where."
say "Next: run $nubo_cmd install to set up Nubo for Claude Code, then open Nubo."
if [ "$on_path" = 0 ]; then
say "(Once $bin is on your PATH, plain nubo install works too.)"
fi
}
main "$@"
What Nubo changes
~/.claude/agents/nubo.mdthe Nubo agent definition for Claude CodeMCP server nuboregistered at user scope in Claude Code~/.claude/CLAUDE.mdone block between <!-- nubo:start --> and <!-- nubo:end -->~/.nubo/your encrypted session (~/.nubo/session.enc) and Nubo's working folderKeychain, service nubothe key that unlocks the session file~/.claude.jsona trust entry for ~/.nubo/workdir under projects/Applications/Nubo.app (or ~/Applications/Nubo.app)the app; the installer uses ~/Applications when /Applications isn't writable for younubo command (symlink)a symlink to the command line tool, in /usr/local/bin or ~/.local/bin
Remove Nubo
Run these in order; you can skip the optional ones.
Stop Nubo's brain
nubo stopOptional: find the id of the session called nubo
claude agentsOptional: delete that session for good (replace <id> with the id from the previous step)
claude rm <id>Remove the agent
rm ~/.claude/agents/nubo.mdRemove the MCP server
claude mcp remove --scope user nuboRemove your Nubo folder and session
rm -rf ~/.nuboRemove the key from the Keychain
security delete-generic-password -s nubo -a keyQuit Nubo (⌘Q), then remove the app
rm -rf /Applications/Nubo.app ~/Applications/Nubo.appRemove the command, only if it is the symlink Nubo made
for f in /usr/local/bin/nubo ~/.local/bin/nubo; do for a in /Applications ~/Applications; do [ "$(readlink "$f")" = $a/Nubo.app/Contents/MacOS/nubo ] && rm "$f"; done; doneThen two manual steps:
- In
~/.claude/CLAUDE.md, delete everything from<!-- nubo:start -->to<!-- nubo:end -->. - In
~/.claude.json, underprojects, remove the entry for…/.nubo/workdir, or set itshasTrustDialogAcceptedto false.
A nubo file Nubo didn't create is left alone.
Older installs may have used the Keychain accounts sleutel or sessie instead of key. Repeat the Keychain command with -a sleutel or -a sessie if the first one finds nothing.
Privacy
Teammates see the replies you approve, not the sessions behind them. Nubo's data lives in Supabase, in the EU.