diff options
Diffstat (limited to 'scripts/remove.sh')
| -rw-r--r-- | scripts/remove.sh | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/scripts/remove.sh b/scripts/remove.sh new file mode 100644 index 0000000..3d8b7e4 --- /dev/null +++ b/scripts/remove.sh @@ -0,0 +1,155 @@ +#!/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 `<kind>\t<name>` line per +# currently-installed managed symlink. +# remove.sh --remove-selected Read `<kind>\t<name>` 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 `<kind>\t<name>` 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<name>` 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 `<kind>\t<name>` 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 "$@" |
