#!/usr/bin/env bash # install.sh — bootstrap installer for the skills library. # # Usage (one-liner, hosts without Task): # curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash # # Pinned to a specific tag: # SKILLS_REF=v0.4.0 bash install.sh # # Idempotent: re-running pulls the latest changes for the configured ref # (default: main) and re-wires the links. # # Claude Code is NOT wired here. It installs this repo as a plugin from the # marketplace this repo also hosts: # # /plugin marketplace add https://git.d-ma.be/mathias/skills.git # /plugin install skills@mathias # # That path carries the hooks and MCP server definitions too, which symlinks # cannot. This script covers the harnesses with no plugin system: Crush, Pi, # OpenCode, and Antigravity. # # Wiring model — one managed directory, one link per harness: # # ~/.agents/skills/tdd -> $CHECKOUT_DIR/skills/tdd (canonical; Pi reads the dir directly) # ~/.agents/skills/xlsx -> $OTHER_SOURCE/xlsx # ~/.config/crush/skills -> ~/.agents/skills # ~/.config/opencode/skills -> ~/.agents/skills # # Antigravity has no symlink-directory discovery — it reads a path-entries # JSON instead (~/.gemini/config/skills.json: # {"entries":[{"path":"..."}]}). One entry points at ~/.agents/skills, so it # sees exactly the same surfaces-filtered, collision-resolved set every other # harness does. Requires jq; skipped with a warning if jq is unavailable — # every other target in this script stays dependency-free on purpose. # # ~/.agents/skills was itself a link to this checkout until #27. That encoded # an assumption nobody had stated — that this checkout is the ONLY source of # skills on the host — and on flamingo the only way to install this library # was to displace Anthropic's official set, which lived in Crush's directory. # A real directory of per-skill links serves several sources at once: total # links are N + one per harness, still not N x harnesses (#23's actual point). # # Extra sources come from SKILLS_EXTRA_SOURCES, a colon-separated list of # directories each holding /SKILL.md. The checkout is always first and # always wins a name collision — but the collision is logged, never resolved # in silence, because quietly picking one is how stale content wins. # # CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or # cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a # fleet whose workspace is shared between macOS (/Users/...) and Linux # (/home/...), an absolute link stored there is broken on one side by # construction. See mathias/skills#23. set -euo pipefail REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}" REF="${SKILLS_REF:-main}" CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}" AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}" CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}" OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}" ANTIGRAVITY_SKILLS_JSON="${ANTIGRAVITY_SKILLS_JSON:-$HOME/.gemini/config/skills.json}" EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}" # Recorded beside the managed directory rather than inside it: a stray file in # there would look like a skill to every harness reading the dir. SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources" log() { printf '[skills] %s\n' "$*"; } ensure_checkout() { # Wiring an existing working checkout (task install) must not fetch or # move HEAD — that would discard in-progress work. if [ "${SKILLS_SKIP_FETCH:-}" = "1" ]; then [ -d "$CHECKOUT_DIR/skills" ] || { log "no skills/ under $CHECKOUT_DIR"; exit 1; } log "using $CHECKOUT_DIR as-is (SKILLS_SKIP_FETCH=1)" return fi if [ -d "$CHECKOUT_DIR/.git" ]; then log "updating $CHECKOUT_DIR" git -C "$CHECKOUT_DIR" fetch --tags --quiet git -C "$CHECKOUT_DIR" checkout --quiet "$REF" if git -C "$CHECKOUT_DIR" symbolic-ref -q HEAD >/dev/null; then # on a branch — fast-forward git -C "$CHECKOUT_DIR" pull --ff-only --quiet fi else log "cloning $REPO_URL into $CHECKOUT_DIR (ref=$REF)" mkdir -p "$(dirname "$CHECKOUT_DIR")" git clone --quiet "$REPO_URL" "$CHECKOUT_DIR" git -C "$CHECKOUT_DIR" checkout --quiet "$REF" fi } count_skills() { find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' ' } # sources — every configured source directory, primary first, one per line. sources() { printf '%s\n' "$CHECKOUT_DIR/skills" [ -n "$EXTRA_SOURCES" ] || return 0 # printf '%s\n' deliberately: without the trailing newline `read` hits EOF # on the final entry and the loop body never runs for it. printf '%s\n' "$EXTRA_SOURCES" | tr ':' '\n' | while IFS= read -r src; do [ -n "$src" ] || continue if [ -d "$src" ]; then printf '%s\n' "$src" else log "extra source $src does not exist — skipping" fi done } # prepare_agents_dir — the canonical location is a real directory now. A host # still on the single-link wiring is migrated in place. prepare_agents_dir() { if [ -L "$AGENTS_DIR" ]; then log "migrating $AGENTS_DIR from the single-link wiring to a per-skill directory" rm -f "$AGENTS_DIR" elif [ -e "$AGENTS_DIR" ] && [ ! -d "$AGENTS_DIR" ]; then log "REFUSING to replace $AGENTS_DIR — it exists and is not a directory" return 1 fi mkdir -p "$AGENTS_DIR" } # wire_skills — one symlink per skill, from every configured source. wire_skills() { local src_list desired name src dir link target src_list="$(mktemp)" sources > "$src_list" desired="" while IFS= read -r src; do [ -d "$src" ] || continue for dir in "$src"/*/; do [ -f "$dir/SKILL.md" ] || continue name="$(basename "$dir")" target="${dir%/}" # A skill declares its surfaces; this installer serves exactly one of # them. A chat-only skill linked into Crush can never fire and competes # for selection with ones that can (#29). A source with no declaration # is foreign — Anthropic's official set has no reason to know this # convention — so silence means "link it". surfaces="$(sed -n 's/^surfaces:[[:space:]]*//p' "$dir/SKILL.md" | head -1)" if [ -n "$surfaces" ] && ! printf '%s' "$surfaces" | tr -d '[]' | tr ',' ' ' | grep -qw library; then log "skipping $name — surfaces: $surfaces does not include the library" continue fi if printf '%s' "$desired" | grep -qx -- "$name"; then log "COLLISION: $name is offered by $target too; keeping $AGENTS_DIR/$name -> $(readlink "$AGENTS_DIR/$name" 2>/dev/null || echo '?')" continue fi link="$AGENTS_DIR/$name" if [ -L "$link" ]; then if [ "$(readlink "$link")" = "$target" ]; then desired="$desired$name " continue fi rm -f "$link" elif [ -e "$link" ]; then log "unmanaged: $link is not a symlink — left in place; move it aside to let $target take the name" continue fi ln -s "$target" "$link" desired="$desired$name " done done < "$src_list" rm -f "$src_list" # Removing a skill from a source must remove its link, or the previous # generation's problem (links resolving to nothing, invisible to git) # returns in a new shape. for link in "$AGENTS_DIR"/*; do [ -e "$link" ] || [ -L "$link" ] || continue name="$(basename "$link")" if [ ! -L "$link" ]; then log "unmanaged: $link is not a symlink and no source offers it — left in place" continue fi printf '%s' "$desired" | grep -qx -- "$name" && continue rm -f "$link" log "pruned $name — no configured source offers it any more" done sources > "$SOURCES_MANIFEST" # Reported through a global, not stdout: wire_skills logs collisions and # unmanaged entries as it goes, and capturing its stdout for a count would # swallow exactly the warnings this change exists to surface. LINKED_COUNT=$(printf '%s' "$desired" | grep -c . || true) } # link_dir # Replaces whatever is there — including a directory left behind by the old # per-skill wiring — with a single symlink. link_dir() { local link="$1" target="$2" if [ -L "$link" ]; then [ "$(readlink "$link")" = "$target" ] && return rm -f "$link" elif [ -d "$link" ]; then # Refuse to discard a directory holding anything but symlinks: that # would be someone's real skills, not our old wiring. if find "$link" -mindepth 1 -maxdepth 1 ! -type l | grep -q .; then log "REFUSING to replace $link — it holds non-symlink entries; move them and re-run" return 1 fi find "$link" -mindepth 1 -maxdepth 1 -type l -delete rmdir "$link" elif [ -e "$link" ]; then rm -f "$link" fi mkdir -p "$(dirname "$link")" ln -s "$target" "$link" log "linked $link → $target" } # wire_antigravity — register $AGENTS_DIR in Antigravity's skills.json. # Merges rather than overwrites: skills.json is a documented user-editable # config (json_configs.md), so a hand-added entry (e.g. a personal skills # dir) must survive a re-run untouched. wire_antigravity() { if ! command -v jq >/dev/null 2>&1; then log "Antigravity: jq not found — skipping (needed to merge $ANTIGRAVITY_SKILLS_JSON safely)" return fi local existing already updated mkdir -p "$(dirname "$ANTIGRAVITY_SKILLS_JSON")" if [ -s "$ANTIGRAVITY_SKILLS_JSON" ]; then existing="$(cat "$ANTIGRAVITY_SKILLS_JSON")" else existing='{}' fi already="$(printf '%s' "$existing" | jq --arg p "$AGENTS_DIR" \ '[.entries // [] | .[] | select(.path == $p)] | length' 2>/dev/null)" || { log "Antigravity: $ANTIGRAVITY_SKILLS_JSON is not valid JSON — leaving it untouched" return 1 } if [ "$already" -gt 0 ]; then log "Antigravity: $AGENTS_DIR already registered in $ANTIGRAVITY_SKILLS_JSON" return fi updated="$(printf '%s' "$existing" | jq --arg p "$AGENTS_DIR" \ '.entries = ((.entries // []) + [{path: $p}])')" printf '%s\n' "$updated" > "$ANTIGRAVITY_SKILLS_JSON" log "Antigravity: registered $AGENTS_DIR in $ANTIGRAVITY_SKILLS_JSON" } main() { ensure_checkout # Canonical location. Pi reads ~/.agents/skills directly, so it needs no # harness-specific link of its own. prepare_agents_dir LINKED_COUNT=0 wire_skills link_dir "$CRUSH_DIR" "$AGENTS_DIR" link_dir "$OPENCODE_DIR" "$AGENTS_DIR" wire_antigravity log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF" log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL" } main "$@"