Skip to content

Instantly share code, notes, and snippets.

@banteg
Last active January 8, 2026 07:01
Show Gist options
  • Select an option

  • Save banteg/1a539b88b3c8945cd71e4b958f319d8d to your computer and use it in GitHub Desktop.

Select an option

Save banteg/1a539b88b3c8945cd71e4b958f319d8d to your computer and use it in GitHub Desktop.
uninstall beads

Beads Uninstall Script

A comprehensive uninstall/cleanup script for Beads (bd) that removes all traces of the tool from a system.

Usage

./scripts/uninstall.sh            # dry-run (scan $HOME)
./scripts/uninstall.sh --apply    # perform cleanup
./scripts/uninstall.sh --root DIR --apply
./scripts/uninstall.sh --skip-home --skip-binary

Key Features

  • Dry-run by default - Shows what would be deleted without making changes. Use --apply to actually perform cleanup.

What It Cleans Up

Per-Repository Cleanup

  • Stops any running bd daemon
  • Removes .beads/ and .beads-hooks/ directories
  • Cleans Beads integration from AGENTS.md (removes marked sections)
  • Removes bd hooks from .claude/settings.local.json and .gemini/settings.json
  • Deletes .cursor/rules/beads.mdc, .aider.conf.yml, .aider/BEADS.md
  • Cleans git hooks that contain bd-related code, restoring backups if they exist
  • Removes merge=beads from .gitattributes
  • Cleans Beads entries from .git/info/exclude
  • Unsets core.hooksPath if it points to .beads-hooks
  • Removes merge driver config (merge.beads.*)

Home Directory Cleanup

  • Removes ~/.beads/ and ~/.config/bd/
  • Cleans hooks from ~/.claude/settings.json and ~/.gemini/settings.json
  • Removes Beads entries from global gitignore
  • Removes ~/.beads-planning/ if it has a .beads subdirectory

Binary Cleanup

  • Removes bd binaries from common locations (/usr/local/bin, /opt/homebrew/bin, ~/.local/bin, ~/go/bin)
  • Runs brew uninstall bd if installed via Homebrew
  • Runs npm uninstall -g @beads/bd if installed via npm

Options

Flag Description
--apply Actually perform deletions (otherwise dry-run)
--root DIR Scan a specific directory instead of $HOME (repeatable)
--skip-home Don't touch home-level files
--skip-binary Don't remove the bd binary
-h, --help Show help

Examples

# See what would be cleaned (dry-run)
./scripts/uninstall.sh

# Actually clean everything
./scripts/uninstall.sh --apply

# Only clean a specific project directory
./scripts/uninstall.sh --root ~/myproject --skip-home --skip-binary --apply

# Clean multiple directories
./scripts/uninstall.sh --root ~/project1 --root ~/project2 --apply
#!/usr/bin/env bash
#
# Beads (bd) uninstall/cleanup script (macOS/Linux).
# Dry-run by default; pass --apply to actually delete/modify files.
#
# Usage:
# ./scripts/uninstall.sh # dry-run (scan $HOME)
# ./scripts/uninstall.sh --apply # perform cleanup
# ./scripts/uninstall.sh --root DIR --apply
# ./scripts/uninstall.sh --skip-home --skip-binary
#
set -euo pipefail
APPLY=0
SKIP_HOME=0
SKIP_BINARY=0
ROOTS=()
usage() {
cat <<'EOF'
Beads uninstall/cleanup script
Defaults:
- dry-run (no changes)
- scan $HOME for .beads and related files
Options:
--apply Actually delete/modify files (otherwise dry-run)
--root DIR Add a scan root (repeatable). If none, uses $HOME.
--skip-home Do not touch home-level files (~/.beads, ~/.config/bd, ~/.claude, ~/.gemini)
--skip-binary Do not remove the bd binary or package installs
-h, --help Show this help
Examples:
./scripts/uninstall.sh
./scripts/uninstall.sh --apply
./scripts/uninstall.sh --root ~/src --apply
EOF
}
log() {
printf '[beads-uninstall] %s\n' "$*"
}
warn() {
printf '[beads-uninstall] WARN: %s\n' "$*" >&2
}
run() {
if [[ "$APPLY" -eq 1 ]]; then
log "+ $*"
"$@"
else
log "[dry-run] $*"
fi
}
run_rm() {
local path="$1"
if [[ "$APPLY" -eq 1 ]]; then
if [[ -e "$path" ]]; then
if [[ -w "$(dirname "$path")" ]]; then
log "+ rm -rf $path"
rm -rf "$path"
else
if command -v sudo >/dev/null 2>&1; then
log "+ sudo rm -rf $path"
sudo rm -rf "$path"
else
warn "Need permissions to remove $path (run with sudo)"
fi
fi
fi
else
log "[dry-run] rm -rf $path"
fi
}
run_mv() {
local src="$1"
local dst="$2"
if [[ "$APPLY" -eq 1 ]]; then
log "+ mv $src $dst"
mv "$src" "$dst"
else
log "[dry-run] mv $src $dst"
fi
}
is_beads_hook() {
local file="$1"
[[ -f "$file" ]] || return 1
grep -Eq 'bd-hooks-version:|bd-shim|bd \(beads\)|bd hooks run' "$file"
}
restore_hook_backup() {
local hook="$1"
local restored=0
if [[ -f "${hook}.old" ]]; then
if is_beads_hook "${hook}.old"; then
run_rm "${hook}.old"
else
run_mv "${hook}.old" "$hook"
restored=1
fi
fi
if [[ "$restored" -eq 0 && -f "${hook}.backup" ]]; then
if is_beads_hook "${hook}.backup"; then
run_rm "${hook}.backup"
else
run_mv "${hook}.backup" "$hook"
restored=1
fi
fi
if [[ "$restored" -eq 0 ]]; then
local latest=""
local backups=()
local glob="${hook}.backup-"*
shopt -s nullglob
backups=($glob)
shopt -u nullglob
if [[ "${#backups[@]}" -gt 0 ]]; then
latest=$(ls -t "${backups[@]}" 2>/dev/null | head -n 1)
fi
if [[ -n "$latest" ]]; then
if is_beads_hook "$latest"; then
run_rm "$latest"
else
run_mv "$latest" "$hook"
fi
fi
fi
}
cleanup_hooks_dir() {
local hooks_dir="$1"
[[ -d "$hooks_dir" ]] || return 0
local hook
for hook in pre-commit post-merge pre-push post-checkout prepare-commit-msg; do
local path="$hooks_dir/$hook"
if [[ -f "$path" ]]; then
if is_beads_hook "$path"; then
run_rm "$path"
restore_hook_backup "$path"
fi
fi
done
}
cleanup_gitattributes() {
local repo="$1"
local file="$repo/.gitattributes"
[[ -f "$file" ]] || return 0
local tmp
tmp=$(mktemp)
awk '!/merge=beads/' "$file" > "$tmp"
if ! cmp -s "$file" "$tmp"; then
if [[ -s "$tmp" ]]; then
run_mv "$tmp" "$file"
else
run_rm "$file"
fi
fi
rm -f "$tmp"
}
cleanup_exclude() {
local git_dir="$1"
local file="$git_dir/info/exclude"
[[ -f "$file" ]] || return 0
local tmp
tmp=$(mktemp)
awk '
function trim(s){sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s}
{
t = trim($0)
if (t ~ /^#.*[Bb]eads/) next
if (t == ".beads/") next
if (t == ".beads/issues.jsonl") next
if (t == ".claude/settings.local.json") next
if (t == "**/RECOVERY*.md") next
if (t == "**/SESSION*.md") next
print
}
' "$file" > "$tmp"
if ! cmp -s "$file" "$tmp"; then
if [[ -s "$tmp" ]]; then
run_mv "$tmp" "$file"
else
run_rm "$file"
fi
fi
rm -f "$tmp"
}
cleanup_agents_file() {
local file="$1"
[[ -f "$file" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
warn "python3 not found; skipping AGENTS.md cleanup for $file"
return 0
fi
APPLY="$APPLY" python3 - "$file" <<'PY'
import os, re, sys
path = sys.argv[1]
apply = os.environ.get("APPLY") == "1"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
orig = content
begin = "<!-- BEGIN BEADS INTEGRATION -->"
end = "<!-- END BEADS INTEGRATION -->"
if begin in content and end in content:
pattern = re.compile(r"\n?\s*<!-- BEGIN BEADS INTEGRATION -->.*?<!-- END BEADS INTEGRATION -->\s*\n?", re.S)
content = re.sub(pattern, "\n", content)
heading = "## Landing the Plane (Session Completion)"
if heading in content:
pattern = re.compile(r"\n?## Landing the Plane \(Session Completion\)[\s\S]*?(?=\n## |\Z)")
m = pattern.search(content)
if m:
block = m.group(0)
if "bd sync" in block or "git pull --rebase" in block:
content = content[:m.start()] + "\n" + content[m.end():]
content = re.sub(r"\n{3,}", "\n\n", content)
changed = content != orig
if not changed:
sys.exit(0)
if apply:
if content.strip() == "":
os.remove(path)
print(f"Removed empty AGENTS.md: {path}")
else:
mode = os.stat(path).st_mode
with open(path, "w", encoding="utf-8") as f:
f.write(content.rstrip() + "\n")
os.chmod(path, mode)
print(f"Updated AGENTS.md: {path}")
else:
print(f"Would update AGENTS.md: {path}")
PY
}
cleanup_settings_json() {
local file="$1"
local kind="$2"
[[ -f "$file" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
warn "python3 not found; skipping JSON cleanup for $file"
return 0
fi
APPLY="$APPLY" python3 - "$file" "$kind" <<'PY'
import json, os, re, sys
path = sys.argv[1]
kind = sys.argv[2]
apply = os.environ.get("APPLY") == "1"
with open(path, "r", encoding="utf-8") as f:
try:
data = json.load(f)
except Exception as e:
print(f"Skipping {path}: failed to parse JSON ({e})")
sys.exit(0)
changed = False
targets = {"bd prime", "bd prime --stealth"}
if kind == "claude":
events = ["SessionStart", "PreCompact"]
else:
events = ["SessionStart", "PreCompress"]
hooks = data.get("hooks")
if isinstance(hooks, dict):
for event in list(events):
event_hooks = hooks.get(event)
if not isinstance(event_hooks, list):
continue
new_event_hooks = []
for hook in event_hooks:
if not isinstance(hook, dict):
new_event_hooks.append(hook)
continue
cmds = hook.get("hooks")
if not isinstance(cmds, list):
new_event_hooks.append(hook)
continue
new_cmds = []
removed_any = False
for cmd in cmds:
if isinstance(cmd, dict) and cmd.get("command") in targets:
removed_any = True
else:
new_cmds.append(cmd)
if removed_any:
changed = True
if new_cmds:
hook["hooks"] = new_cmds
new_event_hooks.append(hook)
if new_event_hooks != event_hooks:
hooks[event] = new_event_hooks
changed = True
if event in hooks and not hooks[event]:
del hooks[event]
changed = True
if not hooks:
data.pop("hooks", None)
onboard = "Before starting any work, run 'bd onboard' to understand the current project state and available issues."
prompt = data.get("prompt")
if isinstance(prompt, str) and onboard in prompt:
new_prompt = prompt.replace(onboard, "").strip()
new_prompt = re.sub(r"\n{3,}", "\n\n", new_prompt).strip()
if new_prompt:
data["prompt"] = new_prompt
else:
data.pop("prompt", None)
changed = True
if not changed:
sys.exit(0)
if apply:
mode = os.stat(path).st_mode
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=True)
f.write("\n")
os.chmod(path, mode)
print(f"Updated settings: {path}")
else:
print(f"Would update settings: {path}")
PY
}
rmdir_if_empty() {
local dir="$1"
[[ -d "$dir" ]] || return 0
if [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
run_rm "$dir"
fi
}
stop_daemon_pid_file() {
local pid_file="$1"
[[ -f "$pid_file" ]] || return 0
local pid
pid="$(tr -d '[:space:]' < "$pid_file" 2>/dev/null || true)"
[[ "$pid" =~ ^[0-9]+$ ]] || return 0
if ps -p "$pid" >/dev/null 2>&1; then
local cmd
cmd="$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]' || true)"
if [[ "$cmd" == *bd* ]]; then
run kill "$pid"
if [[ "$APPLY" -eq 1 ]]; then
sleep 0.2 || true
if ps -p "$pid" >/dev/null 2>&1; then
run kill -9 "$pid"
fi
fi
else
warn "PID $pid from $pid_file does not look like bd; skipping"
fi
fi
}
cleanup_repo() {
local repo="$1"
[[ -d "$repo" ]] || return 0
log "Cleaning repo: $repo"
# Stop daemon if running
stop_daemon_pid_file "$repo/.beads/daemon.pid"
# Remove project integration files
cleanup_agents_file "$repo/AGENTS.md"
cleanup_settings_json "$repo/.claude/settings.local.json" "claude"
cleanup_settings_json "$repo/.gemini/settings.json" "gemini"
if [[ -f "$repo/.cursor/rules/beads.mdc" ]]; then
run_rm "$repo/.cursor/rules/beads.mdc"
fi
if [[ -f "$repo/.aider.conf.yml" ]]; then
run_rm "$repo/.aider.conf.yml"
fi
if [[ -f "$repo/.aider/BEADS.md" ]]; then
run_rm "$repo/.aider/BEADS.md"
fi
if [[ -f "$repo/.aider/README.md" ]]; then
run_rm "$repo/.aider/README.md"
fi
rmdir_if_empty "$repo/.aider"
rmdir_if_empty "$repo/.cursor/rules"
rmdir_if_empty "$repo/.cursor"
# Git-related cleanup
if command -v git >/dev/null 2>&1; then
local git_common_dir=""
git_common_dir="$(git -C "$repo" rev-parse --git-common-dir 2>/dev/null || true)"
if [[ -n "$git_common_dir" ]]; then
# hooks
cleanup_hooks_dir "$git_common_dir/hooks"
# core.hooksPath -> .beads-hooks
local hooks_path=""
hooks_path="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
if [[ -n "$hooks_path" ]]; then
local abs_hooks_path="$hooks_path"
if [[ "$hooks_path" != /* ]]; then
abs_hooks_path="$repo/$hooks_path"
fi
cleanup_hooks_dir "$abs_hooks_path"
if [[ "$hooks_path" == ".beads-hooks" || "$hooks_path" == */.beads-hooks ]]; then
run git -C "$repo" config --unset core.hooksPath
fi
fi
# merge driver config
if git -C "$repo" config --get merge.beads.driver >/dev/null 2>&1; then
run git -C "$repo" config --unset merge.beads.driver
run git -C "$repo" config --unset merge.beads.name || true
fi
cleanup_gitattributes "$repo"
cleanup_exclude "$git_common_dir"
# sync worktrees
if [[ -d "$git_common_dir/beads-worktrees" ]]; then
run_rm "$git_common_dir/beads-worktrees"
fi
fi
fi
# Remove beads directories
if [[ -d "$repo/.beads-hooks" ]]; then
run_rm "$repo/.beads-hooks"
fi
if [[ -d "$repo/.beads" ]]; then
run_rm "$repo/.beads"
fi
}
cleanup_global_gitignore() {
local ignore_path=""
if command -v git >/dev/null 2>&1; then
ignore_path="$(git config --global core.excludesfile 2>/dev/null || true)"
fi
if [[ -n "$ignore_path" ]]; then
if [[ "$ignore_path" == "~/"* ]]; then
ignore_path="${HOME}/${ignore_path#~/}"
fi
elif [[ -f "$HOME/.config/git/ignore" ]]; then
ignore_path="$HOME/.config/git/ignore"
fi
[[ -f "$ignore_path" ]] || return 0
local tmp
tmp=$(mktemp)
awk '
function trim(s){sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s}
{
t = trim($0)
if (t ~ /Beads stealth mode/) next
if (t ~ /\/\.beads\/$/) next
if (t ~ /\/\.claude\/settings\.local\.json$/) next
print
}
' "$ignore_path" > "$tmp"
if ! cmp -s "$ignore_path" "$tmp"; then
if [[ -s "$tmp" ]]; then
run_mv "$tmp" "$ignore_path"
else
run_rm "$ignore_path"
fi
fi
rm -f "$tmp"
}
cleanup_home() {
if [[ "$SKIP_HOME" -eq 1 ]]; then
return 0
fi
log "Cleaning home-level files"
cleanup_settings_json "$HOME/.claude/settings.json" "claude"
cleanup_settings_json "$HOME/.gemini/settings.json" "gemini"
cleanup_global_gitignore
if [[ -d "$HOME/.beads" ]]; then
run_rm "$HOME/.beads"
fi
if [[ -d "$HOME/.config/bd" ]]; then
run_rm "$HOME/.config/bd"
fi
# Optional: planning repo (only if it looks like a beads repo)
if [[ -d "$HOME/.beads-planning/.beads" ]]; then
run_rm "$HOME/.beads-planning"
fi
}
cleanup_binaries() {
if [[ "$SKIP_BINARY" -eq 1 ]]; then
return 0
fi
log "Checking bd binaries"
local paths_file
paths_file=$(mktemp)
if command -v bd >/dev/null 2>&1; then
if command -v which >/dev/null 2>&1; then
which -a bd 2>/dev/null | sed '/^$/d' >> "$paths_file" || true
else
command -v bd >> "$paths_file" || true
fi
fi
for p in \
"/usr/local/bin/bd" \
"/opt/homebrew/bin/bd" \
"$HOME/.local/bin/bd" \
"$HOME/go/bin/bd" \
; do
if [[ -x "$p" ]]; then
printf '%s\n' "$p" >> "$paths_file"
fi
done
sort -u "$paths_file" | while IFS= read -r p; do
[[ -n "$p" ]] || continue
if [[ -x "$p" ]]; then
if "$p" version >/dev/null 2>&1; then
if "$p" version 2>/dev/null | grep -q "^bd version"; then
run_rm "$p"
else
warn "Skipping $p (not beads)"
fi
else
warn "Skipping $p (cannot execute)"
fi
fi
done
rm -f "$paths_file"
# Package managers (best-effort)
if command -v brew >/dev/null 2>&1; then
if brew list --formula 2>/dev/null | grep -qx "bd"; then
run brew uninstall bd
fi
fi
if command -v npm >/dev/null 2>&1; then
if npm ls -g --depth=0 @beads/bd >/dev/null 2>&1; then
run npm uninstall -g @beads/bd
fi
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--apply)
APPLY=1
;;
--root)
shift
[[ $# -gt 0 ]] || { warn "--root requires a directory"; exit 1; }
ROOTS+=("$1")
;;
--skip-home)
SKIP_HOME=1
;;
--skip-binary)
SKIP_BINARY=1
;;
-h|--help)
usage
exit 0
;;
*)
warn "Unknown argument: $1"
usage
exit 1
;;
esac
shift
done
}
add_repo_root() {
local path="$1"
local dir="$path"
if [[ -f "$path" ]]; then
dir="$(dirname "$path")"
fi
local root=""
if command -v git >/dev/null 2>&1; then
root="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || true)"
fi
if [[ -z "$root" ]]; then
case "$path" in
*/.beads|*/.beads-hooks)
root="$(dirname "$path")"
;;
*)
root="$dir"
;;
esac
fi
printf '%s\n' "$root"
}
scan_roots() {
local roots_file="$1"
local root
for root in "${ROOTS[@]}"; do
[[ -d "$root" ]] || continue
log "Scanning root: $root"
# .beads directories
log " Looking for .beads directories..."
{ rg --files --hidden --no-ignore --null -g '!.git/**' -g '.beads/**' "$root" 2>/dev/null || true; } | while IFS= read -r -d '' file; do
case "$file" in
"$HOME/.beads"/*)
continue
;;
esac
add_repo_root "$file" >> "$roots_file"
done
# .beads-hooks directories
log " Looking for .beads-hooks directories..."
{ rg --files --hidden --no-ignore --null -g '!.git/**' -g '.beads-hooks/**' "$root" 2>/dev/null || true; } | while IFS= read -r -d '' file; do
add_repo_root "$file" >> "$roots_file"
done
# other markers (cursor/aider/claude/gemini)
log " Looking for integration markers..."
{ rg --files --hidden --no-ignore --null -g '!.git/**' \
-g '.aider.conf.yml' \
-g '.cursor/rules/beads.mdc' \
-g '.aider/BEADS.md' \
-g '.aider/README.md' \
-g '.claude/settings.local.json' \
-g '.gemini/settings.json' \
"$root" 2>/dev/null || true; } | while IFS= read -r -d '' file; do
add_repo_root "$file" >> "$roots_file"
done
# AGENTS.md that actually contains beads instructions
{ rg -l --hidden --no-ignore --null -g '!.git/**' -g 'AGENTS.md' \
-e 'Landing the Plane \(Session Completion\)' \
-e 'BEGIN BEADS INTEGRATION' \
"$root" 2>/dev/null || true; } | while IFS= read -r -d '' file; do
add_repo_root "$file" >> "$roots_file"
done
done
}
main() {
parse_args "$@"
if [[ "${#ROOTS[@]}" -eq 0 ]]; then
ROOTS=("$HOME")
fi
if [[ "$APPLY" -eq 1 ]]; then
log "Mode: APPLY (changes will be made)"
else
log "Mode: DRY-RUN (no changes will be made)"
fi
log "Roots:"
for r in "${ROOTS[@]}"; do
log " - $r"
done
log "Scanning for beads modifications (this may take a while on large trees)..."
local roots_file
roots_file=$(mktemp)
scan_roots "$roots_file"
log "Scan complete. Cleaning detected repositories..."
sort -u "$roots_file" | while IFS= read -r repo; do
[[ -n "$repo" ]] || continue
cleanup_repo "$repo"
done
rm -f "$roots_file"
cleanup_home
cleanup_binaries
log "Done. Use --apply to perform changes (this run was dry-run)." || true
if [[ "$APPLY" -eq 1 ]]; then
log "Cleanup complete."
fi
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment