#!/usr/bin/env bash # install-skills.sh — Cross-agent skill sync from ai-proj-helper # # Usage: # ./install-skills.sh [options] # # Options: # --agent Install target: codex (default) or claude # --dry-run Preview changes without writing anything # --category Only install plugins in dir_category= # Valid values: biz, core, dev, integration, personal, req # --exclude Skip one install_name (repeatable) # --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="" COMMANDS_DIR="" STATE_FILE="" AGENT_TARGET="codex" DRY_RUN=false CATEGORY_FILTER="" EXCLUDED_NAMES=() 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 --agent) [[ $# -ge 2 ]] || { error "--agent requires codex or claude"; exit 1; } AGENT_TARGET="$2"; shift ;; --dry-run) DRY_RUN=true ;; --force) FORCE=true ;; --cleanup) CLEANUP=true ;; --list) LIST_ONLY=true ;; --category) [[ $# -ge 2 ]] || { error "--category requires a value"; exit 1; } CATEGORY_FILTER="$2"; shift ;; --exclude) [[ $# -ge 2 ]] || { error "--exclude requires an install_name"; exit 1; } EXCLUDED_NAMES+=("$2"); shift ;; --help|-h) grep '^#' "$0" | grep -v '!/usr' | sed 's/^# \?//' exit 0 ;; *) error "Unknown argument: $1" exit 1 ;; esac shift done case "$AGENT_TARGET" in codex) # ~/.agents/skills is the current user-level Codex discovery location and # is intentionally agent-neutral. Commands are installed as normal skills. SKILLS_DIR="${AI_PROJ_HELPER_SKILLS_DIR:-${HOME}/.agents/skills}" STATE_FILE="${AI_PROJ_HELPER_STATE_FILE:-${HOME}/.agents/.ai-proj-helper-installed-skills.json}" ;; claude) SKILLS_DIR="${AI_PROJ_HELPER_SKILLS_DIR:-${HOME}/.claude/skills}" COMMANDS_DIR="${AI_PROJ_HELPER_COMMANDS_DIR:-${HOME}/.claude/commands}" STATE_FILE="${AI_PROJ_HELPER_STATE_FILE:-${HOME}/.claude/.installed-skills.json}" ;; *) error "Unsupported agent: $AGENT_TARGET (expected codex or claude)" exit 1 ;; esac # ── 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','agent':'$AGENT_TARGET'} 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: # A single-file command is renamed when installed for Claude. Hash its # content under a stable logical name so source and target compare equally. relative = "." 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)" local excluded for excluded in ${EXCLUDED_NAMES[@]+"${EXCLUDED_NAMES[@]}"}; do [[ "$install_name" == "$excluded" ]] && return done # 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 local effective_install_type="$install_type" if [[ "$AGENT_TARGET" == "codex" ]]; then effective_install_type="skill" fi if [[ "$LIST_ONLY" == true ]]; then echo " [$dir_category] $effective_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 [[ "$effective_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" "$effective_install_type" "$source_digest" fi return fi local legacy_subset=false if [[ "$effective_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 [[ "$effective_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" "$effective_install_type" "$source_digest" ok "$install_name → command (v$version)" INSTALL_ACTION=true fi else # Standard skill directory → the selected agent's discovery root. 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 --checksum --delete "$src_dir/" "$dst_dir/" state_set "$install_name" "$version" "$effective_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 skills for $AGENT_TARGET 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 "$@"