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 | sh

Why 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

  1. Checks you are on an Apple Silicon Mac. On Intel or Linux it stops and writes nothing.
  2. Reads the latest release from the release manifest and downloads it over https only.
  3. 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.
  4. 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 nubo command in /usr/local/bin or ~/.local/bin. It never uses sudo, prints a PATH hint if needed, and leaves an existing nubo file that isn't a symlink alone.
  5. Ends by suggesting the next step, nubo install, which sets up Nubo for Claude Code. It doesn't run that for you.
  6. 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 Code
  • MCP 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 folder
  • Keychain, 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 you
  • nubo 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 stop
Optional: find the id of the session called nubo
claude agents
Optional: delete that session for good (replace <id> with the id from the previous step)
claude rm <id>
Remove the agent
rm ~/.claude/agents/nubo.md
Remove the MCP server
claude mcp remove --scope user nubo
Remove your Nubo folder and session
rm -rf ~/.nubo
Remove the key from the Keychain
security delete-generic-password -s nubo -a key
Quit Nubo (⌘Q), then remove the app
rm -rf /Applications/Nubo.app ~/Applications/Nubo.app
Remove 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; done

Then two manual steps:

  • In ~/.claude/CLAUDE.md, delete everything from <!-- nubo:start --> to <!-- nubo:end -->.
  • In ~/.claude.json, under projects, remove the entry for …/.nubo/workdir, or set its hasTrustDialogAccepted to 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.