#!/usr/bin/env bash # # Install the justloopit Agent Skill. # # Two modes, picked automatically: # # From a checkout ./install.sh # symlinks this directory into every skills dir found, so # `git pull` keeps the installed copy live. # # From the web curl -fsSL https://justloopit.dev/install.sh | bash # downloads the tarball, checks its sha256, and copies it in. # # Flags: # --to install into this skills directory only # --project install into ./.claude/skills (this project, not your home) # --copy copy instead of symlinking, even from a checkout # --url tarball to fetch (implies downloading, even in a checkout) # --remote download the published skill instead of using this checkout # --force replace an existing install without keeping a backup # --quiet only print failures # # Exit codes: 0 installed · 1 nothing installed · 2 bad usage or missing tool. # set -euo pipefail NAME="justloopit" BASE_URL="${JUSTLOOPIT_BASE_URL:-https://justloopit.dev}" TARBALL_URL="${JUSTLOOPIT_TARBALL_URL:-}" MODE="" # link | copy — decided below TARGETS=() FORCE=0 QUIET=0 REMOTE=0 # 1 = download even when run from a checkout say() { [ "$QUIET" = 1 ] || echo "$@"; } die() { echo "FATAL $1" >&2; exit "${2:-2}"; } # die [exit code] while [ $# -gt 0 ]; do case "$1" in --to) [ $# -ge 2 ] || die "--to needs a directory"; TARGETS+=("$2"); shift 2 ;; --project) TARGETS+=("$PWD/.claude/skills"); shift ;; --copy) MODE="copy"; shift ;; --url) [ $# -ge 2 ] || die "--url needs a URL"; TARBALL_URL="$2"; REMOTE=1; shift 2 ;; --remote) REMOTE=1; shift ;; --force) FORCE=1; shift ;; --quiet) QUIET=1; shift ;; -h|--help) sed -n '2,25p' "$0" 2>/dev/null || echo "see https://justloopit.dev"; exit 0 ;; *) die "unknown flag: $1" ;; esac done # --- where the skill is coming from ------------------------------------------ # A checkout has SKILL.md next to this script. Piped from curl it does not, so # the source is the published tarball instead. SRC="" if [ "$REMOTE" = 0 ] && [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -f "$here/SKILL.md" ]; then SRC="$here"; fi fi CLEANUP="" trap 'if [ -n "$CLEANUP" ]; then rm -rf "$CLEANUP"; fi' EXIT fetch() { # fetch — 0 on success, 1 if the URL is not there if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" else die "need curl or wget to download the skill" fi } sha256_of() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' fi } if [ -z "$SRC" ]; then command -v tar >/dev/null 2>&1 || die "need tar to unpack the skill" url="${TARBALL_URL:-$BASE_URL/$NAME.tar.gz}" CLEANUP="$(mktemp -d)" say "→ downloading $url" fetch "$url" "$CLEANUP/$NAME.tar.gz" || die "could not download $url" 1 # Checksum. Published alongside the tarball; a mismatch is fatal, a missing # file is only a warning so a mirror without one still installs. expected="" if fetch "$url.sha256" "$CLEANUP/sha" 2>/dev/null; then expected="$(awk '{print $1}' "$CLEANUP/sha")" fi actual="$(sha256_of "$CLEANUP/$NAME.tar.gz" || true)" if [ -n "$expected" ] && [ -n "$actual" ] && [ "$expected" != "$actual" ]; then die "checksum mismatch for $url — expected $expected, got $actual" 1 elif [ -z "$expected" ]; then say " (no published checksum found — skipping verification)" fi tar -xzf "$CLEANUP/$NAME.tar.gz" -C "$CLEANUP" || die "tarball did not unpack" 1 [ -f "$CLEANUP/$NAME/SKILL.md" ] || die "tarball has no $NAME/SKILL.md — wrong archive?" 1 SRC="$CLEANUP/$NAME" MODE="copy" # nothing to link to; the temp dir disappears on exit fi [ -f "$SRC/SKILL.md" ] || die "no SKILL.md in $SRC" [ -n "$MODE" ] || MODE="link" # --- where it is going ------------------------------------------------------- if [ "${#TARGETS[@]}" -eq 0 ]; then # Standard Agent Skills locations. Only ones that already exist are used; # if none do, ~/.claude/skills is created, because "download and use it" # should not stop to ask for a mkdir. for dir in "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.cursor/skills" \ "$HOME/.config/goose/skills" "$HOME/.config/opencode/skills"; do if [ -d "$dir" ]; then TARGETS+=("$dir"); fi done if [ "${#TARGETS[@]}" -eq 0 ]; then TARGETS+=("$HOME/.claude/skills"); fi fi version="$(sed -n 's/^[[:space:]]*version:[[:space:]]*//p' "$SRC/SKILL.md" | head -1)" installed=0 for dir in "${TARGETS[@]}"; do mkdir -p "$dir" || { echo "✗ cannot create $dir" >&2; continue; } target="$dir/$NAME" if [ -L "$target" ]; then if [ "$MODE" = "link" ] && [ "$(readlink "$target")" = "$SRC" ]; then say "✓ already linked: $target" installed=$((installed + 1)) continue fi say "→ replacing the symlink at $target (was → $(readlink "$target"))" rm "$target" elif [ -e "$target" ]; then # Ours gets replaced outright — it is a download away. Anything else that # happens to sit at this path is moved aside, never deleted. if [ "$FORCE" = 1 ] || grep -qs "^name: $NAME\$" "$target/SKILL.md"; then rm -rf "$target" else backup="$target.bak.$(date +%Y%m%d%H%M%S)" mv "$target" "$backup" say "→ $target was not a $NAME install — kept it at $backup" fi fi if [ "$MODE" = "link" ]; then ln -s "$SRC" "$target" say "✓ linked: $target → $SRC" else # Stage then move, so a half-copied skill is never visible to an agent. staging="$dir/.$NAME.staging.$$" rm -rf "$staging" mkdir -p "$staging" (cd "$SRC" && tar -cf - --exclude .DS_Store --exclude .git .) | (cd "$staging" && tar -xf -) mv "$staging" "$target" say "✓ installed: $target" fi installed=$((installed + 1)) done [ "$installed" -gt 0 ] || die "nothing was installed" 1 say say "$NAME ${version:+$version }is installed in $installed location(s)." say "Start a new agent session, then run /$NAME — or just describe work you are" say "about to repeat and it triggers on its own."