|
| 1 | +#!/usr/bin/env bash |
| 2 | +# ============================================================================ |
| 3 | +# Universal Claude Code Skill Installer |
| 4 | +# ============================================================================ |
| 5 | +# Usage: |
| 6 | +# curl -fsSL https://raw.githubusercontent.com/OWNER/REPO/main/install.sh | bash |
| 7 | +# bash install.sh # fresh install or upgrade |
| 8 | +# bash install.sh --check-update # check for newer version only |
| 9 | +# |
| 10 | +# Customize the variables below for your skill repository. |
| 11 | +# ============================================================================ |
| 12 | + |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +# --------------------------------------------------------------------------- |
| 16 | +# Skill Configuration (customize these per-repo) |
| 17 | +# --------------------------------------------------------------------------- |
| 18 | +REPO_OWNER="anombyte93" |
| 19 | +REPO_NAME="prd-taskmaster" |
| 20 | +SKILL_NAME="prd-taskmaster" |
| 21 | +VERSION="3.0.0" |
| 22 | +SKILL_DIR="${SKILL_DIR:-${HOME}/.claude/skills/${SKILL_NAME}}" |
| 23 | + |
| 24 | +# --------------------------------------------------------------------------- |
| 25 | +# Internal constants |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | +UPDATES_DIR="${HOME}/.config/claude-skills" |
| 28 | +UPDATES_FILE="${UPDATES_DIR}/updates.json" |
| 29 | +UPDATE_INTERVAL_SECONDS=86400 # 24 hours |
| 30 | +GITHUB_API="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" |
| 31 | +CLONE_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git" |
| 32 | + |
| 33 | +# --------------------------------------------------------------------------- |
| 34 | +# Colors (disabled when piped or in CI) |
| 35 | +# --------------------------------------------------------------------------- |
| 36 | +if [[ -t 1 ]] && [[ -z "${CI:-}" ]] && [[ -z "${NO_COLOR:-}" ]]; then |
| 37 | + RED='\033[0;31m' |
| 38 | + GREEN='\033[0;32m' |
| 39 | + YELLOW='\033[0;33m' |
| 40 | + CYAN='\033[0;36m' |
| 41 | + BOLD='\033[1m' |
| 42 | + RESET='\033[0m' |
| 43 | +else |
| 44 | + RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET='' |
| 45 | +fi |
| 46 | + |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | +# Helpers |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | +info() { printf "${CYAN}[info]${RESET} %s\n" "$*"; } |
| 51 | +ok() { printf "${GREEN}[ok]${RESET} %s\n" "$*"; } |
| 52 | +warn() { printf "${YELLOW}[warn]${RESET} %s\n" "$*"; } |
| 53 | +err() { printf "${RED}[error]${RESET} %s\n" "$*" >&2; } |
| 54 | +die() { err "$@"; exit 1; } |
| 55 | + |
| 56 | +cleanup() { |
| 57 | + if [[ -n "${TMPDIR_SKILL:-}" ]] && [[ -d "${TMPDIR_SKILL}" ]]; then |
| 58 | + rm -rf "${TMPDIR_SKILL}" |
| 59 | + fi |
| 60 | +} |
| 61 | +trap cleanup EXIT |
| 62 | + |
| 63 | +require_cmd() { |
| 64 | + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" |
| 65 | +} |
| 66 | + |
| 67 | +# --------------------------------------------------------------------------- |
| 68 | +# Update check (callable standalone) |
| 69 | +# --------------------------------------------------------------------------- |
| 70 | +check_update() { |
| 71 | + # Respect CI / explicit opt-out |
| 72 | + if [[ -n "${CI:-}" ]] || [[ -n "${NO_UPDATE_CHECK:-}" ]]; then |
| 73 | + return 0 |
| 74 | + fi |
| 75 | + |
| 76 | + require_cmd curl |
| 77 | + require_cmd date |
| 78 | + |
| 79 | + mkdir -p "${UPDATES_DIR}" |
| 80 | + |
| 81 | + # Throttle: only check once per UPDATE_INTERVAL_SECONDS |
| 82 | + if [[ -f "${UPDATES_FILE}" ]]; then |
| 83 | + local last_check |
| 84 | + last_check=$(python3 -c " |
| 85 | +import json, sys |
| 86 | +try: |
| 87 | + d = json.load(open('${UPDATES_FILE}')) |
| 88 | + print(d.get('skills', {}).get('${SKILL_NAME}', {}).get('last_check', 0)) |
| 89 | +except Exception: |
| 90 | + print(0) |
| 91 | +" 2>/dev/null || echo 0) |
| 92 | + |
| 93 | + local now |
| 94 | + now=$(date +%s) |
| 95 | + local elapsed=$(( now - last_check )) |
| 96 | + |
| 97 | + if [[ ${elapsed} -lt ${UPDATE_INTERVAL_SECONDS} ]]; then |
| 98 | + # Within cooldown -- read cached result |
| 99 | + local cached_latest |
| 100 | + cached_latest=$(python3 -c " |
| 101 | +import json |
| 102 | +try: |
| 103 | + d = json.load(open('${UPDATES_FILE}')) |
| 104 | + print(d.get('skills', {}).get('${SKILL_NAME}', {}).get('latest', '')) |
| 105 | +except Exception: |
| 106 | + print('') |
| 107 | +" 2>/dev/null || echo "") |
| 108 | + |
| 109 | + if [[ -n "${cached_latest}" ]] && [[ "${cached_latest}" != "${VERSION}" ]]; then |
| 110 | + warn "Update available: ${VERSION} -> ${cached_latest}" |
| 111 | + info "Run: curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main/install.sh | bash" |
| 112 | + fi |
| 113 | + return 0 |
| 114 | + fi |
| 115 | + fi |
| 116 | + |
| 117 | + # Fetch latest release from GitHub API |
| 118 | + local api_response |
| 119 | + api_response=$(curl -fsSL --max-time 5 \ |
| 120 | + -H "Accept: application/vnd.github+json" \ |
| 121 | + "${GITHUB_API}" 2>/dev/null) || { |
| 122 | + # Network failure is non-fatal for update checks |
| 123 | + return 0 |
| 124 | + } |
| 125 | + |
| 126 | + local latest_version |
| 127 | + latest_version=$(printf '%s' "${api_response}" | python3 -c " |
| 128 | +import json, sys |
| 129 | +try: |
| 130 | + data = json.load(sys.stdin) |
| 131 | + tag = data.get('tag_name', '') |
| 132 | + # Strip leading 'v' if present |
| 133 | + print(tag.lstrip('v')) |
| 134 | +except Exception: |
| 135 | + print('') |
| 136 | +" 2>/dev/null || echo "") |
| 137 | + |
| 138 | + if [[ -z "${latest_version}" ]]; then |
| 139 | + return 0 |
| 140 | + fi |
| 141 | + |
| 142 | + # Write cache |
| 143 | + local now |
| 144 | + now=$(date +%s) |
| 145 | + python3 -c " |
| 146 | +import json, os |
| 147 | +
|
| 148 | +path = '${UPDATES_FILE}' |
| 149 | +try: |
| 150 | + data = json.load(open(path)) |
| 151 | +except Exception: |
| 152 | + data = {} |
| 153 | +
|
| 154 | +data.setdefault('skills', {}) |
| 155 | +data['skills']['${SKILL_NAME}'] = { |
| 156 | + 'last_check': ${now}, |
| 157 | + 'latest': '${latest_version}', |
| 158 | + 'current': '${VERSION}' |
| 159 | +} |
| 160 | +
|
| 161 | +with open(path, 'w') as f: |
| 162 | + json.dump(data, f, indent=2) |
| 163 | +" 2>/dev/null || true |
| 164 | + |
| 165 | + if [[ "${latest_version}" != "${VERSION}" ]]; then |
| 166 | + warn "Update available: ${VERSION} -> ${latest_version}" |
| 167 | + info "Run: curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main/install.sh | bash" |
| 168 | + else |
| 169 | + ok "You are on the latest version (${VERSION})" |
| 170 | + fi |
| 171 | +} |
| 172 | + |
| 173 | +# --------------------------------------------------------------------------- |
| 174 | +# Install logic |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | +install_skill() { |
| 177 | + local mode="install" |
| 178 | + |
| 179 | + info "Claude Code Skill Installer" |
| 180 | + info "Skill: ${BOLD}${SKILL_NAME}${RESET} v${VERSION}" |
| 181 | + printf "\n" |
| 182 | + |
| 183 | + require_cmd git |
| 184 | + |
| 185 | + # ------------------------------------------------------------------ |
| 186 | + # Detect upgrade vs fresh install |
| 187 | + # ------------------------------------------------------------------ |
| 188 | + if [[ -d "${SKILL_DIR}" ]]; then |
| 189 | + mode="upgrade" |
| 190 | + local existing_version="unknown" |
| 191 | + if [[ -f "${SKILL_DIR}/.version" ]]; then |
| 192 | + existing_version=$(head -1 "${SKILL_DIR}/.version" 2>/dev/null || echo "unknown") |
| 193 | + fi |
| 194 | + info "Existing installation detected (${existing_version})" |
| 195 | + info "Mode: upgrade" |
| 196 | + |
| 197 | + # Back up SKILL.md if it exists |
| 198 | + if [[ -f "${SKILL_DIR}/SKILL.md" ]]; then |
| 199 | + cp "${SKILL_DIR}/SKILL.md" "${SKILL_DIR}/SKILL.md.bak" |
| 200 | + ok "Backed up SKILL.md -> SKILL.md.bak" |
| 201 | + fi |
| 202 | + else |
| 203 | + info "Mode: fresh install" |
| 204 | + fi |
| 205 | + |
| 206 | + # ------------------------------------------------------------------ |
| 207 | + # Clone repo to temp directory |
| 208 | + # ------------------------------------------------------------------ |
| 209 | + TMPDIR_SKILL=$(mktemp -d "${TMPDIR:-/tmp}/claude-skill-XXXXXX") |
| 210 | + info "Cloning ${REPO_OWNER}/${REPO_NAME}..." |
| 211 | + |
| 212 | + git clone --depth 1 --quiet "${CLONE_URL}" "${TMPDIR_SKILL}/repo" 2>/dev/null \ |
| 213 | + || die "Failed to clone repository. Check REPO_OWNER/REPO_NAME and network." |
| 214 | + |
| 215 | + # ------------------------------------------------------------------ |
| 216 | + # Locate skill source within the repo |
| 217 | + # ------------------------------------------------------------------ |
| 218 | + # Convention: skill files live at repo root OR under a directory named |
| 219 | + # after the skill. We check both. |
| 220 | + local src_dir="${TMPDIR_SKILL}/repo" |
| 221 | + if [[ -d "${TMPDIR_SKILL}/repo/${SKILL_NAME}" ]]; then |
| 222 | + src_dir="${TMPDIR_SKILL}/repo/${SKILL_NAME}" |
| 223 | + fi |
| 224 | + |
| 225 | + # Verify at minimum SKILL.md exists |
| 226 | + if [[ ! -f "${src_dir}/SKILL.md" ]]; then |
| 227 | + die "SKILL.md not found in repository (checked ${src_dir}). Cannot install." |
| 228 | + fi |
| 229 | + |
| 230 | + # ------------------------------------------------------------------ |
| 231 | + # Copy skill files to SKILL_DIR |
| 232 | + # ------------------------------------------------------------------ |
| 233 | + mkdir -p "${SKILL_DIR}" |
| 234 | + |
| 235 | + # Core file: SKILL.md (always required) |
| 236 | + cp "${src_dir}/SKILL.md" "${SKILL_DIR}/SKILL.md" |
| 237 | + ok "Installed SKILL.md" |
| 238 | + |
| 239 | + # Optional: script.py (or any .py entrypoint) |
| 240 | + if [[ -f "${src_dir}/script.py" ]]; then |
| 241 | + cp "${src_dir}/script.py" "${SKILL_DIR}/script.py" |
| 242 | + ok "Installed script.py" |
| 243 | + fi |
| 244 | + |
| 245 | + # Optional: templates/ directory |
| 246 | + if [[ -d "${src_dir}/templates" ]]; then |
| 247 | + rm -rf "${SKILL_DIR}/templates" |
| 248 | + cp -r "${src_dir}/templates" "${SKILL_DIR}/templates" |
| 249 | + ok "Installed templates/" |
| 250 | + fi |
| 251 | + |
| 252 | + # Optional: reference/ directory |
| 253 | + if [[ -d "${src_dir}/reference" ]]; then |
| 254 | + rm -rf "${SKILL_DIR}/reference" |
| 255 | + cp -r "${src_dir}/reference" "${SKILL_DIR}/reference" |
| 256 | + ok "Installed reference/" |
| 257 | + fi |
| 258 | + |
| 259 | + # Optional: install.sh itself (so the skill is self-updating) |
| 260 | + if [[ -f "${src_dir}/install.sh" ]]; then |
| 261 | + cp "${src_dir}/install.sh" "${SKILL_DIR}/install.sh" |
| 262 | + chmod +x "${SKILL_DIR}/install.sh" |
| 263 | + fi |
| 264 | + |
| 265 | + # ------------------------------------------------------------------ |
| 266 | + # Write .version file |
| 267 | + # ------------------------------------------------------------------ |
| 268 | + local timestamp |
| 269 | + timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') |
| 270 | + cat > "${SKILL_DIR}/.version" <<VEOF |
| 271 | +${VERSION} |
| 272 | +installed: ${timestamp} |
| 273 | +mode: ${mode} |
| 274 | +repo: ${REPO_OWNER}/${REPO_NAME} |
| 275 | +VEOF |
| 276 | + ok "Wrote .version (${VERSION}, ${timestamp})" |
| 277 | + |
| 278 | + # ------------------------------------------------------------------ |
| 279 | + # Success |
| 280 | + # ------------------------------------------------------------------ |
| 281 | + printf "\n" |
| 282 | + printf "${GREEN}${BOLD}Successfully %s ${SKILL_NAME} v${VERSION}${RESET}\n" \ |
| 283 | + "$([ "${mode}" = "upgrade" ] && echo "upgraded" || echo "installed")" |
| 284 | + printf " Location: %s\n" "${SKILL_DIR}" |
| 285 | + |
| 286 | + if [[ "${mode}" = "upgrade" ]] && [[ -f "${SKILL_DIR}/SKILL.md.bak" ]]; then |
| 287 | + printf " Backup: %s\n" "${SKILL_DIR}/SKILL.md.bak" |
| 288 | + fi |
| 289 | + |
| 290 | + printf "\n" |
| 291 | + info "To use this skill in Claude Code, reference it with:" |
| 292 | + printf " ${CYAN}/%s${RESET}\n" "${SKILL_NAME}" |
| 293 | + printf "\n" |
| 294 | +} |
| 295 | + |
| 296 | +# --------------------------------------------------------------------------- |
| 297 | +# Entrypoint |
| 298 | +# --------------------------------------------------------------------------- |
| 299 | +main() { |
| 300 | + case "${1:-}" in |
| 301 | + --check-update|-u) |
| 302 | + check_update |
| 303 | + ;; |
| 304 | + --version|-v) |
| 305 | + echo "${SKILL_NAME} v${VERSION}" |
| 306 | + ;; |
| 307 | + --help|-h) |
| 308 | + printf "Usage: %s [--check-update | --version | --help]\n" "${0##*/}" |
| 309 | + printf "\n" |
| 310 | + printf " (no args) Install or upgrade the skill\n" |
| 311 | + printf " --check-update Check GitHub for a newer release\n" |
| 312 | + printf " --version Print current version\n" |
| 313 | + printf " --help Show this help\n" |
| 314 | + ;; |
| 315 | + "") |
| 316 | + install_skill |
| 317 | + check_update |
| 318 | + ;; |
| 319 | + *) |
| 320 | + die "Unknown argument: $1 (try --help)" |
| 321 | + ;; |
| 322 | + esac |
| 323 | +} |
| 324 | + |
| 325 | +main "$@" |
0 commit comments