aboutsummaryrefslogtreecommitdiff
path: root/scripts/remove.sh
blob: 3d8b7e4ee1ed1e936c23a7818b166013a5384662 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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 "$@"