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) <noreply@anthropic.com>
This commit is contained in:
119
scripts/check-skills
Executable file
119
scripts/check-skills
Executable file
@@ -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
|
||||||
72
specs/check-skills.spec.md
Normal file
72
specs/check-skills.spec.md
Normal file
@@ -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)
|
||||||
125
tests/test-check-skills.sh
Executable file
125
tests/test-check-skills.sh
Executable file
@@ -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 ]]
|
||||||
Reference in New Issue
Block a user