#!/usr/bin/env bash # onboard-skills.sh — install the platform SKILL LIBRARY on any machine, from the # Backstage-published endpoint (/.well-known/skills/). Skills are on-demand expertise: # an agent loads one only when the task calls for it, so a big library costs nothing # until it is relevant. # # FIRST-TIMER ONE-LINER: # curl -fsSL https://static-ai-stack.dc7.io/install/skills | bash -s -- install # # install [--global] [--project DIR] [--only a,b,c] [--url URL] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # WHY ~/.agents/skills/ (global default): OpenCode AUTO-SCANS that root — no config # key, no merge, nothing to back up. The `skills.paths` config option was measured # UNRELIABLE on OpenCode 1.17.x (a declared skill loaded only intermittently) while # the auto-scanned roots loaded 3/3. The same mechanism feeds OpenChamber and the # Multica daemon, which mount the library at ~/.agents — one model everywhere. # # SOURCE OF TRUTH: the Backstage catalog. Every skill here has an owner, a lifecycle # and discipline tags in the portal; content and entity are generated from the same # directory, so a published skill cannot be an orphan. # # DESIGN GUARANTEES (mirror of the other onboarders): # • Idempotent: re-running re-syncs; removed-upstream skills are pruned. # • Surgical: only OUR marker'd skills are touched; a hand-written skill in the # same root is never deleted. # • Touches NO opencode.json keys (auto-discovery), so nothing to merge or restore. # # Requires: bash, curl. (python3 preferred for JSON; falls back to sed.) # This script uses bashisms. Re-exec under bash when possible; for the piped one-liner # invoke with `bash -s` (NOT `sh`). if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 \ && [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── colors / logging ──────────────────────────────────────────────────────────── if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_B=$'\033[1m'; C_0=$'\033[0m'; else C_G= C_Y= C_R= C_B= C_0=; fi log() { printf '%s%s%s\n' "$C_B" "$*" "$C_0"; } ok() { printf '%s✓%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%s!%s %s\n' "$C_Y" "$C_0" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } STATIC_HOST_DEFAULT="https://static-ai-stack.dc7.io" SKILLS_URL_DEFAULT="${STATIC_HOST_DEFAULT}/.well-known/skills" # ── arg parsing ────────────────────────────────────────────────────────────────── CMD="${1:-}"; shift || true SCOPE="global"; PROJECT_DIR="$PWD" SKILLS_URL="${SKILLS_URL:-$SKILLS_URL_DEFAULT}" ONLY="" while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) SCOPE="project"; PROJECT_DIR="${2:?}"; shift ;; --only) ONLY="${2:?}"; shift ;; --url) SKILLS_URL="${2:?}"; shift ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done # Global default is the AUTO-SCANNED home root; --project uses the repo-local # .opencode/skills/ (also auto-scanned) for a self-contained checkout. skills_root() { if [ "$SCOPE" = "global" ]; then echo "$HOME/.agents/skills" else echo "$PROJECT_DIR/.opencode/skills"; fi } ROOT="$(skills_root)" # Marks a skill dir as OURS. Without it we cannot distinguish a skill we installed # (safe to prune when it leaves the index) from one the user hand-wrote. MARKER=".ai-stack-managed" need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } fetch() { curl -fsSL --max-time 60 "$1"; } # Parse the index into "namefile" lines. python3 when available (correct JSON); # the sed fallback keeps a minimal box working, but only for the flat file list. parse_index() { local json="$1" if command -v python3 >/dev/null 2>&1; then printf '%s' "$json" | python3 -c ' import json, sys d = json.load(sys.stdin) for s in d.get("skills", []): for f in s.get("files", []): print(s["name"] + "\t" + f) ' else warn "python3 not found — using a reduced parser (SKILL.md only)" printf '%s' "$json" | tr ',' '\n' | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1\tSKILL.md/p' fi } cmd_install() { need curl log "Installing skills from $SKILLS_URL" local idx idx="$(fetch "$SKILLS_URL/index.json")" || die "cannot reach $SKILLS_URL/index.json" local pairs pairs="$(parse_index "$idx")" [ -n "$pairs" ] || die "index.json contained no skills" # Filter to --only, if given. `|| true` on the grep: a no-match is an empty # result to be reported below, not a reason for `set -e` to kill the script # with no output at all. if [ -n "$ONLY" ]; then local keep keep="$(printf '%s' "$ONLY" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$')" # Anchor to the START of the line and to the field separator, or `--only # architecture` would also match `frontend-architecture`. pairs="$(printf '%s\n' "$pairs" \ | grep -E -f <(printf '%s\n' "$keep" | sed 's/[].[^$\\*+?(){}|]/\\&/g; s|^|^|; s|$|\t|') || true)" [ -n "$pairs" ] || die "--only matched no skills in the index" fi mkdir -p "$ROOT" # Download into a staging dir first: a half-downloaded library that overwrote the # live one would leave the agent with truncated skills and no way to tell. # STAGE is global, not `local`: the EXIT trap runs after this function returns, # where a local is out of scope and `set -u` would abort the cleanup. STAGE="$(mktemp -d)" trap 'rm -rf "${STAGE:-}"' EXIT local stage="$STAGE" local n_files=0 names="" while IFS=$'\t' read -r name file; do [ -n "$name" ] || continue mkdir -p "$stage/$name/$(dirname "$file")" if ! fetch "$SKILLS_URL/$name/$file" > "$stage/$name/$file"; then die "failed to fetch $name/$file — aborting, nothing was changed" fi n_files=$((n_files + 1)) case " $names " in *" $name "*) ;; *) names="$names $name" ;; esac done <<< "$pairs" # Prune OUR skills that left the index. Only marker'd dirs, so a hand-written # skill sitting in the same root survives. local pruned=0 d base for d in "$ROOT"/*/; do [ -d "$d" ] || continue base="$(basename "$d")" [ -f "$d$MARKER" ] || continue case " $names " in *" $base "*) ;; *) rm -rf "$d"; pruned=$((pruned + 1)) ;; esac done local n_skills=0 for name in $names; do rm -rf "$ROOT/$name" mv "$stage/$name" "$ROOT/$name" printf 'installed_at=%s\nsource=%s\n' "$(date -u +%FT%TZ)" "$SKILLS_URL" > "$ROOT/$name/$MARKER" n_skills=$((n_skills + 1)) done ok "installed $n_skills skill(s), $n_files file(s) → $ROOT" [ "$pruned" -gt 0 ] && ok "pruned $pruned skill(s) no longer published" log "" log "Auto-discovered at session start — no opencode.json changes needed." [ "$SCOPE" = "global" ] && log "Available in EVERY project on this machine." return 0 } cmd_status() { [ -d "$ROOT" ] || { warn "no skills installed at $ROOT"; return 0; } local total=0 managed=0 d for d in "$ROOT"/*/; do [ -d "$d" ] || continue total=$((total + 1)) [ -f "$d$MARKER" ] && managed=$((managed + 1)) done ok "$ROOT — $total skill(s), $managed managed by ai-stack" if command -v curl >/dev/null 2>&1; then local pub pub="$(fetch "$SKILLS_URL/index.json" 2>/dev/null | tr ',' '\n' | grep -c '"name"' || true)" [ -n "$pub" ] && [ "$pub" -gt 0 ] && log "published upstream: $pub" fi } cmd_uninstall() { [ -d "$ROOT" ] || { warn "nothing to uninstall at $ROOT"; return 0; } local removed=0 d for d in "$ROOT"/*/; do [ -d "$d" ] || continue [ -f "$d$MARKER" ] || continue rm -rf "$d"; removed=$((removed + 1)) done # Only if we emptied it — never remove a root holding hand-written skills. rmdir "$ROOT" 2>/dev/null || true ok "removed $removed ai-stack skill(s) from $ROOT (hand-written skills untouched)" } cmd_help() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; } case "$CMD" in install) cmd_install ;; status) cmd_status ;; uninstall) cmd_uninstall ;; help|"") cmd_help ;; *) die "unknown command: $CMD (want: install | status | uninstall)" ;; esac