#!/usr/bin/env bash # install-skills.sh — Cross-machine Claude skill sync from ai-proj-helper # # Usage: # ./install-skills.sh [options] # # Options: # --dry-run Preview changes without writing anything # --category Only install plugins in dir_category= # Valid values: biz, core, dev, integration, personal, req # --force Overwrite even if local files were modified # --cleanup Remove locally installed skills that are no longer in repo # --list List all available plugins without installing # --help Show this help set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILLS_DIR="${HOME}/.claude/skills" COMMANDS_DIR="${HOME}/.claude/commands" STATE_FILE="${HOME}/.claude/.installed-skills.json" DRY_RUN=false CATEGORY_FILTER="" FORCE=false CLEANUP=false LIST_ONLY=false INSTALL_ACTION=false # ── Colour helpers ───────────────────────────────────────────────────────────── GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BLUE='\033[0;34m' RESET='\033[0m' info() { echo -e "${BLUE}[info]${RESET} $*"; } ok() { echo -e "${GREEN}[ok]${RESET} $*"; } warn() { echo -e "${YELLOW}[warn]${RESET} $*"; } error() { echo -e "${RED}[error]${RESET} $*" >&2; } dry() { echo -e "${YELLOW}[dry]${RESET} $*"; } # ── Argument parsing ─────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true ;; --force) FORCE=true ;; --cleanup) CLEANUP=true ;; --list) LIST_ONLY=true ;; --category) CATEGORY_FILTER="$2"; shift ;; --help|-h) grep '^#' "$0" | grep -v '!/usr' | sed 's/^# \?//' exit 0 ;; *) error "Unknown argument: $1" exit 1 ;; esac shift done # ── State helpers (plain JSON via python3) ───────────────────────────────────── state_get() { # state_get -> prints version or empty string local name="$1" if [[ -f "$STATE_FILE" ]]; then python3 -c " import json,sys try: d=json.load(open('$STATE_FILE')) print(d.get('$name',{}).get('version','')) except: pass " 2>/dev/null || true fi } state_digest() { # state_digest -> prints installed content digest or empty string local name="$1" if [[ -f "$STATE_FILE" ]]; then python3 -c " import json try: d=json.load(open('$STATE_FILE')) print(d.get('$name',{}).get('content_digest','')) except: pass " 2>/dev/null || true fi } state_set() { # state_set local name="$1" ver="$2" itype="$3" digest="$4" python3 -c " import json,os f='$STATE_FILE' d=json.load(open(f)) if os.path.exists(f) else {} d['$name']={'version':'$ver','install_type':'$itype','content_digest':'$digest'} json.dump(d,open(f,'w'),indent=2) " 2>/dev/null } state_remove() { local name="$1" python3 -c " import json,os f='$STATE_FILE' if not os.path.exists(f): exit() d=json.load(open(f)) d.pop('$name',None) json.dump(d,open(f,'w'),indent=2) " 2>/dev/null } state_all_names() { if [[ -f "$STATE_FILE" ]]; then python3 -c " import json d=json.load(open('$STATE_FILE')) for k in d: print(k) " 2>/dev/null || true fi } # ── Plugin discovery ─────────────────────────────────────────────────────────── find_plugins() { find "$REPO_DIR" -path "*/skills-*/*-plugin/.claude-plugin/plugin.json" | sort } read_field() { # read_field python3 -c "import json,sys; d=json.load(open('$1')); print(d.get('$2',''))" 2>/dev/null || true } content_digest() { # Stable digest for one command file or a complete skill directory. python3 - "$1" <<'PY' import hashlib import os import pathlib import sys target = pathlib.Path(sys.argv[1]) if not target.exists(): print("") raise SystemExit digest = hashlib.sha256() files = [target] if target.is_file() else sorted( path for path in target.rglob("*") if path.is_file() or path.is_symlink() ) for path in files: relative = path.name if target.is_file() else path.relative_to(target).as_posix() digest.update(relative.encode("utf-8")) digest.update(b"\0") if path.is_symlink(): digest.update(b"link\0") digest.update(os.readlink(path).encode("utf-8")) else: digest.update(path.read_bytes()) digest.update(b"\0") print(digest.hexdigest()) PY } is_compatible_subset() { # True when every file in an existing legacy target also exists unchanged in # the repository source. This safely upgrades old SKILL.md-only installs. python3 - "$1" "$2" <<'PY' import pathlib import sys source = pathlib.Path(sys.argv[1]) target = pathlib.Path(sys.argv[2]) if not source.is_dir() or not target.is_dir(): raise SystemExit(1) for target_path in target.rglob("*"): if target_path.is_dir(): continue source_path = source / target_path.relative_to(target) if not source_path.is_file() or target_path.is_symlink() != source_path.is_symlink(): raise SystemExit(1) if target_path.is_symlink(): if target_path.readlink() != source_path.readlink(): raise SystemExit(1) elif target_path.read_bytes() != source_path.read_bytes(): raise SystemExit(1) raise SystemExit(0) PY } # Resolve the actual source directory to rsync from. # If skills/ has SKILL.md at the top level, use it directly. # If skills/ has a single subdirectory (e.g. skills/dev-test/SKILL.md), use that subdirectory. resolve_skills_src() { local skills_dir="$1" if [[ -f "$skills_dir/SKILL.md" ]]; then echo "$skills_dir" return fi # Find the first subdirectory that contains SKILL.md local sub sub="$(find "$skills_dir" -maxdepth 2 -name 'SKILL.md' | head -1)" if [[ -n "$sub" ]]; then echo "$(dirname "$sub")" return fi echo "$skills_dir" } # ── Install a single plugin ──────────────────────────────────────────────────── install_plugin() { INSTALL_ACTION=false local json_path="$1" local plugin_dir plugin_dir="$(dirname "$(dirname "$json_path")")" # strip /.claude-plugin/plugin.json local skills_dir="$plugin_dir/skills" local install_name install_type dir_category version install_name="$(read_field "$json_path" install_name)" install_type="$(read_field "$json_path" install_type)" dir_category="$(read_field "$json_path" dir_category)" version="$(read_field "$json_path" version)" # Skip if no install metadata (legacy plugin without our new fields) if [[ -z "$install_name" || -z "$install_type" ]]; then warn "$(basename "$plugin_dir"): missing install_name/install_type, skipping" return fi # Category filter if [[ -n "$CATEGORY_FILTER" && "$dir_category" != "$CATEGORY_FILTER" ]]; then return fi # Verify skills directory exists if [[ ! -d "$skills_dir" ]]; then warn "$install_name: no skills/ directory in plugin, skipping" return fi if [[ "$LIST_ONLY" == true ]]; then echo " [$dir_category] $install_type:$install_name v$version" return fi # Resolve actual source (handles plugins where content sits one level deeper, # e.g. skills/dev-test/SKILL.md instead of skills/SKILL.md). local src_dir src_dir="$(resolve_skills_src "$skills_dir")" local source_path target_path if [[ "$install_type" == "command" ]]; then source_path="$src_dir/SKILL.md" target_path="$COMMANDS_DIR/${install_name}.md" else source_path="$src_dir" target_path="$SKILLS_DIR/$install_name" fi if [[ ! -e "$source_path" ]]; then warn "$install_name: install source not found, skipping" return fi # A recorded content digest distinguishes repository updates from user edits. # Legacy state is adopted automatically only when the target is missing or # already identical to the repository source. local current_version recorded_digest source_digest target_digest current_version="$(state_get "$install_name")" recorded_digest="$(state_digest "$install_name")" source_digest="$(content_digest "$source_path")" target_digest="$(content_digest "$target_path")" if [[ "$FORCE" == false && -n "$target_digest" && "$target_digest" == "$source_digest" ]]; then if [[ "$DRY_RUN" == false && ( "$current_version" != "$version" || "$recorded_digest" != "$source_digest" ) ]]; then state_set "$install_name" "$version" "$install_type" "$source_digest" fi return fi local legacy_subset=false if [[ "$install_type" == "skill" && -z "$recorded_digest" && -n "$target_digest" ]]; then if is_compatible_subset "$source_path" "$target_path"; then legacy_subset=true fi fi if [[ "$FORCE" == false && -n "$target_digest" && "$legacy_subset" == false ]]; then if [[ -z "$recorded_digest" || "$target_digest" != "$recorded_digest" ]]; then warn "$install_name: local files were modified or have legacy unverified state — skipping (use --force once to adopt repository content)" return fi fi # Perform install if [[ "$install_type" == "command" ]]; then # Single-file command → ~/.claude/commands/.md local src_md="$src_dir/SKILL.md" if [[ ! -f "$src_md" ]]; then warn "$install_name: SKILL.md not found, skipping" return fi if [[ "$DRY_RUN" == true ]]; then dry "$install_name → $COMMANDS_DIR/${install_name}.md" INSTALL_ACTION=true else mkdir -p "$COMMANDS_DIR" cp "$src_md" "$COMMANDS_DIR/${install_name}.md" state_set "$install_name" "$version" "$install_type" "$source_digest" ok "$install_name → command (v$version)" INSTALL_ACTION=true fi else # Skill directory → ~/.claude/skills// local dst_dir="$SKILLS_DIR/$install_name" if [[ "$DRY_RUN" == true ]]; then dry "$install_name → $dst_dir/" INSTALL_ACTION=true else mkdir -p "$dst_dir" # rsync resolved source (handles nested skills/ structures) rsync -a --delete "$src_dir/" "$dst_dir/" state_set "$install_name" "$version" "$install_type" "$source_digest" ok "$install_name → skill (v$version)" INSTALL_ACTION=true fi fi } # ── Cleanup removed plugins ──────────────────────────────────────────────────── cleanup_removed() { if [[ "$CLEANUP" == false ]]; then return fi info "Checking for removed plugins to clean up..." # Collect all install_names still in repo local repo_names=() while IFS= read -r json_path; do local name name="$(read_field "$json_path" install_name)" [[ -n "$name" ]] && repo_names+=("$name") done < <(find_plugins) # Check state file for installed plugins no longer in repo while IFS= read -r installed_name; do local found=false for repo_name in "${repo_names[@]}"; do [[ "$repo_name" == "$installed_name" ]] && found=true && break done if [[ "$found" == false ]]; then local itype itype="$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('$installed_name',{}).get('install_type',''))" 2>/dev/null || true)" if [[ "$DRY_RUN" == true ]]; then dry "Would remove: $installed_name ($itype)" else if [[ "$itype" == "command" ]]; then rm -f "$COMMANDS_DIR/${installed_name}.md" else rm -rf "${SKILLS_DIR:?}/$installed_name" fi state_remove "$installed_name" ok "Removed: $installed_name" fi fi done < <(state_all_names) } # ── Main ─────────────────────────────────────────────────────────────────────── main() { if [[ "$LIST_ONLY" == true ]]; then info "Available plugins in $REPO_DIR:" local count=0 while IFS= read -r json_path; do install_plugin "$json_path" ((count++)) || true done < <(find_plugins) echo "" info "Total: $count plugins" return fi info "Installing Claude skills from: $REPO_DIR" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be written" [[ -n "$CATEGORY_FILTER" ]] && info "Category filter: $CATEGORY_FILTER" local installed=0 while IFS= read -r json_path; do install_plugin "$json_path" if [[ "$INSTALL_ACTION" == true ]]; then ((installed++)) || true fi done < <(find_plugins) cleanup_removed echo "" if [[ "$DRY_RUN" == true ]]; then info "Dry run complete. $installed plugins would be installed/updated." else info "Done. $installed plugins installed/updated." info "State saved to: $STATE_FILE" fi } main "$@"