diff --git a/README.md b/README.md index 8b57d856..7735ef43 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Or as a Claude Code plugin: > [!NOTE] > The plugin ships the skills only. Run `/super-board:super-board onboard` and its 🔍 Checks step adds the guard hooks, scripts and workflows. Needs Claude Code, `gh`, `jq`, bash 3.2+ and Python 3.9+; the installer checks. +Running from Codex or OpenCode? See [supported hosts](docs/hosts.md) for tested versions, sign-in and gaps. + Optional post-run learning can delegate an already installed `calibrate` skill after verified selected work completes. It applies only to explicitly permitted existing canonical skill files; the package bundles no private skill. See diff --git a/docs/hosts.md b/docs/hosts.md new file mode 100644 index 00000000..57ba5403 --- /dev/null +++ b/docs/hosts.md @@ -0,0 +1,37 @@ +# Agent hosts + +super-board runs inside an agent host: the CLI whose session types `/super-board run`. +`scripts/super-board-host.sh` (installed as `.claude/bin/super-board-host.sh`) names it. + +| Host value | Tested version | Start a run | Sign-in | Gaps | +|---|---|---|---|---| +| `claude` | Claude Code 2.1.302 | `/super-board run` (Workflow backend) | `claude` then `/login`; `gh auth login` | None. The reference host. | +| `codex` | codex-cli 0.160.0 | `/super-board run` inside Codex runs as `--codex`; from Claude, `/super-board run --codex` | `codex login`; `gh auth login` | No Workflow tool, so every lane is headless `codex exec`. Review runs only with explicit `--codex` (a host-switched plain `run` counts). No Claude usage guard. | +| `opencode` | OpenCode v2.0.22 | Not supported yet: the run halts with a host-selection request | `opencode auth login`; `gh auth login` | No OpenCode lane backend yet. `permission.external_directory: deny` is not enforced in v2.0.22 (`patch` still writes outside the repo), so OpenCode runs stay attended-only once a backend lands. | + +Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. + +## How the host is picked + +`super-board-host.sh [--config ]` prints one value. First match wins: + +1. `SUPER_BOARD_HOST=claude|codex|opencode`, the manual override. Any other value exits 64. +2. `CODEX_THREAD_ID` or `CODEX_SESSION_ID` → `codex`. +3. `OPENCODE=1` → `opencode`. Checked before `CLAUDECODE`, which leaks into an OpenCode + session started from Claude Code. `AGENT=1` is ignored: other tools set it too. +4. `CLAUDECODE` → `claude`. +5. The board config's top-level `host` key, when `--config` is given. +6. Nothing matched: empty stdout, a hint on stderr, exit 65. The run halts and asks for a + host. It never guesses Claude. + +A live env sign beats the saved config, so a Codex lane started from a Claude-configured +board still answers `codex`. + +Onboard always writes `host` into `.claude/super-board/configs/.json`. +`super-board-setup.py fix` adds a missing one from `--host`; `check` flags any other value. + +## Evidence + +- Codex: env of `codex exec -s read-only`, 2026-10-04 (recorded in the script header). +- OpenCode: env diff of an `opencode run` shell, 2026-10-19: `OPENCODE=1`, + `OPENCODE_SESSION_ID`, `OPENCODE_TERMINAL=1`, `AGENT=1`. diff --git a/scripts/super-board-host.sh b/scripts/super-board-host.sh index 41c21d35..87900a7a 100755 --- a/scripts/super-board-host.sh +++ b/scripts/super-board-host.sh @@ -1,29 +1,60 @@ #!/usr/bin/env bash -# super-board-host.sh — which agent host is this shell inside: `claude` or `codex`. +# super-board-host.sh — which agent host is this shell inside: `claude`, `codex` or `opencode`. # # WHY. `/super-board run` launched from a Codex CLI session has no Claude Workflow tool, so the # workflow backend cannot start. The orchestrator checks its own tool list first; this is the -# check a script can make, and the tie-breaker when the tool list is unclear. +# check a script can make, and the tie-breaker when the tool list is unclear. A shell with no +# known sign gets no answer (exit 65): guessing Claude there starts the wrong backend. # -# THE SIGNAL. Codex CLI sets CODEX_THREAD_ID (and CODEX_SESSION_ID, the same value) in every -# shell it runs. Observed 2026-10-04 on codex-cli 0.160.0 with `codex exec -s read-only`: -# CODEX_THREAD_ID, CODEX_SESSION_ID, CODEX_VERSION, CODEX_SANDBOX=seatbelt, CODEX_CI=1. -# Not used: CODEX_HOME (people export it in their own profile), CODEX_SANDBOX (absent when -# the sandbox is off). A Codex lane started from a Claude session also carries CLAUDECODE; the -# Codex ids still win there, because the innermost host is the one running this shell. +# THE SIGNALS. +# - Codex CLI sets CODEX_THREAD_ID (and CODEX_SESSION_ID, the same value) in every shell it +# runs. Observed 2026-10-04 on codex-cli 0.160.0 with `codex exec -s read-only`: +# CODEX_THREAD_ID, CODEX_SESSION_ID, CODEX_VERSION, CODEX_SANDBOX=seatbelt, CODEX_CI=1. +# Not used: CODEX_HOME (people export it in their own profile), CODEX_SANDBOX (absent when +# the sandbox is off). A Codex lane started from a Claude session also carries CLAUDECODE; +# the Codex ids still win there, because the innermost host is the one running this shell. +# - OpenCode sets OPENCODE=1 (plus OPENCODE_SESSION_ID, OPENCODE_TERMINAL=1, AGENT=1) in the +# shells it runs. Observed 2026-10-19 on opencode v2.0.22 (env diff of a `run` shell). +# Checked before CLAUDECODE, which leaks into OpenCode started from a Claude session. +# Not used: AGENT=1 (a generic name other tools set too). +# - Claude Code sets CLAUDECODE=1. # -# Usage: super-board-host.sh prints `claude` or `codex`, exit 0 -# SUPER_BOARD_HOST=claude|codex forces the answer (tests, or a host the check misreads). +# ORDER. SUPER_BOARD_HOST, Codex ids, OPENCODE, CLAUDECODE, then the config's top-level +# `host` key (--config ), else exit 65. A live env sign beats the saved config. +# +# Usage: super-board-host.sh [--config ] +# prints `claude`, `codex` or `opencode`, exit 0 +# exit 65 no sign: nothing on stdout, how to choose on stderr +# exit 64 SUPER_BOARD_HOST or config `host` is not one of the three +# SUPER_BOARD_HOST=claude|codex|opencode forces the answer (tests, or a host the check +# misreads). set -euo pipefail +config= +case "${1:-}" in + --config) config=${2:-} ;; + '') ;; + *) echo "usage: super-board-host.sh [--config ]" >&2; exit 64 ;; +esac + case "${SUPER_BOARD_HOST:-}" in - claude|codex) echo "$SUPER_BOARD_HOST"; exit 0 ;; + claude|codex|opencode) echo "$SUPER_BOARD_HOST"; exit 0 ;; '') ;; - *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude or codex" >&2; exit 64 ;; + *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex or opencode" >&2; exit 64 ;; esac if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then echo codex -else +elif [ -n "${OPENCODE:-}" ]; then + echo opencode +elif [ -n "${CLAUDECODE:-}" ]; then echo claude +elif saved=$( [ -f "$config" ] && jq -r '.host // empty' "$config" 2>/dev/null ) && [ -n "$saved" ]; then + case "$saved" in + claude|codex|opencode) echo "$saved" ;; + *) echo "super-board-host.sh: config host must be claude, codex or opencode, got '$saved'" >&2; exit 64 ;; + esac +else + echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode, or set \"host\" in the board config" >&2 + exit 65 fi diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index acfab70f..1e494250 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -22,7 +22,8 @@ - installs or refreshes skills, .claude/bin scripts, workflows, guard hooks and their settings.json entries (runs the pack's install.sh) when any is missing or older - removes the folders of skills that no longer exist: super-refine, cleanup-wt, arch-loop - - migrates every .claude/super-board/configs/*.json to the v3 keys (migrate-config) + - migrates every .claude/super-board/configs/*.json to the v3 keys (migrate-config), and + writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode) - `git init` when the folder is not a repo - adds Matt Pocock's helper skills with npx when Node is present and they are missing It never installs a system tool or signs in: those come back in `needs` with the exact @@ -58,6 +59,7 @@ import sys COLUMNS = ["Backlog", "Ready", "Building", "QA", "Review", "Blocked", "Done"] +HOSTS = ("claude", "codex", "opencode") # config `host`; super-board-host.sh accepts the same LABELS = { "qa": ("0e8a16", "Test what exists; skips Building (super-board)"), "bug": ("d73a4a", "Something is broken; built, tested, reviewed (super-board)"), @@ -215,9 +217,10 @@ def machine_tz(): return "UTC" -def migrate_config(cfg): +def migrate_config(cfg, host=None): """Return (new config, [human-readable change lines]). Pure; never drops a project setting - that still means something.""" + that still means something. `host` (the actual worker host) fills a missing top-level + `host` key; a set one is never replaced.""" c = json.loads(json.dumps(cfg)) changes = [] variant = c.pop("variant", None) @@ -268,6 +271,9 @@ def migrate_config(cfg): if not c.get("worker_backend"): c["worker_backend"] = "workflow" changes.append("worker_backend \"workflow\" added") + if host and "host" not in c: + c["host"] = host + changes.append(f"host \"{host}\" added") return c, changes @@ -295,6 +301,7 @@ def check(root, pack, host="claude", home=None, catalog=None): old_dirs = [d for d in OLD_SKILL_DIRS if os.path.isdir(os.path.join(claude, "skills", d))] configs = {} verification = {} + host_errors = {} for p in sorted(glob.glob(os.path.join(claude, "super-board", "configs", "*.json"))): cfg = load_json(p) ready = subprocess.run([sys.executable, os.path.join(os.path.dirname(__file__), @@ -303,7 +310,10 @@ def check(root, pack, host="claude", home=None, catalog=None): if ready.returncode: verification[os.path.relpath(p, root)] = ready.stderr.strip() if isinstance(cfg, dict): - _, ch = migrate_config(cfg) + if "host" in cfg and cfg["host"] not in HOSTS: + host_errors[os.path.relpath(p, root)] = ( + f"host \"{cfg['host']}\" must be one of {', '.join(HOSTS)}") + _, ch = migrate_config(cfg, host) if ch: configs[os.path.relpath(p, root)] = ch older = bool(upgrade_rec.get("from")) or bool(old_dirs) or any( @@ -332,12 +342,13 @@ def check(root, pack, host="claude", home=None, catalog=None): ok.append("git repo found") if helpers: ok.append("required worker skills resolved for " + host) - red = bool(missing or old_dirs or configs or verification or not git or needs or not helpers) + red = bool(missing or old_dirs or configs or verification or host_errors or not git or needs or not helpers) return { "root": root, "pack": pack, "pack_version": pack_version, "installed_version": have_v, "upgrade": older, "from": from_v or ("older" if older else None), "added_skills": upgrade_rec.get("added_skills", []), - "missing": missing, "old_dirs": old_dirs, "configs": configs, "verification": verification, "git": git, + "missing": missing, "old_dirs": old_dirs, "configs": configs, "verification": verification, + "host_errors": host_errors, "git": git, "helper_skills": helpers, "helper_readiness": readiness, "needs": needs, "os": fam, "ok": ok, "green": not red, } @@ -397,7 +408,7 @@ def fix(root, pack, helpers=True, dry=False, host="claude", home=None, catalog=N for rel, ch in before["configs"].items(): p = os.path.join(root, rel) - new, _ = migrate_config(load_json(p)) + new, _ = migrate_config(load_json(p), host) if not dry: write_json(p, new) if before["upgrade"]: @@ -455,7 +466,7 @@ def text_report(res): lines.append(f" ✗ missing {entry['name']} for {res['helper_readiness']['host']}; install it in that host and restart") if res.get("helper_readiness", {}).get("error"): lines.append(" ✗ " + res["helper_readiness"]["error"]) - for path, error in res.get("verification", {}).items(): + for path, error in {**res.get("verification", {}), **res.get("host_errors", {})}.items(): lines.append(f" ✗ {path}: {error}") for n in res.get("needs", []): lines.append(f" Needs your machine: {n['name']} isn't installed. It's needed for {n['why']}.") diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index c9d3be07..e4bd4bbb 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -31,6 +31,13 @@ // only; the dispatcher refuses to run (exit 78) unless set here. "worker_backend": "workflow", + // Agent host this project runs on: "claude" | "codex" | "opencode". Separate from + // worker_backend. Onboard and setup's fix ALWAYS write it (setup fills a missing key + // from --host and never replaces a set one); setup's check flags any other value. + // `super-board-host.sh --config ` uses it only when SUPER_BOARD_HOST and + // every env sign (Codex ids, OPENCODE, CLAUDECODE) are absent. See docs/hosts.md. + "host": "claude", + // Optional post-completion adapter; never bundles or installs the external skill. // Runs only after selected work is verified merged and no work/approvals remain. // The supervising host checks the resulting diff; the helper itself is read-only. diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index 1d55e58e..c91e9022 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -360,7 +360,8 @@ Write it atomically (temp file + rename) after every answer. A halt never loses │ ├─ .claude/super-board/configs/.json (committed): description, project, target │ │ {type: repo | repo+url}, repo, base_branch, timezone, columns (the seven), labels, │ │ paths, merge_policy, migrations, collect (incl. custom), notifications {channel: - │ │ "session", bot_identity}, worker_backend "workflow", verify_commands (non-blank project checks). No `variant`. + │ │ "session", bot_identity}, worker_backend "workflow", host (always: `bash $SB/super-board-host.sh`; + │ │ on exit 65 ask claude/codex/opencode), verify_commands (non-blank project checks). No `variant`. │ ├─ .claude/super-board/active ← │ ├─ .gitignore += .claude/super-board/active, onboard-answers.json, onboard-staged/, │ │ backup/, inflight/, migrations/, upgrade.json (all under .claude/super-board/) diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 0bf73526..c63f5f08 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -21,10 +21,14 @@ pins one model for every card and skips the router. Keep this run choice in each wave and resume command; skip only the Claude usage guard. **Inside Codex, plain `run` is `--codex`.** Your tool list has no Workflow tool, or -`bash .claude/bin/super-board-host.sh` prints `codex` → run as `--codex` (tier flags and +`bash .claude/bin/super-board-host.sh --config ` prints `codex` → run as `--codex` (tier flags and `--codex=` still apply); it counts as explicit `--codex`, Review included. Say so once, in the first wave report: `🔁 codex host — running as --codex`. Never improvise a Claude workflow wave in Codex. +**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign) or +`opencode` (no OpenCode backend yet) halts before any claim, even with no Workflow tool, with one line: +`⛔ unknown host — set SUPER_BOARD_HOST=claude|codex and re-run`. Never guess a backend. + After the same plan and claim steps, launch this command as a background task: `bash .claude/bin/super-board-codex-wave.sh --config --cards --codex[=] --tier --output `. The helper starts one `super-board-codex-lane.sh` per card/lane, cards in parallel, @@ -242,7 +246,8 @@ Repeat until a done condition or halt gate fires: there is no cross-session claim at all, so never run two orchestrators (or /loop re-entries) against the same board without bot_identity. 4. **Launch** — With `--codex`, or no Workflow tool, or the host check printing `codex`, use - "Codex runs" above; otherwise Workflow tool with + "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65 or + `opencode` never reaches Launch (see "Unknown host halts"). Workflow tool with `scriptPath: .claude/workflows/super-board-wave.js` and `args: { configPath, cards, humanApprovesMerge, tier }` (`cards` hydrated after the planner: each carries `lane` and `labels`, and a `qa` card skips the Builder). Runs in the background; the diff --git a/tests/test-host.sh b/tests/test-host.sh index 02f669a4..fb323715 100644 --- a/tests/test-host.sh +++ b/tests/test-host.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Tests super-board-host.sh — the Codex-host check that turns a plain `run` into `--codex`. +# Tests super-board-host.sh — which agent host runs this shell, else exit 65 so the run +# asks for a host instead of guessing. set -euo pipefail KIT=$(cd "$(dirname "$0")/.." && pwd) S="$KIT/scripts/super-board-host.sh" @@ -7,14 +8,50 @@ fail() { echo "FAIL: $1" >&2; exit 1; } # A clean env, so the test answers the same inside Claude Code, Codex or CI. host() { env -i PATH="$PATH" "$@" bash "$S"; } -[ "$(host)" = claude ] || fail 'no Codex ids means claude' +[ "$(host CLAUDECODE=1)" = claude ] || fail 'CLAUDECODE means claude' [ "$(host CODEX_THREAD_ID=01a1)" = codex ] || fail 'CODEX_THREAD_ID means codex' [ "$(host CODEX_SESSION_ID=01a1)" = codex ] || fail 'CODEX_SESSION_ID means codex' [ "$(host CODEX_THREAD_ID=01a1 CLAUDECODE=1)" = codex ] || fail 'a Codex lane under Claude is codex' -[ "$(host CODEX_HOME=/x CODEX_SANDBOX=seatbelt)" = claude ] || fail 'CODEX_HOME/CODEX_SANDBOX alone are not a host' -[ "$(host CODEX_THREAD_ID=)" = claude ] || fail 'an empty id is not a host' [ "$(host SUPER_BOARD_HOST=codex)" = codex ] || fail 'override forces codex' [ "$(host SUPER_BOARD_HOST=claude CODEX_THREAD_ID=01a1)" = claude ] || fail 'override beats the env' +[ "$(host SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override accepts opencode' rc=0; host SUPER_BOARD_HOST=gemini >/dev/null 2>&1 || rc=$? [ "$rc" = 64 ] || fail "bad override exits 64, got $rc" + +# No sign and no override: empty stdout, a hint on stderr, exit 65 — never a guessed claude. +ERR=$(mktemp); trap 'rm -f "$ERR"' EXIT +nohost() { + local rc=0 out + out=$(host "$@" 2>"$ERR") || rc=$? + [ "$rc" = 65 ] || fail "'$*' should exit 65, got $rc" + [ -z "$out" ] || fail "'$*' should print nothing to stdout, got '$out'" + grep -q SUPER_BOARD_HOST "$ERR" || fail "'$*' should explain SUPER_BOARD_HOST on stderr" +} +nohost +nohost CODEX_HOME=/x CODEX_SANDBOX=seatbelt # not host signs +nohost CODEX_THREAD_ID= CLAUDECODE= # empty values are not signs +nohost AGENT=1 # OpenCode sets it, but so do other tools + +# OpenCode v2.0.22 exports OPENCODE=1. It wins over a CLAUDECODE leaked from a parent Claude +# session; the override and a Codex id still win over it. +[ "$(host OPENCODE=1)" = opencode ] || fail 'OPENCODE=1 means opencode' +[ "$(host OPENCODE=1 CLAUDECODE=1)" = opencode ] || fail 'OpenCode under a Claude parent is opencode' +[ "$(host SUPER_BOARD_HOST=claude OPENCODE=1)" = claude ] || fail 'override beats the OpenCode sign' +[ "$(host CODEX_THREAD_ID=01a1 OPENCODE=1)" = codex ] || fail 'a Codex id beats the OpenCode sign' + +# Config `host` (top-level key): used only when no override and no env sign is present. +T=$(mktemp -d); trap 'rm -f "$ERR"; rm -rf "$T"' EXIT +printf '{"host":"codex"}' > "$T/codex.json" +printf '{"worker_backend":"workflow"}' > "$T/none.json" +printf '{"host":"gemini"}' > "$T/bad.json" +cfg() { local f=$1; shift; env -i PATH="$PATH" "$@" bash "$S" --config "$f"; } +[ "$(cfg "$T/codex.json")" = codex ] || fail 'config host codex prints codex' +[ "$(cfg "$T/codex.json" CLAUDECODE=1)" = claude ] || fail 'an env sign beats config host' +[ "$(cfg "$T/codex.json" SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override beats config host' +for f in none missing; do + rc=0; out=$(cfg "$T/$f.json" 2>/dev/null) || rc=$? + [ "$rc" = 65 ] && [ -z "$out" ] || fail "config $f without host should exit 65, got $rc '$out'" +done +rc=0; cfg "$T/bad.json" >/dev/null 2>&1 || rc=$? +[ "$rc" = 64 ] || fail "a bad config host exits 64, got $rc" echo "PASS: super-board-host" diff --git a/tests/test-hosts-doc.sh b/tests/test-hosts-doc.sh new file mode 100644 index 00000000..1ad2f1ea --- /dev/null +++ b/tests/test-hosts-doc.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Tests docs/hosts.md — every host value super-board-host.sh accepts in its SUPER_BOARD_HOST +# case has a table row, and README's Install section links the page. +set -euo pipefail +cd "$(dirname "$0")/.." +fail() { echo "FAIL: $1" >&2; exit 1; } +DOC=docs/hosts.md +[ -f "$DOC" ] || fail "$DOC is missing" + +# The accepted values: the first case arm after `case "${SUPER_BOARD_HOST:-}" in`. +values=$(awk '/case "\$\{SUPER_BOARD_HOST:-\}" in/{getline; sub(/^[ \t]*/, ""); sub(/\).*/, ""); print; exit}' \ + scripts/super-board-host.sh | tr '|' ' ') +[ -n "$values" ] || fail "no SUPER_BOARD_HOST values found in super-board-host.sh" +n=0 +for v in $values; do + grep -q "^| \`$v\` |" "$DOC" || fail "host \`$v\` has no row in $DOC" + n=$((n + 1)) +done +[ "$n" -ge 3 ] || fail "expected at least claude, codex and opencode, got: $values" + +# README links the page from the Install section (between ## Install and the next ## heading). +awk '/^## Install/{on=1; next} /^## /{on=0} on' README.md | grep -q '(docs/hosts.md)' \ + || fail "README Install section must link docs/hosts.md" +echo "PASS: test-hosts-doc.sh ($n hosts)" diff --git a/tests/test-setup.sh b/tests/test-setup.sh index fd90b397..1aa485f3 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -74,6 +74,7 @@ q '.columns == ["Backlog","Ready","Building","QA","Review","Blocked","Done"]' "$ q '.collect.sources == ["sentry","github","prs","architecture"] and (.collect | has("lookback_runs") | not)' "$C" || fail "collect keys not migrated" q '.merge_policy.default == "human" and .notifications.channel == "session" and (.refine | has("qa_hook_rounds") | not)' "$C" \ || fail "merge rule / notifications / refine not migrated: $(cat "$C")" +q '.host == "claude"' "$C" || fail "fix must always write the top-level host (from --host): $(cat "$C")" [ ! -e "$T/.claude/super-board/upgrade.json" ] || fail "upgrade.json should be cleared once the upgrade is done" TXT=$(python3 "$SETUP" check --root "$T" --text || true) echo "$TXT" | grep -q "Upgraded for you" && fail "a finished upgrade must not be reported again" @@ -301,4 +302,22 @@ cmp -s "$GH_STATE" "$WORK/before.json" || fail "dry-run changed board state: $(c [ ! -e "$WORK/dry/.claude" ] || fail "dry-run must not write a backup file" echo "$OUT" | q '.status_backup == null and .restored == 0 and .added_columns != []' || fail "dry-run report wrong: $OUT" -echo "PASS: test-setup.sh (12 scenarios)" +# 13 — config `host`: check accepts claude / codex / opencode, flags anything else, and +# fix never overwrites a host that is already set. +H="$WORK/bare/.claude/super-board/configs"; mkdir -p "$H" +for h in claude codex opencode; do + printf '{"host":"%s"}' "$h" > "$H/b.json" + OUT=$(python3 "$SETUP" check --root "$WORK/bare" || true) + echo "$OUT" | q '.host_errors == {}' || fail "host $h must be accepted: $OUT" +done +printf '{"host":"gemini"}' > "$H/b.json" +OUT=$(python3 "$SETUP" check --root "$WORK/bare" || true) +echo "$OUT" | q '.green == false and (.host_errors | to_entries | .[0].value | test("gemini"))' \ + || fail "host gemini must be flagged: $OUT" +TXT=$(python3 "$SETUP" check --root "$WORK/bare" --text || true) +echo "$TXT" | grep -q "✗ .*b.json.*gemini" || fail "text report must name the bad host: $TXT" +python3 "$SETUP" fix --root "$WORK/bare" --no-helpers >/dev/null || true +q '.host == "gemini"' "$H/b.json" || fail "fix must not guess over a set host: $(cat "$H/b.json")" +rm "$H/b.json" + +echo "PASS: test-setup.sh (13 scenarios)"