From 95f57618b1e55b910d143fa6d188323a1cc4484f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 22 May 2026 17:38:56 -0500 Subject: feat(make): add an interactive remove target with fzf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/remove.sh | 155 ++++++++++++++++++++++++++++++++++++++++++++++ scripts/tests/remove.bats | 83 +++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 scripts/remove.sh create mode 100644 scripts/tests/remove.bats (limited to 'scripts') 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 `\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 "$@" 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" ] +} -- cgit v1.2.3