#!/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 # ── 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_set() { # state_set local name="$1" ver="$2" itype="$3" 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'} 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 } # 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" } # ── Conflict detection (has local been modified since we installed it?) ──────── has_local_modification() { # Returns 0 (true) if local target differs from repo source, 1 if identical or new local install_name="$1" install_type="$2" plugin_skills_dir="$3" if [[ "$install_type" == "command" ]]; then local src="$plugin_skills_dir/SKILL.md" local dst="$COMMANDS_DIR/${install_name}.md" [[ -f "$dst" ]] && ! diff -q "$src" "$dst" &>/dev/null && return 0 else local dst_dir="$SKILLS_DIR/$install_name" if [[ -d "$dst_dir" ]]; then # Compare each file from source while IFS= read -r -d '' src_file; do local rel="${src_file#$plugin_skills_dir/}" local dst_file="$dst_dir/$rel" if [[ -f "$dst_file" ]] && ! diff -q "$src_file" "$dst_file" &>/dev/null; then return 0 fi done < <(find "$plugin_skills_dir" -type f -print0) fi fi return 1 } # ── Install a single plugin ──────────────────────────────────────────────────── install_plugin() { 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 # Check current installed version local current_version current_version="$(state_get "$install_name")" # Skip if up-to-date (same version) and no force if [[ "$current_version" == "$version" && "$FORCE" == false ]]; then return fi # Conflict detection: warn if local files were modified if [[ -n "$current_version" && "$FORCE" == false ]]; then if has_local_modification "$install_name" "$install_type" "$skills_dir"; then warn "$install_name: local files were modified — skipping (use --force to overwrite)" return fi 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")" # 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" else mkdir -p "$COMMANDS_DIR" cp "$src_md" "$COMMANDS_DIR/${install_name}.md" state_set "$install_name" "$version" "$install_type" ok "$install_name → command (v$version)" fi else # Skill directory → ~/.claude/skills// local dst_dir="$SKILLS_DIR/$install_name" if [[ "$DRY_RUN" == true ]]; then dry "$install_name → $dst_dir/" 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" ok "$install_name → skill (v$version)" 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 skipped=0 while IFS= read -r json_path; do local before before="$(state_all_names | wc -l || true)" install_plugin "$json_path" local after after="$(state_all_names | wc -l || true)" if [[ "$after" -gt "$before" ]] || [[ "$DRY_RUN" == 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 "$@"