~/.agents/skills was a single link straight at this checkout. That encoded an assumption nobody had stated — that this checkout is the only source of skills on the host. On flamingo it was not: Crush's directory held Anthropic's official set, and the only way to install this library was to move that set aside. A capability loss dressed up as a tidy-up. The canonical location becomes a real directory of per-skill symlinks, fed by the checkout plus anything in SKILLS_EXTRA_SOURCES. Harnesses keep their one directory link, so the count is N + one per harness rather than N x harnesses, which is what #23 was actually about. The three requirements #27 named, each with a test: - collisions are logged with both paths and the checkout wins, never resolved in silence; - removing a skill from a source prunes its link; - anything in the managed directory that is not our symlink is reported and left in place — the installer does not delete content it did not create. doctor learns the same model: sources are recorded at ~/.agents/skills-sources, a link into a configured source is healthy, a link into an unknown one is not, and the old count check becomes a per-skill check because a per-skill layout can lose one skill without changing any total. The pre-#27 wiring is named as legacy rather than reported as damage. count_skills needed find -L; without it symlinked skill directories count as zero. Verified live on koala: migrated in place to 42 per-skill links, then a second source added a 43rd alongside them, then a re-run without that source pruned it back to 42. Only doctor finding throughout was the genuine stale plugin. Closes #27 Bump-Type: minor
222 lines
8.1 KiB
Bash
Executable File
222 lines
8.1 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
|
|
# and OpenCode.
|
|
#
|
|
# 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
|
|
#
|
|
# ~/.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}"
|
|
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%/}"
|
|
|
|
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"
|
|
}
|
|
|
|
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"
|
|
|
|
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 "$@"
|