Skip to content

Instantly share code, notes, and snippets.

@ichoosetoaccept
Forked from banteg/readme.md
Last active February 6, 2026 22:49
Show Gist options
  • Select an option

  • Save ichoosetoaccept/f083720a5a6e15af2ea1bb36040a4003 to your computer and use it in GitHub Desktop.

Select an option

Save ichoosetoaccept/f083720a5a6e15af2ea1bb36040a4003 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 — repositories, home directory, binaries, git hooks, config files, and more.

Quick Start

1. Clone the repository

git clone https://gist.github.com/f083720a5a6e15af2ea1bb36040a4003.git beads-uninstall
cd beads-uninstall

2. Make the script executable

chmod +x uninstall.sh

3. Run a dry-run first (safe — no changes are made)

./uninstall.sh

This scans your $HOME directory and shows everything that would be removed, without touching anything.

4. Review the output

Look through the dry-run output. Each item is prefixed with [dry-run] so you can see exactly what will happen.

5. Apply the cleanup

Once you're satisfied:

./uninstall.sh --apply

6. (Optional) Migrate to tk first

If you want to preserve your beads tickets by migrating them to tk before uninstalling:

./uninstall.sh --migrate-tk --apply

This will automatically install tk (via Homebrew if available), run tk migrate-beads in each detected repo, and then proceed with the beads cleanup.

7. (Optional) Target specific directories

If you only want to clean specific project directories:

./uninstall.sh --root ~/myproject --skip-home --skip-binary --apply

Features

  • Dry-run by default — no changes unless you pass --apply
  • Migrate to tk — optional --migrate-tk flag preserves tickets by migrating to tk before cleanup
  • Colorized output — clear visual feedback with section headers and action indicators
  • Safe file handling — only removes files that are actually beads-related (e.g. .aider.conf.yml is checked for beads content before removal)
  • Backup restoration — restores original git hook backups when removing beads hooks
  • Summary report — shows total action count at the end

What It Cleans Up

Per-Repository

Target Details
Daemon Stops any running bd daemon process
Directories Removes .beads/ and .beads-hooks/
AGENTS.md Strips beads-injected sections (preserves the rest)
AI tool configs Cleans .claude/settings.local.json, .gemini/settings.json
Cursor Removes .cursor/rules/beads.mdc
Aider Removes .aider/BEADS.md; only removes .aider.conf.yml and .aider/README.md if they contain beads content
Git hooks Removes beads hooks, restores backups if available
.gitattributes Strips merge=beads lines
.git/info/exclude Removes beads-related entries
.git/config Unsets core.hooksPath (if .beads-hooks) and removes merge.beads.* driver config
Worktrees Removes beads-worktrees directory from git common dir

Home Directory

  • ~/.beads/ and ~/.config/bd/
  • Beads hooks from ~/.claude/settings.json and ~/.gemini/settings.json
  • Beads entries from global gitignore
  • ~/.beads-planning/ (only if it contains a .beads subdirectory)

Binaries

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

Options

Flag Description
--apply Actually perform deletions (otherwise dry-run)
--migrate-tk Migrate beads tickets to tk before uninstalling
--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

# Dry-run: see what would be cleaned
./uninstall.sh

# Migrate tickets to tk, then clean everything
./uninstall.sh --migrate-tk --apply

# Clean everything (without migrating)
./uninstall.sh --apply

# Clean a specific project only
./uninstall.sh --root ~/myproject --skip-home --skip-binary --apply

# Clean multiple projects
./uninstall.sh --root ~/project1 --root ~/project2 --apply

Troubleshooting

If you still see beads artifacts after running the script, check these manual steps:

Git hooks not fully removed?

rm -rf .git/hooks/

Merge driver still in .git/config? Open .git/config and remove the [merge "beads"] section:

[merge "beads"]
    driver = bd merge %A %O %A %B
    name = bd JSONL merge driver

Known Limitations

The script handles the vast majority of beads artifacts automatically. In rare edge cases you may still need to:

  • Manually remove git hooks if beads installed hooks that don't match the known signature patterns (bd-hooks-version, bd-shim, bd hooks run). Check with: grep -r beads .git/hooks/
  • Check .git/config if beads was configured through a mechanism other than git config (e.g. manual editing). The script only removes merge.beads.* and core.hooksPath entries.
  • Shell profile entries — the script does not modify ~/.bashrc, ~/.zshrc, or similar. If you added bd to your PATH manually, remove that line yourself.

Requirements

  • bash 4.0+
  • ripgrep (rg) — used for fast filesystem scanning
  • python3 — used for JSON/AGENTS.md cleanup
  • git — for git config and hook cleanup

Attribution

Original script by banteg. This fork adds colorized output, automatic tk migration, bug fixes (safe .aider.conf.yml handling, improved hook cleanup), and documentation improvements.

Fixes incorporated from community feedback by raine and ouachitalabs.

#!/usr/bin/env bash
#
# Beads (bd) uninstall/cleanup script (macOS/Linux).
# Dry-run by default; pass --apply to actually delete/modify files.
#
# Usage:
# ./uninstall.sh # dry-run (scan $HOME)
# ./uninstall.sh --apply # perform cleanup
# ./uninstall.sh --migrate-tk --apply # migrate to tk, then cleanup
# ./uninstall.sh --root DIR --apply
# ./uninstall.sh --skip-home --skip-binary
#
set -euo pipefail
# ── Colors ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
DIM='\033[2m' RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
fi
APPLY=0
SKIP_HOME=0
SKIP_BINARY=0
MIGRATE_TK=0
ROOTS=()
# ── Stats ────────────────────────────────────────────────────────────────
STAT_REMOVED=0
STAT_CHANGED=0
STAT_DAEMONS=0
STAT_BINARIES=0
MIGRATE_COUNT=0
CLEANED_REPOS=()
CACHE_FILE="/tmp/beads-uninstall-repos.txt"
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)
--migrate-tk Migrate beads tickets to tk (ticket) before uninstalling
--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:
./uninstall.sh
./uninstall.sh --apply
./uninstall.sh --migrate-tk --apply
./uninstall.sh --root ~/src --apply
EOF
}
log() {
printf '%b[beads-uninstall]%b %s\n' "$CYAN" "$RESET" "$*"
}
log_section() {
printf '\n%b══ %s%b\n' "${BOLD}${BLUE}" "$*" "$RESET"
}
log_action() {
printf ' %b✓%b %s\n' "$GREEN" "$RESET" "$*"
}
log_skip() {
printf ' %b· %s%b\n' "$DIM" "$*" "$RESET"
}
warn() {
printf '%b[beads-uninstall] WARN:%b %s\n' "$YELLOW" "$RESET" "$*" >&2
}
run() {
if [[ "$APPLY" -eq 1 ]]; then
log_action "$*"
"$@"
else
log_skip "[dry-run] $*"
fi
# Track stats based on command type
case "$1" in
kill) (( STAT_DAEMONS++ )) || true ;;
git) (( STAT_CHANGED++ )) || true ;;
brew|npm) (( STAT_BINARIES++ )) || true ;;
esac
}
run_rm() {
local path="$1"
if [[ "$APPLY" -eq 1 ]]; then
if [[ -e "$path" ]]; then
if [[ -w "$(dirname "$path")" ]]; then
log_action "rm -rf $path"
rm -rf "$path"
else
if command -v sudo >/dev/null 2>&1; then
log_action "sudo rm -rf $path"
sudo rm -rf "$path"
else
warn "Need permissions to remove $path (run with sudo)"
return 0
fi
fi
(( STAT_REMOVED++ )) || true
fi
else
log_skip "[dry-run] rm -rf $path"
(( STAT_REMOVED++ )) || true
fi
}
run_mv() {
local src="$1"
local dst="$2"
if [[ "$APPLY" -eq 1 ]]; then
log_action "mv $src $dst"
mv "$src" "$dst"
else
log_skip "[dry-run] mv $src $dst"
fi
(( STAT_CHANGED++ )) || true
}
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=()
shopt -s nullglob
backups=("${hook}".backup-*)
shopt -u nullglob
if [[ "${#backups[@]}" -gt 0 ]]; then
latest=$(find "${backups[@]}" -maxdepth 0 -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)
# macOS fallback: find -printf is GNU-only
if [[ -z "$latest" ]]; then
latest=$(stat -f '%m %N' "${backups[@]}" 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)
fi
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 post-commit; 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
# Also clean any leftover beads backups even if the main hook is gone
local bak
for bak in "${path}.old" "${path}.backup" "${path}".backup-*; do
if [[ -f "$bak" ]] && is_beads_hook "$bak"; then
run_rm "$bak"
fi
done
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
CLEANED_REPOS+=("$repo")
log_section "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
if grep -qi 'beads\|bd ' "$repo/.aider.conf.yml" 2>/dev/null; then
run_rm "$repo/.aider.conf.yml"
fi
fi
if [[ -f "$repo/.aider/BEADS.md" ]]; then
run_rm "$repo/.aider/BEADS.md"
fi
if [[ -f "$repo/.aider/README.md" ]]; then
if grep -qi 'beads\|bd ' "$repo/.aider/README.md" 2>/dev/null; then
run_rm "$repo/.aider/README.md"
fi
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_section "Home directory cleanup"
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_section "Binary cleanup"
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
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"
(( STAT_BINARIES++ )) || true
else
warn "Skipping $p (not beads)"
fi
else
warn "Skipping $p (cannot execute)"
fi
fi
done < <(sort -u "$paths_file")
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
}
# ── tk (ticket) migration ────────────────────────────────────────────────
ensure_tk_installed() {
if command -v tk >/dev/null 2>&1; then
return 0
fi
log_section "Installing tk (ticket)"
if command -v brew >/dev/null 2>&1; then
log "Installing via Homebrew..."
brew tap wedow/tools 2>/dev/null
brew install ticket 2>/dev/null
if command -v tk >/dev/null 2>&1; then
log_action "tk installed successfully"
return 0
fi
fi
warn "Could not auto-install tk. Install it manually:"
warn " brew tap wedow/tools && brew install ticket"
warn " OR: git clone https://github.com/wedow/ticket.git && ln -s \"\$PWD/ticket/ticket\" ~/.local/bin/tk"
return 1
}
migrate_repos_to_tk() {
local roots_file="$1"
[[ "$MIGRATE_TK" -eq 1 ]] || return 0
log_section "Migrate beads → tk"
# Count repos with beads data to migrate
local repos_with_beads=()
while IFS= read -r repo; do
[[ -n "$repo" ]] || continue
if [[ -f "$repo/.beads/issues.jsonl" ]]; then
repos_with_beads+=("$repo")
fi
done < <(sort -u "$roots_file")
if [[ "${#repos_with_beads[@]}" -eq 0 ]]; then
log "No repos with beads ticket data found."
return 0
fi
printf ' Found %b%s%b repo(s) with beads tickets to migrate\n' "$BOLD" "${#repos_with_beads[@]}" "$RESET"
if [[ "$APPLY" -eq 0 ]]; then
for repo in "${repos_with_beads[@]}"; do
log_skip "[dry-run] tk migrate-beads in $repo"
done
printf '\n %bTip:%b Run with %b--migrate-tk --apply%b to migrate and uninstall.\n' "$YELLOW" "$RESET" "$BOLD" "$RESET"
return 0
fi
if ! ensure_tk_installed; then
warn "Skipping migration (tk not available). Beads data will still be removed."
return 0
fi
for repo in "${repos_with_beads[@]}"; do
log "Migrating: $repo"
local output
if output=$(cd "$repo" && tk migrate-beads 2>&1); then
local count
count=$(printf '%s\n' "$output" | grep -c '^Migrated:' || true)
log_action "Migrated $count ticket(s) in $(basename "$repo")"
(( MIGRATE_COUNT += count )) || true
else
warn "Migration failed for $repo: $output"
fi
done
}
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")
;;
--migrate-tk)
MIGRATE_TK=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"
{ 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
{ 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
{ 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
}
print_summary() {
local verb="removed"
[[ "$APPLY" -eq 1 ]] || verb="to remove"
printf '\n%b── Summary ──────────────────────────────────────────%b\n' "${BOLD}${CYAN}" "$RESET"
# Project list
if [[ "${#CLEANED_REPOS[@]}" -gt 0 ]]; then
printf ' %bRepos cleaned:%b\n' "$BOLD" "$RESET"
for repo in "${CLEANED_REPOS[@]}"; do
printf ' %b├─%b %s\n' "$DIM" "$RESET" "$repo"
done
fi
# Migration stats
if [[ "$MIGRATE_COUNT" -gt 0 ]]; then
printf ' %b✓%b Migrated %b%s%b ticket(s) to tk\n' "$GREEN" "$RESET" "$BOLD" "$MIGRATE_COUNT" "$RESET"
fi
# Breakdown
local total=$(( STAT_REMOVED + STAT_CHANGED + STAT_DAEMONS + STAT_BINARIES ))
if [[ "$total" -gt 0 ]]; then
printf ' %bBreakdown:%b\n' "$BOLD" "$RESET"
[[ "$STAT_REMOVED" -eq 0 ]] || printf ' %b%s%b file(s)/dir(s) %s\n' "$BOLD" "$STAT_REMOVED" "$RESET" "$verb"
[[ "$STAT_CHANGED" -eq 0 ]] || printf ' %b%s%b file(s) modified\n' "$BOLD" "$STAT_CHANGED" "$RESET"
[[ "$STAT_DAEMONS" -eq 0 ]] || printf ' %b%s%b daemon(s) stopped\n' "$BOLD" "$STAT_DAEMONS" "$RESET"
[[ "$STAT_BINARIES" -eq 0 ]] || printf ' %b%s%b binary/package removal(s)\n' "$BOLD" "$STAT_BINARIES" "$RESET"
fi
# Final status
if [[ "$APPLY" -eq 1 ]]; then
printf '\n %b✓ beads is no more.%b\n' "${GREEN}${BOLD}" "$RESET"
else
printf '\n %bDry-run complete.%b\n' "${YELLOW}${BOLD}" "$RESET"
printf ' Run with %b--apply%b to execute changes.\n' "$BOLD" "$RESET"
if [[ "$MIGRATE_TK" -eq 0 ]] && [[ "$total" -gt 0 ]]; then
printf ' Add %b--migrate-tk%b to migrate tickets to tk before uninstalling.\n' "$BOLD" "$RESET"
fi
fi
printf '\n'
}
main() {
parse_args "$@"
if [[ "${#ROOTS[@]}" -eq 0 ]]; then
ROOTS=("$HOME")
fi
printf '\n%b ╔══════════════════════════════════╗%b\n' "${BOLD}${CYAN}" "$RESET"
printf '%b ║ Beads (bd) Uninstall Script ║%b\n' "${BOLD}${CYAN}" "$RESET"
printf '%b ╚══════════════════════════════════╝%b\n\n' "${BOLD}${CYAN}" "$RESET"
if [[ "$APPLY" -eq 1 ]]; then
printf ' %bMode: APPLY%b ─ changes will be made\n' "${RED}${BOLD}" "$RESET"
else
printf ' %bMode: DRY-RUN%b ─ no changes will be made\n' "${GREEN}${BOLD}" "$RESET"
fi
printf ' %bRoots:%b\n' "$BOLD" "$RESET"
for r in "${ROOTS[@]}"; do
printf ' %b├─%b %s\n' "$DIM" "$RESET" "$r"
done
# Scan or reuse cache
local roots_file
roots_file=$(mktemp)
if [[ "$APPLY" -eq 1 ]] && [[ -f "$CACHE_FILE" ]] && [[ -s "$CACHE_FILE" ]]; then
log "Using cached scan from previous dry-run ($CACHE_FILE)"
cp "$CACHE_FILE" "$roots_file"
else
log "Scanning for beads traces (this may take a while on large trees)..."
scan_roots "$roots_file"
fi
# Save cache on dry-run for next --apply to reuse
if [[ "$APPLY" -eq 0 ]]; then
sort -u "$roots_file" > "$CACHE_FILE"
log "Saved scan results to $CACHE_FILE"
fi
# Migrate to tk before cleanup (if requested)
migrate_repos_to_tk "$roots_file"
# Clean each detected repo
while IFS= read -r repo; do
[[ -n "$repo" ]] || continue
cleanup_repo "$repo"
done < <(sort -u "$roots_file")
rm -f "$roots_file"
cleanup_home
cleanup_binaries
# Remove cache after successful apply
if [[ "$APPLY" -eq 1 ]] && [[ -f "$CACHE_FILE" ]]; then
rm -f "$CACHE_FILE"
fi
print_summary
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment