Uninstall carnivore

One command in Terminal removes carnivore from this computer. Run it as the same user who installed carnivore, not as root.

bash -c "$(curl -fsSL https://carnivore.ai/uninstall.sh)"

It lists everything it will remove and asks you to type yes before it touches anything. Add _ --dry-run after the quoted command to only print the list and change nothing, or _ --yes to skip the question, like this: bash -c "$(curl -fsSL https://carnivore.ai/uninstall.sh)" _ --dry-run. Run it from Terminal.app, not from a carnivore terminal, because it ends those. The script is short and is printed in full at the bottom of this page, so you can read it before you run it.

What it removes

Nothing outside that fixed list is ever deleted.

What it keeps

If this computer was in a pack

Removing carnivore here does not tell your main computer. On your main computer, open carnivore, go to pack, open the computers tab, select this computer, click remove and confirm.

Install again

bash -c "$(curl -fsSL https://carnivore.ai/install.sh)" _ --new

The script

The same file is served at uninstall.sh. Its sha256 is 41a9aba192962e8dea6bff59904a58be93b022e0aab9f7b18ea885ecfa9783c2.

#!/usr/bin/env bash
# carnivore uninstaller, served at https://carnivore.ai/uninstall.sh
#
#   bash -c "$(curl -fsSL https://carnivore.ai/uninstall.sh)"
#
# removes carnivore from this computer: quits the app, unregisters its login service
# and login items, stops its server, deletes the app, its settings and state, the
# keychain items it created, and resets the privacy rows (tccutil) it was granted.
# it keeps ~/carnivore-projects, every project folder, ~/.claude, ~/.carnivore and the
# Claude Code and Codex logins, and the Claude Code keychain items a pack relay may
# have written. the carnivore tmux sessions keep running unless you ask for them to stop.
# flags: --yes (skip the question), --stop-tmux (also end the carnivore tmux sessions;
# without it an interactive run asks and --yes leaves them running), --dry-run (print
# what would go, change nothing), --help.
# every path below is fixed; nothing outside this list is ever deleted.
# the whole body lives in main(), called on the last line, so a truncated
# download runs nothing.

APP="/Applications/Carnivore.app"
LOGIN_LABELS="ai.carnivore.menubar com.cloudecode.menubar ai.carnivore.install-launch"
# the app's own programs only (the shell and its helpers), NOT Contents/Resources/payload: the bundled tmux server that holds
# your terminal sessions runs from there and must survive unless you ask for it to stop.
APP_PATTERN="^/Applications/Carnivore.app/Contents/(MacOS|Frameworks)/"
KEYCHAIN_SERVICES="ai.carnivore.federation.key ai.carnivore.relay.device-key ai.carnivore.relay.legacy-publish-token ai.carnivore.catalog.guest-key carnivore-byoai carnivore-notifications carnivore-messaging carnivore-native-push carnivore-summary-api carnivore-summary-local"
KEYCHAIN_BOUND=100
ASSUME_YES=0
DRY_RUN=0
STOP_TMUX=0
TMUX_BIN_GIVEN=""
PATHS=()
CPLUG_SERVICES=()
TTY_DEV=/dev/tty

# --- uninstall contract: begin ---
# The same text sits in uninstall.sh and nuke.sh, and tests/python/release/test_uninstall_contract.py fails when
# the two copies differ. Docs: docs/desktop-app.md, "Uninstall and nuke". A script that needs a different value assigns it
# AFTER this block; it never edits the block. MODE below is run or dry.
CONTRACT_APP="/Applications/Carnivore.app"
CONTRACT_SERVICE_LABEL="ai.carnivore.menubar.engine"
CONTRACT_AGENT_LABELS="ai.carnivore.menubar com.cloudecode.menubar"
CONTRACT_TCC_BUNDLE_IDS="ai.carnivore.menubar com.cloudecode.menubar"
CONTRACT_TCC_SERVICES="AppleEvents SystemPolicyAllFiles SystemPolicyAppData SystemPolicyDocumentsFolder SystemPolicyDesktopFolder SystemPolicyDownloadsFolder SystemPolicyRemovableVolumes SystemPolicyNetworkVolumes"
CONTRACT_TMUX_SOCKETS="cloude carnivore"
# the first release whose app understands --unregister-login-service; an older app would start as a full GUI app instead.
CONTRACT_MIN_APP_VERSION="1.16.4"

# contract_say MESSAGE: print one line of the contract's progress.
contract_say() {
  printf '%s\n' "$*"
}

# contract_find_bundle: print the folder of the installed Carnivore app wherever it is (the login job's own program, the
# Applications folder, ~/Applications, the Trash), or nothing.
contract_find_bundle() {
  local home="${CONTRACT_HOME:-$HOME}" launchctl_bin="${CONTRACT_LAUNCHCTL:-launchctl}" program candidate
  program="$("$launchctl_bin" print "gui/$(id -u)/$CONTRACT_SERVICE_LABEL" </dev/null 2>/dev/null | sed -n 's/^[[:space:]]*program = //p' | head -n 1)"
  case "$program" in
    */Contents/MacOS/Carnivore)
      if [[ -x "$program" ]]; then printf '%s\n' "${program%/Contents/MacOS/Carnivore}"; return 0; fi
      ;;
  esac
  for candidate in "$CONTRACT_APP" "$home/Applications/Carnivore.app" "$home"/.Trash/Carnivore*.app; do
    if [[ -x "$candidate/Contents/MacOS/Carnivore" ]]; then printf '%s\n' "$candidate"; return 0; fi
  done
  return 0
}

# contract_app_bin: print the app's executable when the app is installed, else nothing.
contract_app_bin() {
  local bundle
  bundle="$(contract_find_bundle)"
  if [[ -n "$bundle" ]]; then printf '%s\n' "$bundle/Contents/MacOS/Carnivore"; fi
  return 0
}

# contract_app_version BUNDLE: print the app's CFBundleShortVersionString, or nothing when it cannot be read.
contract_app_version() {
  sed -n '/<key>CFBundleShortVersionString<\/key>/{n;s/.*<string>\(.*\)<\/string>.*/\1/p;}' "$1/Contents/Info.plist" 2>/dev/null | head -n 1
  return 0
}

# contract_version_at_least ACTUAL MINIMUM: succeed when the dotted version ACTUAL is MINIMUM or newer; fails on a non-number.
contract_version_at_least() {
  local -a have want
  local i x y
  IFS=. read -r -a have <<<"${1%%[-+]*}"
  IFS=. read -r -a want <<<"$2"
  for i in 0 1 2; do
    x="${have[i]:-0}"
    y="${want[i]:-0}"
    [[ "$x" =~ ^[0-9]+$ && "$y" =~ ^[0-9]+$ ]] || return 1
    if ((10#$x > 10#$y)); then return 0; fi
    if ((10#$x < 10#$y)); then return 1; fi
  done
  return 0
}

# contract_service_registered: succeed when an engine login job is loaded now or the install is set to the launchd-agent host.
contract_service_registered() {
  local home="${CONTRACT_HOME:-$HOME}" launchctl_bin="${CONTRACT_LAUNCHCTL:-launchctl}"
  if "$launchctl_bin" print "gui/$(id -u)/$CONTRACT_SERVICE_LABEL" </dev/null >/dev/null 2>&1; then return 0; fi
  grep -q 'launchd-agent' "$home/Library/Application Support/Carnivore Menubar/engine-host.json" 2>/dev/null
}

# contract_run_bounded SECONDS COMMAND...: run COMMAND, end it when it outlives SECONDS; its status, 124 on a timeout.
contract_run_bounded() {
  local limit="$1" pid i=0 rc=0
  shift
  "$@" </dev/null 2>/dev/null &
  pid=$!
  while kill -0 "$pid" 2>/dev/null; do
    if [[ "$i" -ge "$limit" ]]; then
      kill "$pid" 2>/dev/null || true
      wait "$pid" 2>/dev/null || true
      return 124
    fi
    sleep 1
    i=$((i + 1))
  done
  wait "$pid" || rc=$?
  return "$rc"
}

# contract_unregister_login_service MODE: have the installed app unregister the engine login service (only when an engine
# login job exists and the app is new enough to know the flag), then boot the job out with launchctl.
contract_unregister_login_service() {
  local mode="$1" bin bundle version uid launchctl_bin rc=0
  uid="$(id -u)"
  launchctl_bin="${CONTRACT_LAUNCHCTL:-launchctl}"
  bin=""
  if [[ "${CONTRACT_SYSTEM:-1}" == 1 ]]; then bin="$(contract_app_bin)"; fi
  bundle="${bin%/Contents/MacOS/Carnivore}"
  version=""
  if [[ -n "$bin" ]]; then version="$(contract_app_version "$bundle")"; fi
  if [[ "${CONTRACT_SYSTEM:-1}" != 1 ]]; then
    contract_say "login service: the installed app is not run from here, booting out $CONTRACT_SERVICE_LABEL with launchctl"
  elif [[ -z "$bin" ]]; then
    contract_say "login service: no Carnivore app found, booting out $CONTRACT_SERVICE_LABEL with launchctl"
  elif ! contract_service_registered; then
    contract_say "login service: no engine login job is registered, so the app is not run"
  elif ! contract_version_at_least "$version" "$CONTRACT_MIN_APP_VERSION"; then
    contract_say "login service: this app (version ${version:-unknown}) cannot unregister it; booting out $CONTRACT_SERVICE_LABEL with launchctl, and macOS may start it again at the next login"
  elif [[ "$mode" == dry ]]; then
    contract_say "[dry-run] would run: $bin --unregister-login-service"
  else
    contract_run_bounded 30 "$bin" --unregister-login-service || rc=$?
    if [[ "$rc" == 0 ]]; then
      contract_say "login service: the app unregistered it"
    else
      contract_say "login service: the app could NOT unregister it (exit $rc, 124 is a timeout); it is booted out for now and macOS may start it again at the next login. Remove it in System Settings, General, Login Items & Extensions"
    fi
  fi
  if [[ "$mode" == dry ]]; then
    contract_say "[dry-run] would run: launchctl bootout gui/$uid/$CONTRACT_SERVICE_LABEL"
  else
    "$launchctl_bin" bootout "gui/$uid/$CONTRACT_SERVICE_LABEL" </dev/null >/dev/null 2>&1 || true
  fi
}

# contract_unregister_agents MODE: boot out both menubar LaunchAgent labels and remove their plists. Booting out the label that
# started the running app ends that app and its process group, so nuke.sh calls this last.
contract_unregister_agents() {
  local mode="$1" label uid home launchctl_bin
  uid="$(id -u)"
  home="${CONTRACT_HOME:-$HOME}"
  launchctl_bin="${CONTRACT_LAUNCHCTL:-launchctl}"
  for label in $CONTRACT_AGENT_LABELS; do
    if [[ "$mode" == dry ]]; then
      contract_say "[dry-run] would run: launchctl bootout gui/$uid/$label"
    else
      "$launchctl_bin" bootout "gui/$uid/$label" </dev/null >/dev/null 2>&1 || true
    fi
  done
  for label in $CONTRACT_AGENT_LABELS; do
    if [[ "$mode" == dry ]]; then
      contract_say "[dry-run] would remove: $home/Library/LaunchAgents/$label.plist"
    else
      rm -f "${home:?}/Library/LaunchAgents/$label.plist"
    fi
  done
}

# contract_launchd_left: print every launchd job of ours that is still loaded for this user (none when the list is empty).
contract_launchd_left() {
  local label launchctl_bin="${CONTRACT_LAUNCHCTL:-launchctl}"
  if [[ "${CONTRACT_SYSTEM:-1}" != 1 ]]; then return 0; fi
  for label in "$CONTRACT_SERVICE_LABEL" $CONTRACT_AGENT_LABELS; do
    if "$launchctl_bin" print "gui/$(id -u)/$label" </dev/null >/dev/null 2>&1; then printf '%s\n' "$label"; fi
  done
  return 0
}

# contract_record_background_items: say what Background Items still lists for us. macOS lets only root read the list
# (sfltool dumpbtm), so a regular user gets the sudo command to run; root runs it bounded.
contract_record_background_items() {
  local out="" lines
  if [[ "${CONTRACT_SYSTEM:-1}" != 1 ]] || ! command -v sfltool >/dev/null 2>&1; then
    contract_say "background items: not checked (sfltool is not available here)"
    return 0
  fi
  if [[ "$(id -u)" != 0 ]]; then
    contract_say "background items: not checked (macOS lets only root read the list). To see whether a Carnivore record remains, run: sudo sfltool dumpbtm"
    return 0
  fi
  out="$(contract_run_bounded 20 sfltool dumpbtm)" || true
  lines="$(printf '%s\n' "$out" | grep -i -E 'carnivore|cloudecode' || true)"
  if [[ -n "$lines" ]]; then
    contract_say "background items: macOS still lists a Carnivore record. The login service is unregistered; the record is macOS's own"
    contract_say "  and goes away when you remove it in System Settings, General, Login Items & Extensions."
    printf '%s\n' "$lines" | head -n 12 | sed 's/^/  /'
  elif printf '%s\n' "$out" | grep -q 'Records for UID'; then
    contract_say "background items: no Carnivore record"
  else
    contract_say "background items: could not read the list, so a record may remain (run: sudo sfltool dumpbtm)"
  fi
}

# contract_reset_tcc MODE: reset OUR privacy rows only (each service the app holds, for each of our two bundle ids).
# Never "All", never another bundle id. tccutil answers "No such bundle identifier" once the app is deleted (it works while
# the app is in /Applications or the Trash), so callers run this BEFORE they delete the app.
contract_reset_tcc() {
  local mode="$1" id service ok refused
  if [[ "${CONTRACT_SYSTEM:-1}" != 1 ]] || ! command -v tccutil >/dev/null 2>&1; then
    contract_say "privacy rows: not reset (tccutil is not available here)"
    return 0
  fi
  for id in $CONTRACT_TCC_BUNDLE_IDS; do
    ok=0
    refused=0
    for service in $CONTRACT_TCC_SERVICES; do
      if [[ "$mode" == dry ]]; then
        contract_say "[dry-run] would run: tccutil reset $service $id"
      elif tccutil reset "$service" "$id" </dev/null >/dev/null 2>&1; then
        ok=$((ok + 1))
      else
        refused=$((refused + 1))
      fi
    done
    if [[ "$mode" != dry ]]; then
      contract_say "privacy rows for $id: $ok reset, $refused refused"
      if [[ "$refused" -gt 0 ]]; then contract_say "  (macOS refuses an app id it does not know, for example once the app is gone; rows left behind come back if the same app is installed again)"; fi
    fi
  done
}

# contract_bundled_tmux: print the tmux the app bundle ships for this computer's architecture, or nothing.
contract_bundled_tmux() {
  local tmux_bin bundle arch
  arch="$(uname -m)"
  bundle="$(contract_find_bundle)"
  tmux_bin="$bundle/Contents/Resources/payload/payload-$arch/bin/tmux"
  if [[ -n "$bundle" && -x "$tmux_bin" ]]; then printf '%s\n' "$tmux_bin"; fi
  return 0
}

# contract_socket_is_ours SOCKET: succeed only for a tmux socket name carnivore itself uses (never "default", never a name
# read from a config file that was edited).
contract_socket_is_ours() {
  case " $CONTRACT_TMUX_SOCKETS " in
    *" $1 "*) return 0 ;;
  esac
  return 1
}

# contract_stop_tmux MODE SOCKET [TMUX_BIN]: end the tmux server on ONE named socket of ours (never the default socket,
# never your own tmux server) and say whether it ended. TMUX_BIN when given, else the bundled tmux first; a tmux on PATH
# is then tried on the same socket only because a server started by another tmux version refuses the bundled one.
# Callers run this only when asked, and before they delete the app the bundled tmux lives in.
contract_stop_tmux() {
  local mode="$1" socket="$2" given="${3:-}" candidate tried=0 out rc ended=0
  local -a candidates
  if ! contract_socket_is_ours "$socket"; then
    contract_say "tmux: socket '$socket' is not one of ours ($CONTRACT_TMUX_SOCKETS), left alone"
    return 0
  fi
  if [[ -n "$given" ]]; then
    candidates=("$given")
  else
    candidates=("$(contract_bundled_tmux)" "$(command -v tmux || true)")
  fi
  for candidate in "${candidates[@]}"; do
    if [[ -z "$candidate" ]]; then continue; fi
    tried=1
    if [[ "$mode" == dry ]]; then
      contract_say "[dry-run] would run: $candidate -L $socket kill-server"
      continue
    fi
    "$candidate" -L "$socket" kill-server </dev/null >/dev/null 2>&1 || true
    rc=0
    out="$("$candidate" -L "$socket" list-sessions </dev/null 2>&1)" || rc=$?
    if [[ "$rc" == 0 ]]; then
      contract_say "tmux: socket $socket still has sessions after $candidate kill-server"
    elif printf '%s\n' "$out" | grep -qi 'version mismatch'; then
      contract_say "tmux: $candidate could not talk to the server on socket $socket (tmux version mismatch)"
    else
      contract_say "tmux: socket $socket has no server left"
      ended=1
      break
    fi
  done
  if [[ "$tried" == 0 ]]; then contract_say "tmux: no tmux found, socket $socket left alone"; fi
  if [[ "$tried" == 1 && "$ended" == 0 && "$mode" != dry ]]; then contract_say "tmux: the sessions on socket $socket may still be running"; fi
  return 0
}
# --- uninstall contract: end ---

# the block reads these three; they are set here and never taken from the environment.
CONTRACT_SYSTEM=1
CONTRACT_HOME="$HOME"
CONTRACT_LAUNCHCTL=launchctl

# say MESSAGE: print one progress line.
say() {
  printf '%s\n' "$*"
}

# fail MESSAGE [STATUS]: print MESSAGE to stderr and stop with STATUS (default 1).
fail() {
  printf 'carnivore: %s\n' "$1" >&2
  exit "${2:-1}"
}

# have_tty: succeed when the person's terminal can be read.
have_tty() {
  { : <"$TTY_DEV"; } 2>/dev/null
}

# usage: print the flags.
usage() {
  say "usage: bash -c \"\$(curl -fsSL https://carnivore.ai/uninstall.sh)\" _ [--yes] [--stop-tmux] [--dry-run]"
  say "  --yes        do not ask before removing"
  say "  --stop-tmux  also end the carnivore terminal sessions (work running in them stops); without it they keep running"
  say "  --dry-run    print what would be removed and change nothing"
}

# parse_args ARGS...: read the flags.
parse_args() {
  while [[ "$#" -gt 0 ]]; do
    case "$1" in
      --yes) ASSUME_YES=1 ;;
      --dry-run) DRY_RUN=1 ;;
      --stop-tmux) STOP_TMUX=1 ;;
      --tmux-bin)
        [[ "$#" -ge 2 ]] || fail "--tmux-bin needs a path" 2
        TMUX_BIN_GIVEN="$2"
        shift
        ;;
      --help | -h) usage; exit 0 ;;
      *) usage >&2; fail "unknown option $1" 2 ;;
    esac
    shift
  done
}

# preflight: macos only, not root, a real home, and an app path that is the carnivore app.
preflight() {
  [[ "$(uname -s)" == Darwin ]] || fail "carnivore runs on macos only"
  [[ "$(id -u)" != 0 ]] || fail "do not run this as root or with sudo. run it as the user who installed carnivore."
  [[ "${HOME:-}" == /?* && "$HOME" != "/" && "$HOME" != "//" ]] || fail "HOME is not set to a real folder"
  # stopping the tmux servers below would end this script when it runs inside a carnivore terminal.
  case "$(basename "$(printf %s "${TMUX:-}" | cut -d, -f1)")" in
    cloude | carnivore) fail "this is a carnivore terminal, and removing carnivore ends it. run this from Terminal.app instead." 2 ;;
  esac
}

# build_paths: the fixed list of files and folders this script may delete.
build_paths() {
  local lib="$HOME/Library"
  PATHS=(
    "$APP"
    "$lib/Application Support/Carnivore"
    "$lib/Application Support/Carnivore Menubar"
    "$lib/Application Support/CloudeCode"
    "$lib/Application Support/cloude-code-menubar"
    "$lib/LaunchAgents/ai.carnivore.menubar.plist"
    "$lib/LaunchAgents/com.cloudecode.menubar.plist"
    "$lib/Preferences/ai.carnivore.menubar.plist"
    "$lib/Preferences/com.cloudecode.menubar.plist"
    "$lib/Caches/ai.carnivore.menubar"
    "$lib/Caches/ai.carnivore.menubar.ShipIt"
    "$lib/Caches/cloude-code-menubar-updater"
    "$lib/Caches/Carnivore"
    "$lib/HTTPStorages/ai.carnivore.menubar"
    "$lib/Saved Application State/ai.carnivore.menubar.savedState"
    "$lib/Logs/Carnivore"
    "$lib/Logs/cloude-code"
    "/Applications/.Carnivore-staging.app"
    "/Applications/.Carnivore-previous.app"
  )
}

# find_cplug_services: list per-plugin keychain service names. dump-keychain without -d
# prints attributes only, never secret data; only names of the exact shape are kept.
find_cplug_services() {
  local line
  CPLUG_SERVICES=()
  while IFS= read -r line; do
    [[ "$line" =~ ^carnivore-cplug-[A-Za-z0-9._-]+$ ]] && CPLUG_SERVICES+=("$line")
  done < <(security dump-keychain 2>/dev/null </dev/null |
    sed -n 's/.*"svce"<blob>="\(carnivore-cplug-[^"]*\)".*/\1/p' | sort -u)
  return 0
}

# anything_left: succeed when any listed path, keychain item or loaded login job of ours still exists.
anything_left() {
  local p s
  for p in "${PATHS[@]}"; do
    [[ -e "$p" || -L "$p" ]] && return 0
  done
  for s in $KEYCHAIN_SERVICES ${CPLUG_SERVICES[@]+"${CPLUG_SERVICES[@]}"}; do
    security find-generic-password -s "$s" </dev/null >/dev/null 2>&1 && return 0
  done
  [[ -z "$(contract_launchd_left)" ]] || return 0
  return 1
}

# show_plan: print everything this run will do.
show_plan() {
  local p s
  say "this removes carnivore from this computer:"
  say ""
  say "  stops: the carnivore app, its login service and login items (${CONTRACT_SERVICE_LABEL} ${LOGIN_LABELS}) and its server"
  say "  resets: carnivore's privacy rows (tccutil, bundle ids ${CONTRACT_TCC_BUNDLE_IDS})"
  if [[ "$STOP_TMUX" == 1 ]]; then
    say "  ends:  every carnivore terminal session on this computer (tmux sockets: ${CONTRACT_TMUX_SOCKETS}). work running inside them stops."
  else
    say "  keeps: your carnivore terminal sessions running (add --stop-tmux to end them)"
  fi
  say "  deletes:"
  for p in "${PATHS[@]}"; do
    say "    $p"
  done
  say "  deletes keychain items:"
  for s in $KEYCHAIN_SERVICES ${CPLUG_SERVICES[@]+"${CPLUG_SERVICES[@]}"}; do
    say "    $s"
  done
  say ""
  say "it keeps ~/carnivore-projects, ~/.claude, ~/.carnivore, your Claude Code and Codex logins and any Claude Code keychain items a pack relay wrote."
  say ""
}

# confirm: ask on the terminal (the script is usually piped); --yes skips it.
confirm() {
  [[ "$ASSUME_YES" == 1 ]] && return 0
  have_tty || fail "this needs a terminal to ask you. run it from a Terminal window, or add --yes." 2
  local answer=""
  printf 'type yes to remove carnivore: ' >"$TTY_DEV"
  IFS= read -r answer <"$TTY_DEV" || answer=""
  [[ "$answer" == yes ]] || fail "nothing was removed" 3
}

# safe_remove PATH: delete one listed path; refuses anything not under ~/Library or the app folder.
safe_remove() {
  local p="$1"
  case "$p" in
    "$HOME"/Library/?*) ;;
    "$APP" | /Applications/.Carnivore-staging.app | /Applications/.Carnivore-previous.app) ;;
    *) return 1 ;;
  esac
  rm -rf "${p:?}"
}

# delete_keychain_service SERVICE: delete every generic password item of one service, bounded.
delete_keychain_service() {
  local service="$1" n=0
  while [[ "$n" -lt "$KEYCHAIN_BOUND" ]]; do
    security delete-generic-password -s "$service" </dev/null >/dev/null 2>&1 || break
    n=$((n + 1))
  done
}

# server_pids: print the pid of every carnivore server (python -m src.main whose cwd is the
# app's server folder). A bystander claude or mcp bridge never matches.
server_pids() {
  local p cwd
  for p in $(pgrep -f 'src\.main' </dev/null 2>/dev/null); do
    cwd="$(lsof -a -p "$p" -d cwd -Fn </dev/null 2>/dev/null | sed -n 's/^n//p')"
    [[ "$cwd" == "$HOME/Library/Application Support/Carnivore Menubar/server" ]] && printf '%s\n' "$p"
  done
  return 0
}

# stop_app: quit the app, wait up to about 15 s, then end whatever is left of it.
stop_app() {
  local i=0
  osascript -e 'quit app "Carnivore"' </dev/null >/dev/null 2>&1 || true
  while [[ "$i" -lt 15 ]]; do
    pgrep -f "$APP_PATTERN" </dev/null >/dev/null 2>&1 || return 0
    sleep 1
    i=$((i + 1))
  done
  pkill -f "$APP_PATTERN" </dev/null >/dev/null 2>&1 || true
  sleep 2
  # a modal dialog (the first quit notice) can hold a quit and a terminate: end what is still there.
  pkill -9 -f "$APP_PATTERN" </dev/null >/dev/null 2>&1 || true
}

# stop_server: end the carnivore server only, then force anything that is still up.
stop_server() {
  local p
  for p in $(server_pids); do kill "$p" >/dev/null 2>&1 || true; done
  sleep 2
  for p in $(server_pids); do kill -9 "$p" >/dev/null 2>&1 || true; done
}

# stop_everything: unregister the login service, quit the app, boot out the login items, stop the server.
stop_everything() {
  local label
  say "stopping carnivore"
  contract_unregister_login_service run
  contract_unregister_agents run
  stop_app
  for label in $LOGIN_LABELS; do
    launchctl bootout "gui/$(id -u)/$label" </dev/null >/dev/null 2>&1 || true
  done
  stop_server
}

# ask_stop_tmux: on a terminal without --yes, ask whether to end the carnivore terminal sessions; the default is no.
ask_stop_tmux() {
  [[ "$STOP_TMUX" == 1 || "$ASSUME_YES" == 1 ]] && return 0
  have_tty || return 0
  local answer=""
  printf 'also end the carnivore terminal sessions? work running in them stops. type yes to end them, anything else keeps them: ' >"$TTY_DEV"
  IFS= read -r answer <"$TTY_DEV" || answer=""
  [[ "$answer" == yes ]] && STOP_TMUX=1
  return 0
}

# stop_tmux MODE: when asked for, end the carnivore terminal sessions with the bundled tmux (it runs before the app is deleted).
stop_tmux() {
  local sock tmux_hint
  if [[ "$STOP_TMUX" != 1 ]]; then
    tmux_hint="$(contract_bundled_tmux)"
    say "your carnivore terminal sessions keep running (tmux sockets: ${CONTRACT_TMUX_SOCKETS}); end them any time with: '${tmux_hint:-tmux}' -L cloude kill-server"
    return 0
  fi
  for sock in $CONTRACT_TMUX_SOCKETS; do
    contract_stop_tmux "$1" "$sock" "$TMUX_BIN_GIVEN"
  done
}

# finish_login: record Background Items, and fail when a login job of ours is still loaded.
finish_login() {
  local left
  contract_record_background_items
  left="$(contract_launchd_left)"
  [[ -z "$left" ]] || fail "a carnivore login job is still loaded: $(printf '%s' "$left" | tr '\n' ' '). run: launchctl bootout gui/$(id -u)/<label>" 4
}

# remove_everything: delete the listed paths and keychain items; returns 1 when a path is still there.
remove_everything() {
  local p s failed=0
  say "removing files"
  for p in "${PATHS[@]}"; do
    safe_remove "$p" || say "could not remove $p"
    if [[ -e "$p" || -L "$p" ]]; then failed=1; fi
  done
  say "removing keychain items"
  for s in $KEYCHAIN_SERVICES ${CPLUG_SERVICES[@]+"${CPLUG_SERVICES[@]}"}; do
    delete_keychain_service "$s"
  done
  return "$failed"
}

# finish_notes: what is left for the person to do.
finish_notes() {
  say ""
  if [[ "${1:-}" == removed ]]; then say "carnivore is removed from this computer."; fi
  say "if this computer was in a pack: on your main computer, open carnivore, go to pack, computers, and remove this computer from the list."
  say "to install carnivore again: bash -c \"\$(curl -fsSL https://carnivore.ai/install.sh)\" _ --new"
}

# main: run every step in order.
main() {
  set -euo pipefail
  parse_args "$@"
  preflight
  build_paths
  find_cplug_services
  if ! anything_left; then
    say "nothing to remove. carnivore is not installed on this computer."
    finish_notes
    return 0
  fi
  show_plan
  if [[ "$DRY_RUN" == 1 ]]; then
    contract_unregister_login_service dry
    contract_unregister_agents dry
    contract_reset_tcc dry
    stop_tmux dry
    say "dry run: nothing was changed."
    return 0
  fi
  confirm
  ask_stop_tmux
  stop_everything
  contract_reset_tcc run
  # the terminal sessions end while the app's own tmux is still on disk, and before anything below can stop the script.
  stop_tmux run
  if ! remove_everything; then
    fail "some files could not be removed (see above). close anything using them and run this again." 4
  fi
  finish_login
  finish_notes removed
}

main "$@"

updated 2026-10-24

carnivore.ai