aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 17:38:56 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 17:38:56 -0500
commit95f57618b1e55b910d143fa6d188323a1cc4484f (patch)
tree84cebbba456466ad8c0453f3964481ba813d32f5
parentca6a213841129a72881f17d7050dff3b48c637ac (diff)
downloadrulesets-95f57618b1e55b910d143fa6d188323a1cc4484f.tar.gz
rulesets-95f57618b1e55b910d143fa6d188323a1cc4484f.zip
feat(make): add an interactive remove target with fzf
make remove is the granular counterpart to make uninstall, which removes everything. remove.sh lists every rulesets-managed symlink under ~/.claude/ — only links whose target resolves into the repo, so foreign symlinks are left alone — pipes them through fzf --multi, and rm's the picked links. The repo's own files stay put, and make install re-creates anything removed. It's split into --list and --remove-selected modes so the logic is testable without fzf. 5 bats cases cover the listing, the foreign-link exclusion, removal, report-and-continue on a missing target, and the empty no-op. The removal loop runs without set -e and without rm -f, so a vanished target reports visibly and the rest still process. shellcheck clean, make test green.
-rw-r--r--Makefile5
-rw-r--r--scripts/remove.sh155
-rw-r--r--scripts/tests/remove.bats83
3 files changed, 242 insertions, 1 deletions
diff --git a/Makefile b/Makefile
index 44431dc..2ade6e1 100644
--- a/Makefile
+++ b/Makefile
@@ -75,7 +75,7 @@ $(if $(shell command -v pacman 2>/dev/null),sudo pacman -S --noconfirm $(1),\
$(error No supported package manager found (brew/apt-get/pacman)))))
endef
-.PHONY: help install uninstall list install-hooks uninstall-hooks \
+.PHONY: help install uninstall remove list install-hooks uninstall-hooks \
install-lang install-elisp install-python list-languages \
install-mcp diff lint doctor test deps
@@ -290,6 +290,9 @@ uninstall: ## Remove global symlinks from ~/.claude/
@echo ""
@echo "done"
+remove: ## Interactively pick installed rulesets entries to remove (fzf)
+ @bash scripts/remove.sh
+
list: ## Show global install status
@echo "Skills:"
@for skill in $(SKILLS); do \
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 "$@"
diff --git a/scripts/tests/remove.bats b/scripts/tests/remove.bats
new file mode 100644
index 0000000..bfacf5f
--- /dev/null
+++ b/scripts/tests/remove.bats
@@ -0,0 +1,83 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/remove.sh — interactive removal of rulesets-managed
+# symlinks under ~/.claude/.
+#
+# Strategy (mirrors audit.bats): redirect HOME to a temp dir per test,
+# scaffold ~/.claude/{skills,rules,hooks} with symlinks pointing at REAL
+# repo files, run the real remove.sh against that synthetic ~/.claude/.
+# remove.sh resolves the repo root from its own location, so the "managed"
+# check compares against the real repo regardless of HOME.
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+REMOVE="$REAL_REPO/scripts/remove.sh"
+
+setup() {
+ TEST_HOME="$(mktemp -d -t remove-bats.XXXXXX)"
+ HOME_BAK="$HOME"
+ export HOME="$TEST_HOME"
+ mkdir -p "$TEST_HOME/.claude/skills" \
+ "$TEST_HOME/.claude/rules" \
+ "$TEST_HOME/.claude/hooks"
+
+ # Symlinks into real repo files — exactly what `make install` creates.
+ ln -s "$REAL_REPO/debug" "$TEST_HOME/.claude/skills/debug"
+ ln -s "$REAL_REPO/claude-rules/commits.md" "$TEST_HOME/.claude/rules/commits.md"
+ ln -s "$REAL_REPO/hooks/git-commit-confirm.py" \
+ "$TEST_HOME/.claude/hooks/git-commit-confirm.py"
+}
+
+teardown() {
+ export HOME="$HOME_BAK"
+ rm -rf "$TEST_HOME"
+}
+
+@test "remove --list emits scaffolded skill/rule/hook with correct kinds" {
+ run bash "$REMOVE" --list
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *$'skill\tdebug'* ]]
+ [[ "$output" == *$'rule\tcommits.md'* ]]
+ [[ "$output" == *$'hook\tgit-commit-confirm.py'* ]]
+}
+
+@test "remove --list excludes a foreign symlink pointing outside the repo" {
+ ln -s /etc/hostname "$TEST_HOME/.claude/rules/foreign.md"
+
+ run bash "$REMOVE" --list
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"foreign.md"* ]]
+ # The genuine managed links are still listed.
+ [[ "$output" == *$'rule\tcommits.md'* ]]
+}
+
+@test "remove --remove-selected removes the piped rule and leaves the skill" {
+ run bash -c "printf 'rule\tcommits.md\n' | bash '$REMOVE' --remove-selected"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"rm rule commits.md"* ]]
+ # Rule symlink gone, skill symlink intact.
+ [ ! -L "$TEST_HOME/.claude/rules/commits.md" ]
+ [ -L "$TEST_HOME/.claude/skills/debug" ]
+}
+
+@test "remove --remove-selected reports a missing item but still processes a valid one after it" {
+ # First line names a link that doesn't exist; second is real. The loop
+ # must report the failure visibly and continue to remove the valid one.
+ run bash -c "printf 'rule\tghost.md\nskill\tdebug\n' | bash '$REMOVE' --remove-selected"
+
+ [[ "$output" == *"ghost.md"* ]]
+ [[ "$output" == *"rm skill debug"* ]]
+ [ ! -L "$TEST_HOME/.claude/skills/debug" ]
+}
+
+@test "remove --remove-selected with empty stdin is a clean no-op, exit 0" {
+ run bash -c "printf '' | bash '$REMOVE' --remove-selected"
+
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ # Nothing touched.
+ [ -L "$TEST_HOME/.claude/rules/commits.md" ]
+ [ -L "$TEST_HOME/.claude/skills/debug" ]
+}