Files
skills/install.sh
T
mathiasandClaude Sonnet 5 e9ad692418
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
feat(install): re-add Antigravity as a wiring target
Antigravity discovers skills via a path-entries JSON
(~/.gemini/config/skills.json), not a symlink directory. One entry points
at $AGENTS_DIR — the same managed, surfaces-filtered, collision-resolved
directory Crush/OpenCode/Pi already read — so Antigravity sees exactly the
same skill set as every other harness, for free.

Requires jq; every other target in this script stays dependency-free on
purpose, so wire_antigravity skips with a warning (not a hard failure) if
jq is missing. Merges into skills.json rather than overwriting it: the
file is documented as user-editable (json_configs.md — e.g. a personal
skills dir), so a hand-added entry must survive a re-run.

Was previously removed as "unused" (mathias/skills#41 — Antigravity usage
picked back up). Mistral Vibe stays removed.

Closes #41

Bump-Type: minor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:58:34 +02:00

278 lines
10 KiB
Bash
Executable File

#!/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>/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 <link_path> <target_path>
# 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 "$@"