#!/bin/sh
# Tests for install.sh's wiring model.
#
# The regression these exist for: install.sh wired each harness with ONE
# directory symlink to this checkout, which quietly encoded "this checkout is
# the only source of skills on the host". On flamingo the Crush directory held
# Anthropic's official skill set, and the only way to install this library was
# to displace it — a real capability loss dressed up as a tidy-up (#27).
#
# Fixtures drive install.sh entirely through env seams, so no test touches the
# real host wiring.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/install.sh"
pass=0
fail=0
skipped=0
WORK=${TMPDIR:-/tmp}/install_test.$$
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n%s\n' "$1" "${2:-}"; }
# skill
— a minimal valid skill.
skill() {
mkdir -p "$1/$2"
printf -- '---\nname: %s\ndescription: d\n---\n' "$2" > "$1/$2/SKILL.md"
}
# new_fixture [extra-source-names...] — checkout with alpha+beta.
new_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/ck/skills" "$_f/home"
skill "$_f/ck/skills" alpha
skill "$_f/ck/skills" beta
printf '%s' "$_f"
}
# run_install [extra-sources] — echoes output, sets $rc.
run_install() {
_f=$1
_extra=${2:-}
_out=$(HOME="$_f/home" \
SKILLS_SKIP_FETCH=1 \
SKILLS_CHECKOUT_DIR="$_f/ck" \
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
ANTIGRAVITY_SKILLS_JSON="$_f/home/.gemini/config/skills.json" \
SKILLS_EXTRA_SOURCES="$_extra" \
bash "$SUT" 2>&1)
rc=$?
printf '%s' "$_out"
}
# --- 1. the new shape: a real directory of per-skill links -------------------
f=$(new_fixture fresh)
out=$(run_install "$f")
A="$f/home/.agents/skills"
if [ ! -d "$A" ] || [ -L "$A" ]; then
no 'agents dir is a real directory' "$out"
elif [ ! -L "$A/alpha" ] || [ ! -L "$A/beta" ]; then
no 'each skill is its own link' "$out"
elif [ ! -L "$f/home/.config/crush/skills" ] || [ ! -L "$f/home/.config/opencode/skills" ]; then
no 'harnesses keep one directory link each' "$out"
else
ok 'fresh install builds per-skill links under a real agents dir'
fi
# --- 2. a second source is served WITHOUT displacing the first --------------
f=$(new_fixture twosource)
mkdir -p "$f/other"
skill "$f/other" xlsx
out=$(run_install "$f" "$f/other")
if [ -L "$f/home/.agents/skills/xlsx" ] && [ -L "$f/home/.agents/skills/alpha" ]; then
ok 'a second source is linked alongside the checkout'
else
no 'a second source is linked alongside the checkout' "$out"
fi
# --- 3. name collisions are REPORTED, never silently resolved ---------------
# Two sources offering `code-review` is a real possibility, and picking one
# quietly is how stale content wins.
f=$(new_fixture collide)
mkdir -p "$f/other"
skill "$f/other" alpha
out=$(run_install "$f" "$f/other")
if printf '%s' "$out" | grep -qi 'collision' \
&& printf '%s' "$out" | grep -q "$f/other/alpha"; then
target=$(readlink "$f/home/.agents/skills/alpha")
if [ "$target" = "$f/ck/skills/alpha" ]; then
ok 'a name collision is reported and the checkout wins'
else
no 'a name collision is reported and the checkout wins' "resolved to $target"
fi
else
no 'a name collision is reported and the checkout wins' "$out"
fi
# --- 4. removing a skill from a source removes its link ---------------------
# Without this the previous generation's problem returns in a new shape: links
# that resolve to nothing, invisible to git.
f=$(new_fixture prune)
out=$(run_install "$f")
rm -rf "$f/ck/skills/beta"
out=$(run_install "$f")
if [ -e "$f/home/.agents/skills/beta" ] || [ -L "$f/home/.agents/skills/beta" ]; then
no 'a removed skill loses its link' "$out"
else
ok 'a removed skill loses its link'
fi
# --- 5. migration from the old single-link shape ----------------------------
f=$(new_fixture migrate)
mkdir -p "$f/home/.agents"
ln -s "$f/ck/skills" "$f/home/.agents/skills"
out=$(run_install "$f")
if [ -L "$f/home/.agents/skills" ]; then
no 'the old single-link wiring is migrated in place' "$out"
elif [ -L "$f/home/.agents/skills/alpha" ]; then
ok 'the old single-link wiring is migrated in place'
else
no 'the old single-link wiring is migrated in place' "$out"
fi
# --- 6. unmanaged real content in the agents dir is never deleted -----------
f=$(new_fixture protect)
mkdir -p "$f/home/.agents/skills"
skill "$f/home/.agents/skills" handwritten
out=$(run_install "$f")
if [ -f "$f/home/.agents/skills/handwritten/SKILL.md" ] \
&& printf '%s' "$out" | grep -qi 'unmanaged'; then
ok 'unmanaged content is reported and left in place'
else
no 'unmanaged content is reported and left in place' "$out"
fi
# --- 7. the source list is recorded for doctor ------------------------------
f=$(new_fixture manifest)
mkdir -p "$f/other"
skill "$f/other" xlsx
out=$(run_install "$f" "$f/other")
if [ -f "$f/home/.agents/skills-sources" ] \
&& grep -q "$f/other" "$f/home/.agents/skills-sources" \
&& grep -q "$f/ck/skills" "$f/home/.agents/skills-sources"; then
ok 'configured sources are recorded for doctor'
else
no 'configured sources are recorded for doctor' "$out"
fi
# --- Antigravity: jq is optional, so BOTH branches must be tested -----------
# These three cases inherited whatever jq the host happened to have. koala has
# it, the CI runner does not, so they passed locally and failed in CI — the
# classic works-on-my-machine gate. install.sh treats jq as optional by design,
# which means the skip is a contract and needs a test of its own.
# run_install_nojq — same as run_install, with the jq lookup pointed
# at a binary that does not exist. Deterministic regardless of the host.
run_install_nojq() {
_f=$1
_out=$(HOME="$_f/home" \
SKILLS_SKIP_FETCH=1 \
SKILLS_CHECKOUT_DIR="$_f/ck" \
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
SKILLS_JQ="definitely-not-jq-$$" \
bash "$SUT" 2>&1)
rc=$?
printf '%s' "$_out"
}
f=$(new_fixture antigravity_nojq)
out=$(run_install_nojq "$f")
if [ -e "$f/home/.gemini/config/skills.json" ]; then
no 'without jq, no skills.json is written' "$out"
elif ! printf '%s' "$out" | grep -qi 'jq not found'; then
no 'without jq, the skip is reported' "$out"
elif [ ! -L "$f/home/.agents/skills/alpha" ]; then
no 'without jq, the other harnesses are still wired' "$out"
else
ok 'without jq, Antigravity is skipped and the rest still wires'
fi
# --- 8. surfaces: the library link set is not "every skill" -----------------
# claude.ai has no shell, no cluster and no checkout, so a chat-only skill
# linked into Crush is a skill that can never fire, competing for selection
# with ones that can (#29).
f=$(new_fixture surfaces)
mkdir -p "$f/ck/skills/chatonly"
printf -- '---\nname: chatonly\nsurfaces: [chat]\ndescription: c\n---\n' > "$f/ck/skills/chatonly/SKILL.md"
printf -- '---\nname: alpha\nsurfaces: [claude-code, library]\ndescription: a\n---\n' > "$f/ck/skills/alpha/SKILL.md"
out=$(run_install "$f")
if [ -L "$f/home/.agents/skills/alpha" ] && [ ! -e "$f/home/.agents/skills/chatonly" ]; then
ok 'a skill that does not ship to the library is not linked'
else
no 'a skill that does not ship to the library is not linked' "$out"
fi
# A foreign source has no reason to know this convention, so no declaration
# means "link it" — otherwise adding Anthropic's set would link nothing.
f=$(new_fixture foreign)
mkdir -p "$f/other/xlsx"
printf -- '---\nname: xlsx\ndescription: x\n---\n' > "$f/other/xlsx/SKILL.md"
out=$(run_install "$f" "$f/other")
if [ -L "$f/home/.agents/skills/xlsx" ]; then
ok 'a source with no surfaces convention is linked as-is'
else
no 'a source with no surfaces convention is linked as-is' "$out"
fi
# --- 9-11. Antigravity's merge behaviour, only where jq exists ---------------
# These three assert what jq DOES. install.sh treats jq as optional, so on a
# host without it there is nothing to assert — and failing there is what turned
# main red: they passed on koala (jq present) and failed on the runner (absent).
# A skipped case is honest; a case that fails because the host lacks an optional
# dependency is a false red, and false reds are how a suite stops being read.
if ! command -v jq >/dev/null 2>&1; then
skipped=$((skipped + 3))
printf ' SKIP jq absent — Antigravity merge behaviour not covered on this host (3 cases)\n'
else
# skills.json entries must point at $AGENTS_DIR — that carries the
# surfaces-filtering, collision handling and multi-source merge. Pointing at
# the checkout directly would give Antigravity a different skill set than
# every other harness.
f=$(new_fixture antigravity)
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
if [ ! -f "$AG" ]; then
no 'Antigravity skills.json is created' "$out"
else
registered=$(jq -r --arg p "$f/home/.agents/skills" '[.entries[]? | select(.path == $p)] | length' "$AG")
if [ "$registered" = "1" ]; then
ok 'Antigravity registers the managed agents dir, not the raw checkout'
else
no 'Antigravity registers the managed agents dir, not the raw checkout' "$(cat "$AG")"
fi
fi
f=$(new_fixture antigravity_idempotent)
run_install "$f" >/dev/null
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
count=$(jq '.entries | length' "$AG" 2>/dev/null || echo "?")
if [ "$count" = "1" ]; then
ok 'Antigravity wiring is idempotent (no duplicate entries)'
else
no 'Antigravity wiring is idempotent (no duplicate entries)' "entries=$count"$'\n'"$out"
fi
# A user may hand-edit skills.json to point at their own personal skills dir
# (json_configs.md documents this). Our merge must not clobber it.
f=$(new_fixture antigravity_preserve)
mkdir -p "$f/home/.gemini/config"
printf '{"entries":[{"path":"/Users/mathias/personal-skills"}]}' > "$f/home/.gemini/config/skills.json"
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
personal=$(jq -r '[.entries[]? | select(.path == "/Users/mathias/personal-skills")] | length' "$AG" 2>/dev/null || echo 0)
if [ "$personal" = "1" ]; then
ok 'Antigravity wiring preserves pre-existing hand-authored entries'
else
no 'Antigravity wiring preserves pre-existing hand-authored entries' "$(cat "$AG")"$'\n'"$out"
fi
fi
printf '\ninstall: %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skipped"
[ "$fail" -eq 0 ]