From e7914f3cfb7a4dbb720ad02210cfe3b8e14eb109 Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:52:53 -0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[refactor]=20install:=20sh?= =?UTF-8?q?ared=20.agents/=20folder=20with=20per-host=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Files install once under .agents/ (skills, bin, workflows, hooks) - .claude/ always links in: one link per skill, one per file - .codex/ and .opencode/ only when the folder exists, a config host key or super-board-host.sh names it, or --hosts asks - Upgrade swaps super-board's own real .claude/ entries for links; user links and other files stay - No symlinks (Windows): copies instead and says so - Tests: fresh layout, --hosts, detection, upgrade, idempotent rerun, whole-folder links, no-ln fallback Closes #41 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 10 +- get.sh | 2 +- install.sh | 217 +++++++++++++++++++++++++++++++----------- tests/test-get.sh | 2 +- tests/test-install.sh | 121 ++++++++++++++++++++++- 5 files changed, 291 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f6aefd3..bf942a7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,10 +41,16 @@ See `skills/super-board/references/rate-limit-etiquette.md` for the full discipl ## Installation contract -`install.sh [--no-hooks] [--protect-main] ` copies the pack into the target project's `.claude/` tree: +`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] ` copies the pack +once into the target project's shared `.agents/` tree, then links each host folder into it: one +link per skill under `skills/`, one per file under `bin/`, `workflows/`, `hooks/`. `.claude/` is +always linked; `.codex/` and `.opencode/` only when that folder already exists, a config `host` +key names it, `super-board-host.sh` reports it, or `--hosts` asks. A real-directory `.claude/` +install is replaced by links on upgrade; a user's own links and anything not super-board's are +left alone; where no symlink can be made the file is copied. Settings and run state stay in `.claude/`. ``` -.claude/ +.agents/ ├── skills//... ├── hooks/guard-*.py, cleanup-wt.py (wired into settings.json; --no-hooks skips) ├── workflows/super-board-wave.js, ui-refine-loop.js diff --git a/get.sh b/get.sh index 5029093d..28e3e4c0 100755 --- a/get.sh +++ b/get.sh @@ -9,7 +9,7 @@ # --ref which version to fetch (default: the latest release) # --no-helper-skills skip `npx skills@latest add mattpocock/skills` # -h, --help show this help -# Passed through to install.sh: --no-hooks, --protect-main. +# Passed through to install.sh: --no-hooks, --protect-main, --hosts=. # # Environment: # SUPER_BOARD_REF same as --ref diff --git a/install.sh b/install.sh index 009fbe83..fd899e6e 100755 --- a/install.sh +++ b/install.sh @@ -1,17 +1,26 @@ #!/usr/bin/env bash # super-board installer. -# Copies skills/, scripts/, workflows/ and the guard hooks into the target -# project's .claude/ tree, and merges the hook settings into -# .claude/settings.json (backed up first, never duplicated). +# Copies skills/, scripts/, workflows/ and the guard hooks once into the target +# project's shared .agents/ tree, links them into each agent host's folder, and +# merges the hook settings into .claude/settings.json (backed up first, never duplicated). # # Usage: -# ./install.sh [--no-hooks] [--protect-main] [target-project-dir] +# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] [target-project-dir] # Defaults to the current working directory. --no-hooks skips the guard hooks # and leaves settings.json untouched. --protect-main also wires the opt-in # guard that blocks direct and force pushes to main/master/base_branch; with -# --no-hooks it installs that one guard alone. The installer asks nothing: +# --no-hooks it installs that one guard alone. --hosts (or --hosts=…) adds host +# folders to link; .claude/ is always linked. The installer asks nothing: # super-board onboard asks the questions (protect main is one of them). # +# Layout: .agents/{skills/,bin,workflows,hooks} hold the files. Each host +# folder (.claude/ always; .codex/ and .opencode/ when that folder already exists, +# a config "host" key names it, super-board-host.sh reports it, or --hosts asks) +# gets one link per skill under skills/ and one link per file under bin/, +# workflows/ and hooks/. Nothing that is not super-board's is touched. Where a +# link cannot be made (Windows without symlink rights) the file is copied. +# Settings and run state (.claude/settings.json, .claude/super-board/) stay put. +# # Output is grouped with emojis, one line per group of files (failures still name # the file). Environment: SUPER_BOARD_QUIET_NEXT=1 prints only the "🔧 installing" # group — get.sh sets it and prints its own header and summary around it. @@ -26,15 +35,21 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOOKS=1 PROTECT=0 TARGET="" +ASKED_HOSTS="" +want_hosts=0 for arg in "$@"; do + if [ "$want_hosts" -eq 1 ]; then ASKED_HOSTS="$ASKED_HOSTS,$arg"; want_hosts=0; continue; fi case "$arg" in --no-hooks) HOOKS=0 ;; --protect-main) PROTECT=1 ;; - -h|--help) sed -n '2,14p' "$0"; exit 0 ;; + --hosts) want_hosts=1 ;; + --hosts=*) ASKED_HOSTS="$ASKED_HOSTS,${arg#--hosts=}" ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; -*) echo "unknown option: $arg" >&2; exit 64 ;; *) TARGET="$arg" ;; esac done +[ "$want_hosts" -eq 0 ] || { echo "--hosts needs a list, e.g. --hosts claude,codex" >&2; exit 64; } TARGET="${TARGET:-$PWD}" # Primary: the board and its lanes. Secondary: standalone helpers the lanes @@ -48,19 +63,47 @@ if [ ! -d "$TARGET" ]; then exit 64 fi +# Hosts: claude always; codex/opencode when asked, configured or detected. +HOSTS="claude" +add_host() { case " $HOSTS " in *" $1 "*) ;; *) HOSTS="$HOSTS $1" ;; esac; } +for h in $(echo "$ASKED_HOSTS" | tr ',' ' '); do + case "$h" in + claude|codex|opencode) add_host "$h" ;; + *) echo "unknown host: $h (use claude, codex or opencode)" >&2; exit 64 ;; + esac +done +for h in codex opencode; do [ ! -d "$TARGET/.$h" ] || add_host "$h"; done +for h in $(python3 - "$TARGET/.claude/super-board/configs" <<'PY' +import glob, json, os, sys +for p in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): + try: + h = json.load(open(p)).get("host") + except Exception: + continue + if h in ("codex", "opencode"): + print(h) +PY +); do add_host "$h"; done +[ "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" != codex ] || add_host codex + QUIET="${SUPER_BOARD_QUIET_NEXT:-}" PACK_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION" 2>/dev/null || echo '?')" +SHARED="$TARGET/.agents" -# Record an upgrade BEFORE the copy overwrites the old VERSION. +# Record an upgrade BEFORE the copy overwrites the old VERSION. An install made +# before .agents/ existed keeps its VERSION under .claude/skills/super-board. PREV_VERSION="" -if [ -d "$TARGET/.claude/skills/super-board" ]; then - PREV_VERSION="$(tr -d '[:space:]' < "$TARGET/.claude/skills/super-board/VERSION" 2>/dev/null || echo 'older')" - [ -n "$PREV_VERSION" ] || PREV_VERSION="older" -fi +for d in "$SHARED/skills/super-board" "$TARGET/.claude/skills/super-board"; do + if [ -d "$d" ]; then + PREV_VERSION="$(tr -d '[:space:]' < "$d/VERSION" 2>/dev/null || echo 'older')" + [ -n "$PREV_VERSION" ] || PREV_VERSION="older" + break + fi +done ADDED_SKILLS="" if [ -n "$PREV_VERSION" ]; then for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do - [ -e "$TARGET/.claude/skills/$skill" ] || ADDED_SKILLS="$ADDED_SKILLS $skill" + [ -e "$SHARED/skills/$skill" ] || [ -e "$TARGET/.claude/skills/$skill" ] || ADDED_SKILLS="$ADDED_SKILLS $skill" done fi @@ -70,45 +113,43 @@ echo "🔧 installing" # Resolve all skill paths before replacing anything. A destination can contain # the pack (or a linked source skill); clearing it would destroy our input. # A destination inside its source would instead recursively copy into itself. -python3 - "$REPO_ROOT" "$TARGET" $PRIMARY_SKILLS $SECONDARY_SKILLS <<'PY' +python3 - "$REPO_ROOT" "$TARGET" "$HOSTS" $PRIMARY_SKILLS $SECONDARY_SKILLS <<'PY' from pathlib import Path import sys root = Path(sys.argv[1]).resolve() target = Path(sys.argv[2]) -for skill in sys.argv[3:]: +folders = [".agents"] + ["." + h for h in sys.argv[3].split()] +for skill in sys.argv[4:]: src = (root / "skills" / skill).resolve() - dest = (target / ".claude" / "skills" / skill).resolve() - if src == dest: - continue - if dest == root or dest in root.parents or dest in src.parents or src in dest.parents: - sys.exit(f" ✗ refusing to install {skill}: source and destination overlap ({dest})") + for folder in folders: + dest = (target / folder / "skills" / skill).resolve() + if src == dest: + continue + if dest == root or dest in root.parents or dest in src.parents or src in dest.parents: + sys.exit(f" ✗ refusing to install {skill}: source and destination overlap ({dest})") PY -mkdir -p "$TARGET/.claude/skills" "$TARGET/.claude/bin" +mkdir -p "$SHARED/skills" "$SHARED/bin" "$SHARED/workflows" -# A project may keep the real skill directories elsewhere and symlink them into -# .claude/skills — a layout some repos use to share one tree between .claude and -# another agent host. `cp -R src dest/` fails outright on such a target ("Not a +# Copy one skill into dest. A project may keep the real skill directories +# elsewhere and symlink them in — a layout some repos use to share one tree +# between agent hosts. `cp -R src dest/` fails outright on such a target ("Not a # directory"), so resolve the link and write to where it actually points. That # keeps the project's chosen layout instead of replacing it with a plain copy. -for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do - if [ ! -d "$REPO_ROOT/skills/$skill" ]; then - echo " ✗ missing $skill in repo — skipping" >&2 - continue - fi - dest="$TARGET/.claude/skills/$skill" - note="" +# Returns 1 when the skill was skipped. +install_skill() { + local skill="$1" dest="$2" note="" resolved if [ -L "$dest" ]; then # `cd -P` walks the link to its physical target in one step. Do NOT rebuild # the path from `readlink` output: `cd ""` SUCCEEDS in bash, so an empty # readlink silently leaves you in the parent, and the rm below then empties - # .claude/skills instead of one skill. That is not hypothetical — it deleted + # the skills folder instead of one skill. That is not hypothetical — it deleted # 190 entries on a real repo on 2026-08-20 before this comment existed. resolved=$(cd -P "$dest" 2>/dev/null && pwd) || resolved="" if [ -z "$resolved" ]; then echo " ✗ $skill is a symlink pointing nowhere — skipping rather than replacing it" >&2 - continue + return 1 fi dest="$resolved" note=" (via symlink → ${resolved#$TARGET/})" @@ -120,14 +161,14 @@ for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do case "$dest" in */"$skill") : ;; *) echo " ✗ refusing to write $skill: resolved to '$dest', which is not a $skill directory" >&2 - continue ;; + return 1 ;; esac # A self-vendored project can expose this very source through one or more # links. There is nothing to copy in that case; clearing dest erases src too. if [ "$REPO_ROOT/skills/$skill" -ef "$dest" ]; then echo " ✓ $skill already uses the source directory" - continue + return 0 fi # Replace the contents, not the directory itself, so a symlink stays a symlink. @@ -135,7 +176,7 @@ for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do rm -rf "${dest:?}/"* 2>/dev/null || true cp -R "$REPO_ROOT/skills/$skill/." "$dest/" [ -z "$note" ] || echo " ✓ $skill$note" -done +} # File destinations can be links into the pack too. -ef follows symlinks and # also recognizes hard links, so cp never tries to overwrite its own input. @@ -145,20 +186,97 @@ copy_file() { fi } -for script in super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do - if [ -f "$REPO_ROOT/scripts/$script" ]; then - copy_file "$REPO_ROOT/scripts/$script" "$TARGET/.claude/bin/$script" - chmod +x "$TARGET/.claude/bin/$script" +# Relative path from a host folder (physical) to the shared folder, so links +# survive a moved or freshly cloned project. Prints e.g. ../../.agents/skills. +relpath() { + python3 - "$1" "$2" <<'PY' +import os, sys +print(os.path.relpath(os.path.realpath(sys.argv[1]), os.path.realpath(sys.argv[2]))) +PY +} + +COPIED=0 +# Point // at .agents//. Already resolving there +# (a link, or a whole folder linked into .agents/) → nothing to do. A user's own +# link elsewhere → keep it and write through it, as before .agents/ existed. +# super-board's own real file or directory → replaced by the link. +link_entry() { + local host="$1" group="$2" name="$3" src_in_pack="$4" kind="$5" rel="$6" + local shared="$SHARED/$group/$name" dest="$TARGET/.$host/$group/$name" + [ -e "$shared" ] || return 0 + [ "$dest" -ef "$shared" ] && return 0 + if [ -L "$dest" ]; then + if [ "$kind" = dir ]; then install_skill "$name" "$dest" || true + elif [ ! "$dest" -ef "$src_in_pack" ]; then copy_file "$src_in_pack" "$dest" + fi + return 0 fi + case "$dest" in "$TARGET/.$host/$group/$name") : ;; *) return 0 ;; esac + rm -rf "${dest:?}" + ln -s "$rel/$name" "$dest" 2>/dev/null && return 0 + COPIED=1 + if [ "$kind" = dir ]; then mkdir -p "$dest" && cp -R "$shared/." "$dest/"; else cp "$shared" "$dest"; fi +} + +for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do + if [ ! -d "$REPO_ROOT/skills/$skill" ]; then + echo " ✗ missing $skill in repo — skipping" >&2 + continue + fi + install_skill "$skill" "$SHARED/skills/$skill" || true done -mkdir -p "$TARGET/.claude/workflows" -for wf in super-board-wave.js ui-refine-loop.js; do +SCRIPTS="super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh" +for script in $SCRIPTS; do + if [ -f "$REPO_ROOT/scripts/$script" ]; then + copy_file "$REPO_ROOT/scripts/$script" "$SHARED/bin/$script" + chmod +x "$SHARED/bin/$script" + fi +done +WORKFLOWS="super-board-wave.js ui-refine-loop.js" +for wf in $WORKFLOWS; do if [ -f "$REPO_ROOT/workflows/$wf" ]; then - copy_file "$REPO_ROOT/workflows/$wf" "$TARGET/.claude/workflows/$wf" + copy_file "$REPO_ROOT/workflows/$wf" "$SHARED/workflows/$wf" fi done -echo " ✓ skills, scripts and workflows → .claude/" + +HOOK_FILES="" +if [ "$HOOKS" -eq 1 ]; then + # hooks/*.py only: hooks/dev/ holds gates for repos that author skills, never installed. + for h in "$REPO_ROOT"/hooks/*.py; do [ -f "$h" ] && HOOK_FILES="$HOOK_FILES $(basename "$h")"; done +elif [ "$PROTECT" -eq 1 ]; then + HOOK_FILES="guard-protected-push.py" +fi +if [ -n "$HOOK_FILES" ]; then + mkdir -p "$SHARED/hooks" + for h in $HOOK_FILES; do + copy_file "$REPO_ROOT/hooks/$h" "$SHARED/hooks/$h" + chmod +x "$SHARED/hooks/$h" + done +fi + +GROUPS_="skills bin workflows" +[ -z "$HOOK_FILES" ] || GROUPS_="$GROUPS_ hooks" +LINKED="" +for host in $HOSTS; do + LINKED="${LINKED:+$LINKED, }.$host" + for group in $GROUPS_; do + mkdir -p "$TARGET/.$host/$group" + rel=$(relpath "$SHARED/$group" "$TARGET/.$host/$group") + case "$group" in + skills) for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do + link_entry "$host" skills "$skill" "$REPO_ROOT/skills/$skill" dir "$rel"; done ;; + bin) for script in $SCRIPTS; do link_entry "$host" bin "$script" "$REPO_ROOT/scripts/$script" file "$rel"; done ;; + workflows) for wf in $WORKFLOWS; do link_entry "$host" workflows "$wf" "$REPO_ROOT/workflows/$wf" file "$rel"; done ;; + hooks) for h in $HOOK_FILES; do link_entry "$host" hooks "$h" "$REPO_ROOT/hooks/$h" file "$rel"; done ;; + esac + done +done +if [ "$COPIED" -eq 1 ]; then + echo " ✓ skills, scripts and workflows → .agents/ (copied into $LINKED: no symlinks here)" +else + echo " ✓ skills, scripts and workflows → .agents/ (linked from $LINKED)" +fi SETTINGS_PY="$REPO_ROOT/scripts/super-board-settings.py" # Stdlib-only merge (scripts/super-board-settings.py): keeps every existing key @@ -177,15 +295,7 @@ merge_settings() { } if [ "$HOOKS" -eq 1 ]; then - mkdir -p "$TARGET/.claude/hooks" - # hooks/*.py only: hooks/dev/ holds gates for repos that author skills, never installed. - n=0 - for h in "$REPO_ROOT"/hooks/*.py; do - [ -f "$h" ] || continue - copy_file "$h" "$TARGET/.claude/hooks/$(basename "$h")" - chmod +x "$TARGET/.claude/hooks/$(basename "$h")" - n=$((n + 1)) - done + set -- $HOOK_FILES; n=$# SNIPPETS="$REPO_ROOT/hooks/settings-snippet.json" if [ "$PROTECT" -eq 1 ]; then SNIPPETS="$SNIPPETS $REPO_ROOT/hooks/settings-protect-main.json"; fi # shellcheck disable=SC2086 @@ -193,9 +303,6 @@ if [ "$HOOKS" -eq 1 ]; then elif [ "$PROTECT" -eq 1 ]; then # --no-hooks --protect-main: the one guard asked for, nothing else. This is the # command onboard names when its protect-main question finds no guard script. - mkdir -p "$TARGET/.claude/hooks" - copy_file "$REPO_ROOT/hooks/guard-protected-push.py" "$TARGET/.claude/hooks/guard-protected-push.py" - chmod +x "$TARGET/.claude/hooks/guard-protected-push.py" echo " 🛡️ --no-hooks: only the push guard → .claude/settings.json ($(merge_settings "$REPO_ROOT/hooks/settings-protect-main.json"))" else echo " ⏭️ guard hooks skipped (--no-hooks)" diff --git a/tests/test-get.sh b/tests/test-get.sh index 2eb6d1fc..f99a4f42 100755 --- a/tests/test-get.sh +++ b/tests/test-get.sh @@ -100,7 +100,7 @@ printf '%s\n' "$out" | grep -q '^🧠 helper skills: installed$' || fail "helper T="$WORK/t9"; mkdir -p "$T" # /usr/bin can contain node on Linux. Keep only the tools this install uses. NOBIN="$WORK/bin-nonode"; mkdir -p "$NOBIN" -for t in bash id curl tar gzip python3 mktemp mkdir rm dirname tr cp chmod grep basename sed gh jq; do +for t in bash id curl tar gzip python3 mktemp mkdir rm dirname tr cp ln chmod grep basename sed gh jq; do ln -s "$(command -v "$t")" "$NOBIN/$t" done out=$(PATH="$NOBIN" bash "$GET" --target "$T" 2>&1) || fail "install without node failed: $out" diff --git a/tests/test-install.sh b/tests/test-install.sh index acdb2e5b..3b91e452 100755 --- a/tests/test-install.sh +++ b/tests/test-install.sh @@ -23,6 +23,9 @@ fail() { echo "FAIL: $1" >&2; exit 1; } plant_decoys() { for d in decoy-one decoy-two decoy-three; do mkdir -p "$1/.claude/skills/$d"; touch "$1/.claude/skills/$d/SKILL.md"; done; } decoys_intact() { [ "$(ls "$1/.claude/skills" | grep -c '^decoy')" -eq 3 ]; } +# A Codex shell would add .codex/ links by detection; keep the default layout predictable. +unset CODEX_THREAD_ID CODEX_SESSION_ID SUPER_BOARD_HOST + SKILLS="super-board super-build super-qa super-review" ALL_SKILLS="$SKILLS super-collect ui-refine-loop visual git-sync" @@ -263,7 +266,7 @@ T=$(mktemp -d) OUT=$(SUPER_BOARD_QUIET_NEXT=1 "$INSTALL" "$T" 2>&1) [ "$(echo "$OUT" | head -1)" = "🔧 installing" ] || fail "quiet mode should start at the installing group, got: $OUT" echo "$OUT" | grep -q "next:" && fail "quiet mode must not print the next step" -echo "$OUT" | grep -q "✓ skills, scripts and workflows → .claude/" || fail "quiet mode should print the grouped files line" +echo "$OUT" | grep -q "✓ skills, scripts and workflows → .agents/" || fail "quiet mode should print the grouped files line" echo "$OUT" | grep -q "🛡️ 6 guard hooks → .claude/settings.json (new file)" || fail "guard line should count hooks, got: $OUT" [ "$(echo "$OUT" | wc -l | tr -d ' ')" -le 3 ] || fail "grouped output should be ≤ 3 lines here, got: $OUT" rm -rf "$T" @@ -290,6 +293,120 @@ for s in super-board-skills-ready.py super-board-host.sh super-board-codex-wave. done rm -rf "$T" +# 13 — shared layout: the files live once under .agents/; .claude/ reaches them through +# links. No .codex/ or .opencode/ unless that host is configured, detected or asked for. +resolves() { # — host paths reach the .agents/ copy + for p in skills/super-board/SKILL.md skills/git-sync/SKILL.md bin/super-board-host.sh workflows/super-board-wave.js; do + [ "$1/.$2/$p" -ef "$1/.agents/$p" ] || fail "$2: $p does not resolve to .agents/$p" + done +} +T=$(mktemp -d) +OUT=$("$INSTALL" "$T" 2>&1) +[ -d "$T/.agents/skills/super-board" ] && [ ! -L "$T/.agents/skills/super-board" ] || fail "fresh: .agents/skills/super-board should be a real directory" +[ -f "$T/.agents/bin/super-board-host.sh" ] && [ ! -L "$T/.agents/bin/super-board-host.sh" ] || fail "fresh: .agents/bin should hold real files" +[ -x "$T/.agents/bin/super-board-run.sh" ] || fail "fresh: .agents/bin scripts must be executable" +resolves "$T" claude +[ -L "$T/.claude/skills/super-board" ] || fail "fresh: .claude/skills/super-board should be a link" +[ "$(readlink "$T/.claude/skills/super-board")" = "../../.agents/skills/super-board" ] || fail "fresh: links must be relative, got $(readlink "$T/.claude/skills/super-board")" +[ "$T/.claude/hooks/guard-secrets.py" -ef "$T/.agents/hooks/guard-secrets.py" ] || fail "fresh: hooks should resolve to .agents/hooks" +[ ! -e "$T/.codex" ] && [ ! -e "$T/.opencode" ] || fail "fresh: no host was asked for, yet .codex/ or .opencode/ appeared" +echo "$OUT" | grep -q "✓ skills, scripts and workflows → .agents/ (linked from .claude)" || fail "fresh: grouped line should name .agents/ and the hosts, got: $OUT" +rm -rf "$T" + +# 14 — --hosts adds hosts (claude is always kept); both flag forms; unknown names refused. +T=$(mktemp -d) +"$INSTALL" --no-hooks --hosts codex,opencode "$T" >/dev/null 2>&1 || fail "--hosts codex,opencode failed" +for h in claude codex opencode; do resolves "$T" "$h"; done +rm -rf "$T" +T=$(mktemp -d) +"$INSTALL" --no-hooks --hosts=opencode "$T" >/dev/null 2>&1 || fail "--hosts=opencode failed" +resolves "$T" opencode; resolves "$T" claude +[ ! -e "$T/.codex" ] || fail "--hosts=opencode must not create .codex/" +rm -rf "$T" +T=$(mktemp -d) +if OUT=$("$INSTALL" --hosts claude,cursor "$T" 2>&1); then fail "an unknown host should be refused"; fi +echo "$OUT" | grep -q "unknown host: cursor" || fail "unknown host should be named, got: $OUT" +[ ! -e "$T/.agents" ] || fail "a refused --hosts must install nothing" +rm -rf "$T" + +# 15 — detection: an existing .codex/ or .opencode/ folder, a config "host" key, or a Codex shell. +T=$(mktemp -d); mkdir -p "$T/.codex" +"$INSTALL" --no-hooks "$T" >/dev/null 2>&1; resolves "$T" codex +[ ! -e "$T/.opencode" ] || fail "detection: .codex/ alone must not add .opencode/" +rm -rf "$T" +T=$(mktemp -d); mkdir -p "$T/.claude/super-board/configs" +echo '{"host": "opencode"}' > "$T/.claude/super-board/configs/app.json" +"$INSTALL" --no-hooks "$T" >/dev/null 2>&1; resolves "$T" opencode +rm -rf "$T" +T=$(mktemp -d) +CODEX_THREAD_ID=t1 "$INSTALL" --no-hooks "$T" >/dev/null 2>&1; resolves "$T" codex +rm -rf "$T" + +# 16 — upgrade a real-directory .claude/ install: every path it had still resolves, super-board's +# own entries become links, anything that is not super-board's stays exactly as it was. +T=$(mktemp -d) +mkdir -p "$T/.claude/skills/super-board" "$T/.claude/skills/super-qa" "$T/.claude/skills/other-skill" \ + "$T/.claude/bin" "$T/.claude/workflows" "$T/.claude/hooks" +echo "1.8.2" > "$T/.claude/skills/super-board/VERSION"; echo old > "$T/.claude/skills/super-board/SKILL.md" +echo old > "$T/.claude/skills/super-board/stale-file.md" +echo mine > "$T/.claude/skills/other-skill/SKILL.md" +echo old > "$T/.claude/bin/super-board-run.sh"; echo mine > "$T/.claude/bin/mine.sh" +echo old > "$T/.claude/workflows/super-board-wave.js"; echo mine > "$T/.claude/hooks/my-hook.py" +echo old > "$T/.claude/hooks/guard-secrets.py" +"$INSTALL" "$T" >/dev/null 2>&1 || fail "upgrade from a real-directory install failed" +resolves "$T" claude +for p in skills/super-board skills/super-qa bin/super-board-run.sh workflows/super-board-wave.js hooks/guard-secrets.py; do + [ -L "$T/.claude/$p" ] || fail "upgrade: .claude/$p should now be a link into .agents/" +done +[ ! -e "$T/.agents/skills/super-board/stale-file.md" ] || fail "upgrade: stale files must not carry over" +[ "$(cat "$T/.claude/skills/other-skill/SKILL.md")" = mine ] && [ ! -L "$T/.claude/skills/other-skill" ] || fail "upgrade: other-skill was touched" +[ "$(cat "$T/.claude/bin/mine.sh")" = mine ] && [ "$(cat "$T/.claude/hooks/my-hook.py")" = mine ] || fail "upgrade: user bin/hooks were touched" +[ ! -e "$T/.agents/skills/other-skill" ] || fail "upgrade: other-skill must not move into .agents/" +jq -e '.from == "1.8.2"' "$T/.claude/super-board/upgrade.json" >/dev/null || fail "upgrade: upgrade.json not recorded" +rm -rf "$T" + +# 17 — a second run changes nothing (tree, link targets, file contents). +snapshot() { python3 - "$1" <<'PY' +import hashlib, os, sys +root = sys.argv[1] +for d, dirs, files in sorted(os.walk(root)): + for n in sorted(dirs + files): + p = os.path.join(d, n); r = os.path.relpath(p, root) + if os.path.islink(p): print("L", r, os.readlink(p)) + elif os.path.isfile(p): print("F", r, hashlib.sha256(open(p, "rb").read()).hexdigest()) + else: print("D", r) +PY +} +T=$(mktemp -d) +"$INSTALL" --hosts codex "$T" >/dev/null 2>&1 +BEFORE=$(snapshot "$T") +"$INSTALL" --hosts codex "$T" >/dev/null 2>&1 +[ "$(snapshot "$T")" = "$BEFORE" ] || fail "a second install changed the tree: $(diff <(echo "$BEFORE") <(snapshot "$T"))" +rm -rf "$T" + +# 18 — a host folder whose skills/ (or bin/) is itself a link into .agents/ is already shared: +# nothing is replaced, nothing loops, the neighbours survive. +T=$(mktemp -d); mkdir -p "$T/.agents/skills" "$T/.agents/bin" "$T/.claude" +ln -s ../.agents/skills "$T/.claude/skills"; ln -s ../.agents/bin "$T/.claude/bin" +plant_decoys "$T" +"$INSTALL" --no-hooks "$T" >/dev/null 2>&1 || fail "whole-folder link layout failed" +[ -d "$T/.agents/skills/super-board" ] && [ ! -L "$T/.agents/skills/super-board" ] || fail "whole-folder link: skill turned into a self-link" +[ -f "$T/.claude/skills/super-board/SKILL.md" ] && [ -x "$T/.claude/bin/super-board-run.sh" ] || fail "whole-folder link: files missing" +decoys_intact "$T" || fail "whole-folder link: DECOYS DESTROYED" +rm -rf "$T" + +# 19 — no symlinks (Windows without symlink rights): copy instead, so every path still resolves. +T=$(mktemp -d); FAKE=$(mktemp -d) +printf '#!/bin/sh\nexit 1\n' > "$FAKE/ln"; chmod +x "$FAKE/ln" +for run in 1 2; do + OUT=$(PATH="$FAKE:$PATH" "$INSTALL" "$T" 2>&1) || fail "no-symlink install failed: $OUT" + [ -f "$T/.claude/skills/super-board/SKILL.md" ] && [ -x "$T/.claude/bin/super-board-host.sh" ] \ + && [ -f "$T/.claude/hooks/guard-secrets.py" ] || fail "no-symlink install: .claude paths missing" + cmp -s "$T/.claude/skills/super-board/SKILL.md" "$T/.agents/skills/super-board/SKILL.md" || fail "no-symlink: copy differs" +done +echo "$OUT" | grep -q "copied" || fail "no-symlink install should say it copied, got: $OUT" +rm -rf "$T" "$FAKE" + # 9 — the snippet the installer merges is the one hooks/README.md documents. python3 - ../hooks/README.md ../hooks/settings-snippet.json <<'PY' || fail "hooks/README.md snippet and hooks/settings-snippet.json differ" import json, re, sys @@ -297,4 +414,4 @@ doc = re.search(r"```json\n(.*?)```", open(sys.argv[1]).read(), re.S).group(1) sys.exit(0 if json.loads(doc) == json.load(open(sys.argv[2])) else 1) PY -echo "PASS: test-install.sh (17 scenarios)" +echo "PASS: test-install.sh (24 scenarios)"