#!/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."