#!/usr/bin/env bash # remove.sh — interactively remove individual rulesets-managed symlinks. # # The granular counterpart to `make uninstall` (which removes everything). # Lists currently-installed, rulesets-managed symlinks under ~/.claude/, lets # the user fzf-multi-pick, and rm's the chosen links. The repo's own files are # never touched; `make install` re-creates anything removed here. # # Three modes (the split keeps the logic testable without fzf): # remove.sh --list Print one `\t` line per # currently-installed managed symlink. # remove.sh --remove-selected Read `\t` lines from stdin and # rm each one's symlink. # remove.sh Interactive: pipe --list through fzf, feed the # selection into the removal logic. # # A symlink is "managed" only when its resolved target lives inside this repo, # so foreign symlinks in ~/.claude/ are left alone. set -uo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}" SKILLS_DIR="${SKILLS_DIR:-$CLAUDE_DIR/skills}" RULES_DIR="${RULES_DIR:-$CLAUDE_DIR/rules}" HOOKS_DIR="${HOOKS_DIR:-$CLAUDE_DIR/hooks}" # True when $1 is a symlink whose resolved target is inside the repo root. managed_link() { local link="$1" target [ -L "$link" ] || return 1 target="$(readlink -f "$link" 2>/dev/null)" || return 1 [ -n "$target" ] || return 1 case "$target" in "$REPO"/*) return 0 ;; *) return 1 ;; esac } # Print `\t` for every managed symlink in $dir (non-recursive). list_dir() { local kind="$1" dir="$2" link name [ -d "$dir" ] || return 0 for link in "$dir"/* "$dir"/.*; do [ -e "$link" ] || [ -L "$link" ] || continue name="$(basename "$link")" case "$name" in .|..) continue ;; esac if managed_link "$link"; then printf '%s\t%s\n' "$kind" "$name" fi done } # Print `config\t` for managed symlinks directly under ~/.claude/ — # the config entries (settings.json, .mcp.json, commands, ...). The skills/ # rules/ hooks/ subdirs are their own kinds, so skip them here. list_config() { local link name [ -d "$CLAUDE_DIR" ] || return 0 for link in "$CLAUDE_DIR"/* "$CLAUDE_DIR"/.*; do [ -e "$link" ] || [ -L "$link" ] || continue name="$(basename "$link")" case "$name" in .|..|skills|rules|hooks) continue ;; esac if managed_link "$link"; then printf '%s\t%s\n' "config" "$name" fi done } cmd_list() { list_dir skill "$SKILLS_DIR" list_dir rule "$RULES_DIR" list_dir hook "$HOOKS_DIR" list_config } # Map a kind to the absolute symlink path for a given name. path_for() { local kind="$1" name="$2" case "$kind" in skill) printf '%s\n' "$SKILLS_DIR/$name" ;; rule) printf '%s\n' "$RULES_DIR/$name" ;; hook) printf '%s\n' "$HOOKS_DIR/$name" ;; config) printf '%s\n' "$CLAUDE_DIR/$name" ;; *) return 1 ;; esac } # Read `\t` lines from stdin and rm each symlink. Failures are # visible and the loop continues — no `set -e`, no `rm -f` masking. cmd_remove_selected() { local kind name path while IFS=$'\t' read -r kind name _; do [ -n "$kind" ] || continue [ -n "$name" ] || continue path="$(path_for "$kind" "$name")" || { echo " ERROR unknown kind: $kind" >&2 continue } if rm "$path" 2>/dev/null; then printf ' rm %s %s\n' "$kind" "$name" else printf ' ERROR could not remove %s %s (%s)\n' "$kind" "$name" "$path" >&2 fi done } cmd_interactive() { if ! command -v fzf >/dev/null 2>&1; then echo "ERROR: fzf is not installed (needed for interactive removal)" >&2 echo " Install fzf, or drive removal via:" >&2 echo " bash scripts/remove.sh --list | ... | bash scripts/remove.sh --remove-selected" >&2 exit 1 fi local listing selection listing="$(cmd_list)" if [ -z "$listing" ]; then echo "Nothing installed to remove." exit 0 fi # --with-nth=1,2 shows both columns; tab is the field delimiter fzf uses. selection="$(printf '%s\n' "$listing" \ | fzf --multi --with-nth=1,2 \ --prompt="Remove> " \ --header="TAB to multi-select, Enter to confirm, Esc to cancel" \ 2>/dev/null)" || true if [ -z "$selection" ]; then echo "Nothing selected. No changes made." exit 0 fi printf '%s\n' "$selection" | cmd_remove_selected } main() { case "${1:-}" in --list) cmd_list ;; --remove-selected) cmd_remove_selected ;; "") cmd_interactive ;; *) echo "Usage: remove.sh [--list | --remove-selected]" >&2 exit 2 ;; esac } main "$@"