From cbde292dd70e978be85a00ed70f7b3f7058c99fa Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 17 Mar 2026 11:14:25 +1300 Subject: [PATCH] Add check-skills: verify skill symlinks match source repo Compares installed skill symlinks against the source directory, reporting missing, stale, and orphan entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/check-skills | 119 +++++++++++++++++++++++++++++++++++ specs/check-skills.spec.md | 72 +++++++++++++++++++++ tests/test-check-skills.sh | 125 +++++++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+) create mode 100755 scripts/check-skills create mode 100644 specs/check-skills.spec.md create mode 100755 tests/test-check-skills.sh diff --git a/scripts/check-skills b/scripts/check-skills new file mode 100755 index 0000000..b6f51ed --- /dev/null +++ b/scripts/check-skills @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check which skills from the source repo are missing from the active Claude profile. + +DEFAULT_SKILLS_REPO="$HOME/dev/claude/projects/custom-claude-skills/skills" + +usage() { + cat <<'EOF' +Usage: check-skills [OPTIONS] [SKILLS_REPO] + +Check which skills from the custom-claude-skills repository are missing +from the active Claude Code profile's skills directory. + +Arguments: + SKILLS_REPO Path to skills source directory + (default: ~/dev/claude/projects/custom-claude-skills/skills) + +Options: + --profile DIR Override Claude profile directory + (default: $CLAUDE_CONFIG_DIR or ~/.claude) + --dryrun, -n Accepted for convention compliance (script is read-only) + --help, -h Show this help +EOF +} + +# Colours +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' + +# Parse args +profile_dir="" +skills_repo="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) profile_dir="$2"; shift 2 ;; + --dryrun|-n) shift ;; + --help|-h) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + *) skills_repo="$1"; shift ;; + esac +done + +skills_repo="${skills_repo:-$DEFAULT_SKILLS_REPO}" +profile_dir="${profile_dir:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}}" +profile_skills="$profile_dir/skills" + +# Validate source +if [[ ! -d "$skills_repo" ]]; then + echo -e "${RED}ERROR${NC} Skills source directory not found: $skills_repo" >&2 + exit 1 +fi + +# Display paths (use ~ shorthand for readability) +display_profile="${profile_skills/#$HOME/~}" +display_source="${skills_repo/#$HOME/~}" +echo -e "${BOLD}Profile:${NC} $display_profile" +echo -e "${BOLD}Source:${NC} $display_source" +echo "" + +linked=0 +missing=0 +stale=0 +orphan=0 + +# Check each source skill +for skill_dir in "$skills_repo"/*/; do + [[ -d "$skill_dir" ]] || continue + [[ -f "$skill_dir/SKILL.md" ]] || continue + + skill_name="$(basename "$skill_dir")" + target="$profile_skills/$skill_name" + + if [[ -L "$target" ]]; then + actual="$(readlink -f "$target" 2>/dev/null || echo "BROKEN")" + expected="$(readlink -f "$skill_dir")" + if [[ "$actual" == "$expected" ]]; then + echo -e " ${GREEN}LINKED${NC} $skill_name" + linked=$((linked + 1)) + else + echo -e " ${YELLOW}STALE${NC} $skill_name (points to $actual)" + stale=$((stale + 1)) + fi + elif [[ -e "$target" ]]; then + echo -e " ${YELLOW}STALE${NC} $skill_name (non-symlink exists)" + stale=$((stale + 1)) + else + echo -e " ${YELLOW}MISSING${NC} $skill_name" + missing=$((missing + 1)) + fi +done + +# Check for orphans in profile +if [[ -d "$profile_skills" ]]; then + for entry in "$profile_skills"/*/; do + [[ -d "$entry" || -L "$entry" ]] || continue + entry_name="$(basename "$entry")" + if [[ ! -d "$skills_repo/$entry_name" ]]; then + echo -e " ${CYAN}ORPHAN${NC} $entry_name (not in source repo)" + orphan=$((orphan + 1)) + fi + done +fi + +echo "" +echo -e "Summary: ${GREEN}$linked linked${NC}, ${YELLOW}$missing missing${NC}, ${YELLOW}$stale stale${NC}, ${CYAN}$orphan orphan${NC}" + +if [[ $missing -gt 0 || $stale -gt 0 ]]; then + echo "" + echo "Run 'install.sh' from the custom-claude-skills repo to fix missing/stale skills." + exit 1 +fi + +exit 0 diff --git a/specs/check-skills.spec.md b/specs/check-skills.spec.md new file mode 100644 index 0000000..4d6b62b --- /dev/null +++ b/specs/check-skills.spec.md @@ -0,0 +1,72 @@ +# check-skills + +## Purpose + +Check which skills from the custom-claude-skills repository are missing from the active Claude Code profile's skills directory. + +## Usage + +``` +check-skills [OPTIONS] [SKILLS_REPO] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `SKILLS_REPO` | `~/dev/claude/projects/custom-claude-skills/skills` | Path to the skills source directory | + +### Options + +| Flag | Description | +|------|-------------| +| `--profile DIR` | Override the Claude profile directory (default: `$CLAUDE_CONFIG_DIR` or `~/.claude`) | +| `--dryrun`, `-n` | Same as normal mode (script is read-only by nature) | +| `--help`, `-h` | Show usage | + +## Behaviour + +1. Resolve the skills source directory (the repo's `skills/` folder) +2. Resolve the active profile's skills directory (`$CLAUDE_CONFIG_DIR/skills` or `~/.claude/skills`) +3. For each subdirectory in the source that contains a `SKILL.md`: + - Check if a symlink exists in the profile's skills directory pointing to that source + - Classify as: **linked** (symlink exists and points to correct source), **stale** (symlink exists but points elsewhere or is broken), or **missing** (no entry in profile skills dir) +4. Also check for entries in the profile's skills directory that don't correspond to any source skill — classify as **orphan** +5. Print a summary report + +## Output Format + +``` +Profile: ~/.claude-octopus +Source: ~/dev/claude/projects/custom-claude-skills/skills + + LINKED reflect + LINKED log + LINKED reflect-logs + MISSING housekeeping + STALE old-skill (points to /some/other/path) + ORPHAN manual-skill (not in source repo) + +Summary: 3 linked, 1 missing, 1 stale, 1 orphan +``` + +- **LINKED** — green, no action needed +- **MISSING** — yellow, skill exists in repo but not linked in profile +- **STALE** — yellow, symlink target doesn't match expected source +- **ORPHAN** — cyan, exists in profile but not in source repo (informational, not an error) + +Exit code: +- `0` if no missing or stale skills +- `1` if any missing or stale skills found + +## Dryrun Behaviour + +Identical to normal mode — this script is read-only and never modifies anything. The `--dryrun` flag is accepted for convention compliance but has no effect. + +## Edge Cases + +- Skills source directory doesn't exist → error message, exit 1 +- Profile skills directory doesn't exist → treat all skills as missing +- Broken symlink in profile → classify as stale +- `CLAUDE_CONFIG_DIR` not set → fall back to `~/.claude` +- Subdirectory in source without `SKILL.md` → skip (not a valid skill) diff --git a/tests/test-check-skills.sh b/tests/test-check-skills.sh new file mode 100755 index 0000000..95296cd --- /dev/null +++ b/tests/test-check-skills.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test check-skills script + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/../scripts/check-skills" + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +pass=0 +fail=0 + +assert_exit() { + local desc="$1" expected="$2" + shift 2 + local actual + if "$@" >/dev/null 2>&1; then actual=0; else actual=$?; fi + if [[ "$actual" -eq "$expected" ]]; then + echo -e " ${GREEN}PASS${NC} $desc" + pass=$((pass + 1)) + else + echo -e " ${RED}FAIL${NC} $desc (expected exit $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +assert_output_contains() { + local desc="$1" pattern="$2" + shift 2 + local output + output="$("$@" 2>&1)" || true + if echo "$output" | grep -qE "$pattern"; then + echo -e " ${GREEN}PASS${NC} $desc" + pass=$((pass + 1)) + else + echo -e " ${RED}FAIL${NC} $desc (pattern '$pattern' not found in output)" + fail=$((fail + 1)) + fi +} + +# Setup temp dirs +TMPDIR_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_ROOT"' EXIT + +# Create a fake skills source with two skills +FAKE_SOURCE="$TMPDIR_ROOT/source" +mkdir -p "$FAKE_SOURCE/skill-a" "$FAKE_SOURCE/skill-b" "$FAKE_SOURCE/not-a-skill" +echo "---" > "$FAKE_SOURCE/skill-a/SKILL.md" +echo "---" > "$FAKE_SOURCE/skill-b/SKILL.md" +# not-a-skill has no SKILL.md — should be skipped + +# Test 1: All linked +echo "Test: All skills linked" +FAKE_PROFILE="$TMPDIR_ROOT/profile-all" +mkdir -p "$FAKE_PROFILE/skills" +ln -s "$FAKE_SOURCE/skill-a" "$FAKE_PROFILE/skills/skill-a" +ln -s "$FAKE_SOURCE/skill-b" "$FAKE_PROFILE/skills/skill-b" +assert_exit "exits 0 when all linked" 0 "$SCRIPT" --profile "$FAKE_PROFILE" "$FAKE_SOURCE" +assert_output_contains "shows LINKED for skill-a" "LINKED.*skill-a" "$SCRIPT" --profile "$FAKE_PROFILE" "$FAKE_SOURCE" +assert_output_contains "shows LINKED for skill-b" "LINKED.*skill-b" "$SCRIPT" --profile "$FAKE_PROFILE" "$FAKE_SOURCE" +assert_output_contains "summary shows 2 linked" "2 linked" "$SCRIPT" --profile "$FAKE_PROFILE" "$FAKE_SOURCE" + +# Test 2: Missing skill +echo "Test: Missing skill" +FAKE_PROFILE2="$TMPDIR_ROOT/profile-missing" +mkdir -p "$FAKE_PROFILE2/skills" +ln -s "$FAKE_SOURCE/skill-a" "$FAKE_PROFILE2/skills/skill-a" +assert_exit "exits 1 when skill missing" 1 "$SCRIPT" --profile "$FAKE_PROFILE2" "$FAKE_SOURCE" +assert_output_contains "shows MISSING for skill-b" "MISSING.*skill-b" "$SCRIPT" --profile "$FAKE_PROFILE2" "$FAKE_SOURCE" + +# Test 3: Stale symlink (points elsewhere) +echo "Test: Stale symlink" +FAKE_PROFILE3="$TMPDIR_ROOT/profile-stale" +mkdir -p "$FAKE_PROFILE3/skills" +ln -s "$FAKE_SOURCE/skill-a" "$FAKE_PROFILE3/skills/skill-a" +ln -s "/some/other/path" "$FAKE_PROFILE3/skills/skill-b" +assert_exit "exits 1 when stale" 1 "$SCRIPT" --profile "$FAKE_PROFILE3" "$FAKE_SOURCE" +assert_output_contains "shows STALE for skill-b" "STALE.*skill-b" "$SCRIPT" --profile "$FAKE_PROFILE3" "$FAKE_SOURCE" + +# Test 4: Orphan skill +echo "Test: Orphan skill" +FAKE_PROFILE4="$TMPDIR_ROOT/profile-orphan" +mkdir -p "$FAKE_PROFILE4/skills/extra-skill" +ln -s "$FAKE_SOURCE/skill-a" "$FAKE_PROFILE4/skills/skill-a" +ln -s "$FAKE_SOURCE/skill-b" "$FAKE_PROFILE4/skills/skill-b" +assert_exit "exits 0 (orphans are informational)" 0 "$SCRIPT" --profile "$FAKE_PROFILE4" "$FAKE_SOURCE" +assert_output_contains "shows ORPHAN for extra-skill" "ORPHAN.*extra-skill" "$SCRIPT" --profile "$FAKE_PROFILE4" "$FAKE_SOURCE" + +# Test 5: No profile skills dir +echo "Test: No profile skills directory" +FAKE_PROFILE5="$TMPDIR_ROOT/profile-empty" +mkdir -p "$FAKE_PROFILE5" +assert_exit "exits 1 when no skills dir (all missing)" 1 "$SCRIPT" --profile "$FAKE_PROFILE5" "$FAKE_SOURCE" +assert_output_contains "shows MISSING for both" "2 missing" "$SCRIPT" --profile "$FAKE_PROFILE5" "$FAKE_SOURCE" + +# Test 6: Invalid source dir +echo "Test: Invalid source directory" +assert_exit "exits 1 for nonexistent source" 1 "$SCRIPT" --profile "$FAKE_PROFILE" "/nonexistent/path" + +# Test 7: --help +echo "Test: Help flag" +assert_exit "--help exits 0" 0 "$SCRIPT" --help +assert_output_contains "--help shows usage" "Usage:" "$SCRIPT" --help + +# Test 8: --dryrun accepted +echo "Test: Dryrun flag" +assert_exit "--dryrun accepted" 0 "$SCRIPT" --dryrun --profile "$FAKE_PROFILE" "$FAKE_SOURCE" + +# Test 9: Skips dirs without SKILL.md +echo "Test: Skips non-skill directories" +output="$("$SCRIPT" --profile "$FAKE_PROFILE" "$FAKE_SOURCE" 2>&1)" +if echo "$output" | grep -q "not-a-skill"; then + echo -e " ${RED}FAIL${NC} should not list not-a-skill" + fail=$((fail + 1)) +else + echo -e " ${GREEN}PASS${NC} correctly skips dir without SKILL.md" + pass=$((pass + 1)) +fi + +echo "" +echo "Results: $pass passed, $fail failed" +[[ $fail -eq 0 ]]