Skip to content

Instantly share code, notes, and snippets.

@banteg
Last active January 21, 2026 06:56
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 "$@"
@raine
Copy link

raine commented Jan 21, 2026

[beads-uninstall] [dry-run] rm -rf /Users/raine/code/Lolgato/.aider.conf.yml

Why does the script want to remove .aider.conf.yml? It has nothing to do with beads

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment