From b93adcaa977f7154729339457cd845d0332b77b6 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 8 Nov 2026 16:31:52 -0500 Subject: [PATCH] bstack: run Claude on the plan through pi-claude-bridge and fall back on rate limits Pi roles now reach Claude through pi-claude-bridge, so they use the Claude Code login (Max plan) instead of an API key. A new fallback extension (bstack/pi/extensions/fallback.ts) moves to the next configured model once Pi's own retries give up on a 429; it does not switch on auth errors, bad requests, context overflow or aborts, and skips models with no login. `bercail bstack status` and `bercail doctor` report the bridge, the Claude login and the fallback order. --- bstack/MANIFEST | 1 + bstack/pi/extensions/fallback.ts | 114 +++++++++++++ config/agentic-dev/config.toml.example | 12 +- docs/bstack.md | 6 +- docs/bstack/08-make-it-yours.md | 17 ++ docs/config.md | 4 +- lib/bstack.sh | 118 +++++++++++-- lib/help.sh | 10 +- tests/bstack_test.sh | 224 ++++++++++++++++++++----- tests/pi_fallback_provider.ts | 82 +++++++++ tests/pi_fallback_test.mjs | 105 ++++++++++++ tests/pi_fallback_test.sh | 28 ++++ 12 files changed, 656 insertions(+), 65 deletions(-) create mode 100644 bstack/pi/extensions/fallback.ts create mode 100644 tests/pi_fallback_provider.ts create mode 100644 tests/pi_fallback_test.mjs create mode 100755 tests/pi_fallback_test.sh diff --git a/bstack/MANIFEST b/bstack/MANIFEST index f962492..ad519be 100644 --- a/bstack/MANIFEST +++ b/bstack/MANIFEST @@ -16,6 +16,7 @@ commands/how.md commands/why.md commands/unslop.md commands/pr-review.md +pi/extensions/fallback.ts pi/instructions.md pi/agents/bstack-builder.md pi/agents/bstack-judge.md diff --git a/bstack/pi/extensions/fallback.ts b/bstack/pi/extensions/fallback.ts new file mode 100644 index 0000000..b11ee52 --- /dev/null +++ b/bstack/pi/extensions/fallback.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isContextOverflow, type AssistantMessage } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const profile = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const efforts = /[#:]((?:off|minimal|low|medium|high|xhigh|max))$/; + +function read(name: string): string | undefined { + try { + return readFileSync(resolve(profile, name), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export default function (pi: ExtensionAPI) { + const attempted = new Set(); + let response: { status: number; rejected: boolean } | undefined; + let assistant: AssistantMessage | undefined; + + const noteSession = (model: string) => { + if (!process.env.HC_TERMINAL_ID) return; + const current = pi.getSessionName(); + const note = current ? `${current.replace(/ · fallback .*$/, "")} · fallback ${model.split("/")[0]}` : `fallback: ${model}`; + pi.setSessionName(note); + }; + + pi.on("message_start", (event, ctx) => { + if (event.message.role !== "user") return; + attempted.clear(); + ctx.ui.setStatus("bercail-fallback", undefined); + }); + pi.on("turn_start", () => { + response = undefined; + assistant = undefined; + }); + pi.on("after_provider_response", (event) => { + response = { + status: event.status, + rejected: event.headers["anthropic-ratelimit-unified-status"] === "rejected", + }; + }); + pi.on("turn_end", (event) => { + assistant = event.message.role === "assistant" ? event.message : undefined; + }); + + pi.on("agent_before_settle", async (event, ctx) => { + if (event.outcome !== "error" || assistant?.stopReason !== "error" || ctx.signal?.aborted) return; + const error = assistant.errorMessage ?? ""; + if (response && [400, 401, 403].includes(response.status)) return; + if (isContextOverflow(assistant, ctx.model?.contextWindow)) return; + // Explicit request errors win over transport signals, including bridge headers. + if (/\b(?:400|401|403)\b|unauthenticated|unauthorized|not.authorized|authenticat|invalid.api.key|permission.denied|access.denied|forbidden|invalid_request_error|bad.request|context.{0,30}(?:overflow|length|window|exceed|limit)|prompt.{0,20}(?:too.long|exceed)|too.many.tokens|request.{0,10}abort/i.test(error)) return; + if (!(response?.status === 429 || response?.rejected || /\b429\b|rate[_ -]?limit|usage[_ -]?limit|usage cap|quota (?:exceeded|exhausted)|hit your limit|out of (?:usage|credits)/i.test(error))) return; + + const current = `${assistant.provider}/${assistant.model}`; + attempted.add(current); + const models = new Map((read("models") ?? "").split(/\r?\n/).flatMap<[string, string]>((line) => { + const index = line.indexOf("="); + return index > 0 ? [[line.slice(0, index).trim(), line.slice(index + 1).trim()]] : []; + })); + const order = (read("fallback") ?? "claude\ngpt\ngrok").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const skipped: string[] = []; + for (const family of order) { + const configured = models.get(family); + if (!configured) { skipped.push(`${family}: no model`); continue; } + const effort = configured.match(efforts)?.[1]; + const name = configured.replace(efforts, ""); + if (attempted.has(name)) continue; + attempted.add(name); + const slash = name.indexOf("/"); + const model = slash > 0 ? ctx.modelRegistry.find(name.slice(0, slash), name.slice(slash + 1)) : undefined; + if (!model) { skipped.push(`${name}: unavailable`); continue; } + if (model.provider === "claude-bridge") { + // The bridge's dummy API key makes setModel succeed even without a Claude login. + try { + const auth = await pi.exec("env", ["-u", "ANTHROPIC_API_KEY", "-u", "ANTHROPIC_BASE_URL", "-u", "ANTHROPIC_AUTH_TOKEN", "claude", "auth", "status", "--json"], { signal: ctx.signal, timeout: 5000 }); + const login = auth.code === 0 ? JSON.parse(auth.stdout) : undefined; + if (login?.loggedIn !== true || !["claude.ai", "oauth_token"].includes(login.authMethod)) { + skipped.push(`${name}: no Claude subscription login`); + continue; + } + } catch { + if (ctx.signal?.aborted) return; + skipped.push(`${name}: Claude login unavailable`); + continue; + } + } + if (ctx.signal?.aborted) return; + if (!(await pi.setModel(model))) { skipped.push(`${name}: no login`); continue; } + if (effort) pi.setThinkingLevel(effort as Parameters[0]); + const text = `Rate limit: switched ${current} → ${name}. Continuing the same request.`; + ctx.ui.setStatus("bercail-fallback", `fallback: ${name}`); + ctx.ui.notify(text, "warning"); + noteSession(name); + const failed = [...event.context.contextEntries].reverse().find((entry) => entry.messages.some((message) => message.role === "assistant" && message.stopReason === "error") && entry.sourceEntry.type === "message"); + // Omit the empty error assistant. A later provider 400s on it and then looks like a non-limit failure. + return { + entries: [...event.entries, + ...(failed ? [{ type: "context_edit" as const, targetId: failed.sourceEntry.id, replacement: null }] : []), + { type: "custom_message" as const, customType: "bercail-fallback", content: text, display: true, details: { from: current, to: name, skipped } }, + ], + continue: true, + }; + } + const text = `Rate limit: fallback exhausted (${order.join(" → ") || "no candidates"}). All configured models are limited or unavailable.${skipped.length ? ` ${skipped.join("; ")}.` : ""}`; + ctx.ui.setStatus("bercail-fallback", "fallback exhausted"); + ctx.ui.notify(text, "error"); + return { entries: [...event.entries, { type: "custom" as const, customType: "bercail-fallback-error", data: { text, attempted: [...attempted], skipped } }], continue: false }; + }); +} diff --git a/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index d5c5d60..e8bda18 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -12,11 +12,17 @@ review = "tuicr" auto_review = true [bstack] -# opencode with your own Claude, ChatGPT, and X subscriptions. New installs turn -# it on; `bercail bstack setup` connects plans. +# Pi with your own Claude, ChatGPT, and X subscriptions. Setup installs the +# Claude bridge. Sign in with `claude auth login`, /login openai, /login xai. enabled = true +# Existing configs without this key keep opencode. +harness = "pi" +# Pi rate-limit fallback candidates, scanned from the start, skipping the current +# and already attempted models. Only claude/gpt/grok, no duplicates. [] disables it. +fallback = ["claude", "gpt", "grok"] # A model per provider. Empty: auto, the newest model of each connected plan -# (re-picked on bercail update). Set an opencode id (#variant allowed) to pin one. +# (re-picked on bercail update). Set a provider/model id (#variant allowed) to pin one. +# Claude on Pi: claude-bridge/claude-opus-5-5, not anthropic/claude-opus-5-5. claude = "" gpt = "" grok = "" diff --git a/docs/bstack.md b/docs/bstack.md index c314b4f..128f1c9 100644 --- a/docs/bstack.md +++ b/docs/bstack.md @@ -14,9 +14,11 @@ dev # the desk starts opencode as the bstack agent ## On Pi -New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail update` to render the [Pi](https://pi.dev) profile with your own logins (`/login anthropic`, `/login openai`, `/login xai` inside `pi`). Pi's Anthropic login bills extra usage per token. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up. +New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail bstack setup` to install [Pi](https://pi.dev) (>=1.2.0) and `npm:pi-claude-bridge@0.11.0`. Claude uses your Claude Code login (`claude auth login`), not Pi's `/login anthropic` or per-token Extra Usage. GPT and Grok use `/login openai` and `/login xai` inside Pi. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up. -bercail renders the profile to `~/.local/share/bercail/pi/` and never edits `~/.pi/agent/`; each launch names the profile with flags. No bercail extensions are installed or loaded. Role boundaries and git rules are instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs. +bercail renders the profile to `~/.local/share/bercail/pi/`. Installing the bridge adds its pinned package to Pi's settings, leaving unrelated settings and auth intact. Every role explicitly loads the bridge and the managed `extensions/fallback.ts` with `-e`, and unsets `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, and `ANTHROPIC_AUTH_TOKEN` so fallback to Claude uses your plan. Role boundaries and git rules remain instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs. + +After Pi exhausts its own rate-limit retries, the extension tries the next model in `[bstack] fallback` (default `["claude", "gpt", "grok"]`). It scans from the start, skipping the current and already attempted models, and leaves the initial role picks unchanged. Set `fallback = ["gpt", "grok"]` to limit candidates, or `[]` to disable them. Families must be unique. The profile stores resolved `family=model` lines in `pi/models` and one fallback family per line in `pi/fallback`. `bercail bstack status` reports the order, bridge availability, and Claude Code subscription auth (`authMethod` `claude.ai` or `oauth_token`, not an API key). `bercail doctor` checks the installed bridge and flags missing or stale profile data. ## Where to find what diff --git a/docs/bstack/08-make-it-yours.md b/docs/bstack/08-make-it-yours.md index 0651ce3..1445500 100644 --- a/docs/bstack/08-make-it-yours.md +++ b/docs/bstack/08-make-it-yours.md @@ -31,6 +31,23 @@ connect plans later ([the order](./02-the-bstack-agent.md#the-roles)). Higher effort reasons longer and spends more of each plan; a model without the level you ask for gets the next one down. +### Pi rate-limit fallback + +On `harness = "pi"`, `fallback = ["claude", "gpt", "grok"]` sets runtime +candidates after Pi exhausts rate-limit retries. Each switch scans from the +start, skipping the current and already attempted models. For example, +`fallback = ["gpt", "grok"]` tries GPT before Grok. Only these three family +names are valid, duplicates are rejected, and `[]` disables fallback. This does +not change the initial role model picks above or opencode behavior. + +Pi Claude IDs use `claude-bridge/`, for example +`claude-bridge/claude-opus-5-5#high`. The bridge uses `claude auth login`, not +Pi's Anthropic login. Status and doctor count a subscription only when +`claude auth status` reports `authMethod` `claude.ai` or `oauth_token`. +Run `bercail update` after changing fallback or model pins. `bercail bstack status` +shows the order and `bercail doctor` flags stale profile data. See +[On Pi](../bstack.md#on-pi) for installation and role launches. + ## What bstack turns on in opencode - **`bstack` as the default agent**, on the lead's model, with bercail's diff --git a/docs/config.md b/docs/config.md index 9528c1c..03a3cef 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,8 @@ auto_review = true [bstack] enabled = true +harness = "pi" # unset in older configs: opencode +fallback = ["claude", "gpt", "grok"] # Pi rate-limit candidates, no duplicates claude = "" gpt = "" grok = "" @@ -33,7 +35,7 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b - worktrunk hooks (`~/.config/worktrunk/config.toml`, `herdr-layout.sh`). - `~/.local/bin/{bercail,agentic-dev,bercail-desk-agent,bercail-desk-claude-hook}` (a mise or packslip install keeps its own `bercail`), and `~/.local/share/bercail/`. - `~/.agents/skills/{handoff,review,bstack,council,autopilot,how,why,unslop,desk-restore}`. -- bstack's files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)). +- bstack's Pi profile in `~/.local/share/bercail/pi/` and role templates in `~/.config/herdr-core/templates/`, or its files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)). The Pi harness installs `npm:pi-claude-bridge@0.11.0` into Pi's package settings. - The local TUI plugin `~/.config/opencode/plugins/bercail-desk/tui.ts`, including when bstack is off. - `diff_watch_interval_ms` in `~/.config/tuicr/config.toml`, unless you set it already. - On Linux and Omarchy, the extras in [platforms](development.md#platforms). diff --git a/lib/bstack.sh b/lib/bstack.sh index 8fccdc7..97b9548 100644 --- a/lib/bstack.sh +++ b/lib/bstack.sh @@ -23,6 +23,7 @@ BSTACK_RENDERED=(agents/bstack.md agents/bstack-builder.md agents/bstack-judge.m # and every launch names this dir with flags. BSTACK_PI_DIR="${AGENTIC_DEV_SHARE_DIR}/pi" BSTACK_PI_TEMPLATES="${XDG_CONFIG_HOME:-$HOME/.config}/herdr-core/templates" +BSTACK_PI_BRIDGE="npm:pi-claude-bridge@0.11.0" BSTACK_PI_RENDERED=(instructions.md agents/bstack-builder.md agents/bstack-judge.md agents/bstack-autopilot.md prompts/ready.md prompts/council.md prompts/handoff.md prompts/autopilot.md prompts/how.md prompts/why.md prompts/unslop.md prompts/pr-review.md @@ -171,7 +172,7 @@ _bstack_integration_id() { _bstack_family_label() { if _bstack_on_pi; then case "$1" in - claude) printf "Pi's Anthropic login (billed per token as extra usage)" ;; + claude) printf 'Claude plan (Claude Code via pi-claude-bridge)' ;; gpt) printf 'Sign in with ChatGPT (openai, or openai-codex)' ;; grok) printf 'xAI login' ;; esac @@ -285,6 +286,7 @@ _bstack_pick_model() { bstack_login_command() { if _bstack_on_pi; then + if [[ "$1" == claude ]]; then printf 'claude auth login'; return 0; fi printf '/login %s (inside pi)' "$(_bstack_pi_providers "$1" | head -1)" return 0 fi @@ -297,10 +299,45 @@ bstack_login_command() { # --- pi ----------------------------------------------------------------------- +# Runtime fallback candidates, separate from the initial role model picks. +# The array is a list of family names; an empty array disables fallback. +bstack_pi_fallback() { + local raw + raw="$(_bstack_toml_get bstack fallback)" + [[ -n "$raw" ]] || raw='["claude", "gpt", "grok"]' + if ! jq -e 'type == "array" and all(.[]; . == "claude" or . == "gpt" or . == "grok") and (length == (unique | length))' \ + <<<"$raw" >/dev/null 2>&1; then + warn '[bstack] fallback must be an array of claude, gpt, grok, without duplicates' + return 1 + fi + jq -r '.[]' <<<"$raw" +} + +_bstack_pi_version_ok() { + local v + v="$(pi --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+)+' | head -1)" + [[ -n "$v" && "$(printf '%s\n' 1.2.0 "$v" | sort -V | head -1)" == 1.2.0 ]] +} + +_bstack_pi_bridge_present() { + dep_present pi || return 1 + local table + table="$(pi --offline -e "$BSTACK_PI_BRIDGE" --list-models 2>/dev/null)" || return 1 + awk '$1 == "claude-bridge" { found = 1 } END { exit !found }' <<<"$table" +} + +# The bridge's dummy key and Claude Code's API-key auth are not plan logins. +_bstack_pi_claude_ready() { + dep_present claude || return 1 + env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN \ + claude auth status --json 2>/dev/null \ + | jq -e '.loggedIn == true and (.authMethod == "claude.ai" or .authMethod == "oauth_token")' >/dev/null 2>&1 +} + # The Pi providers behind a family, the one to log in to first. _bstack_pi_providers() { case "$1" in - claude) printf '%s\n' anthropic ;; + claude) printf '%s\n' claude-bridge ;; gpt) printf '%s\n' openai openai-codex ;; grok) printf '%s\n' xai ;; esac @@ -311,7 +348,8 @@ _bstack_pi_providers() { _bstack_pi_connected_ids() { local provider dep_present pi || return 0 - for provider in anthropic openai openai-codex xai; do + _bstack_pi_claude_ready && printf '%s\n' claude-bridge + for provider in openai openai-codex xai; do if pi auth check --provider "$provider" --no-refresh --json 2>/dev/null \ | jq -e '.status == "ready"' >/dev/null 2>&1; then printf '%s\n' "$provider" @@ -323,7 +361,7 @@ _bstack_pi_connected_ids() { # Pi clamps a thinking level the model lacks, so a model that thinks offers them all. _bstack_pi_load_models() { local table - table="$(pi --offline --list-models 2>/dev/null || true)" + table="$(pi --offline -e "$BSTACK_PI_BRIDGE" --list-models 2>/dev/null || true)" BSTACK_MODELS="$(awk 'NR > 1 && NF >= 5 { print $1 "/" $2 }' <<<"$table")" BSTACK_VARIANTS="$(awk 'NR > 1 && NF >= 5 { print $1 "/" $2, ($5 == "yes" ? "low,medium,high,xhigh,max" : "") }' <<<"$table")" } @@ -333,7 +371,7 @@ _bstack_pi_pick_model() { local list provider model="" list="$(cat)" case "$1" in - claude) model="$(grep -E '^anthropic/claude-opus-[0-9]+(-[0-9]{1,2})?$' <<<"$list" | sort -V | tail -1)" ;; + claude) model="$(grep -E '^claude-bridge/claude-opus-[0-9]+(-[0-9]{1,2})?$' <<<"$list" | sort -V | tail -1)" ;; gpt) for provider in openai openai-codex; do grep -qxF "$provider" <<<"$BSTACK_CONNECTED" || continue @@ -359,15 +397,17 @@ _bstack_pi_model_id() { # empty is the lead. Skills are named because settings.json may filter them out. bstack_pi_command() { local role="$1" model="$2" brief="${3:-}" id - local -a argv=(pi) + local -a argv=(env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN) + [[ -z "$role" ]] || argv+=("BERCAIL_ROLE=$role") + argv+=(pi) [[ -z "$model" ]] || argv+=(--model "$(_bstack_pi_model_id "$model")") + argv+=(-e "$BSTACK_PI_BRIDGE" -e "$BSTACK_PI_DIR/extensions/fallback.ts") argv+=(--prompt-template "$BSTACK_PI_DIR/prompts" --append-system-prompt "$BSTACK_PI_DIR/instructions.md") [[ -z "$brief" ]] || argv+=(--append-system-prompt "$BSTACK_PI_DIR/agents/$brief.md") for id in "${AGENTIC_DEV_SKILL_IDS[@]}"; do argv+=(--skill "$(skill_canonical_dir "$id")") done - [[ -z "$role" ]] || printf 'env BERCAIL_ROLE=%s ' "$role" printf '%q ' "${argv[@]}" | sed 's/ $//' } @@ -395,6 +435,9 @@ _bstack_deploy_pi() { _bstack_render "$work/src/pi/$rel" "$work/pi-rendered" copy_file "$work/pi-rendered" "$BSTACK_PI_DIR/$rel" done + copy_file "$work/src/pi/extensions/fallback.ts" "$BSTACK_PI_DIR/extensions/fallback.ts" + bstack_pi_fallback >"$work/fallback" || return 1 + copy_file "$work/fallback" "$BSTACK_PI_DIR/fallback" _bstack_body "$work/src/agents/bstack-pr-review.md" >"$work/pr-review.md" copy_file "$work/pr-review.md" "$BSTACK_PI_DIR/agents/bstack-pr-review.md" @@ -449,7 +492,9 @@ _bstack_remove_pi() { # Install what the harness needs: Pi, or opencode. _bstack_install_harness() { if _bstack_on_pi; then - maybe_mise_install pi pi + maybe_mise_install pi pi || return 1 + _bstack_pi_version_ok || { warn 'bstack needs Pi >=1.2.0'; return 1; } + _bstack_pi_bridge_present || run pi install "$BSTACK_PI_BRIDGE" else install_opencode_binary fi @@ -475,6 +520,22 @@ _bstack_status_pi() { else log " pi: missing (bercail bstack setup installs it)" fi + if _bstack_pi_bridge_present; then + log " bridge: present ($BSTACK_PI_BRIDGE)" + else + log " bridge: missing (pi install $BSTACK_PI_BRIDGE)" + fi + if _bstack_pi_claude_ready; then + log " Claude auth: ready (claude auth status --json)" + else + log " Claude auth: no subscription login (claude auth login)" + fi + local fallback + if fallback="$(bstack_pi_fallback)"; then + log " fallback: $(paste -sd' ' - <<<"$fallback") (skips current/already attempted)" + else + log " fallback: invalid [bstack] fallback" + fi log " logins:" for family in "${BSTACK_FAMILIES[@]}"; do model="$(bstack_resolve_model "$family")" @@ -507,7 +568,7 @@ _bstack_setup_pi() { deploy_bstack bstack_status log "" - log "next: type the /login lines above in pi for anything missing, then run: bercail update" + log "next: run claude auth login for Claude; type the /login lines above in pi for other missing logins, then run: bercail update" } _bstack_doctor_pi() { @@ -516,8 +577,24 @@ _bstack_doctor_pi() { log " missing pi (bstack harness is pi; bercail bstack setup)" return 1 fi - log " ok pi $(pi --version 2>/dev/null)" - for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md; do + if _bstack_pi_version_ok; then + log " ok pi $(pi --version 2>/dev/null)" + else + log " old pi $(pi --version 2>/dev/null) (bstack needs >=1.2.0)" + missing=1 + fi + if _bstack_pi_bridge_present; then + log " ok bridge $BSTACK_PI_BRIDGE" + else + log " missing bridge $BSTACK_PI_BRIDGE (bercail bstack setup)" + missing=1 + fi + if _bstack_pi_claude_ready; then + log " ok Claude auth (claude auth status --json)" + else + log " -- Claude auth: no subscription login (claude auth login)" + fi + for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md extensions/fallback.ts models fallback; do if [[ ! -f "$BSTACK_PI_DIR/$rel" ]]; then log " missing bstack $BSTACK_PI_DIR/$rel (bercail update)" missing=1 @@ -531,6 +608,14 @@ _bstack_doctor_pi() { log " yours $path (not managed by bstack)" fi done + local fallback + if ! fallback="$(bstack_pi_fallback)"; then + log " invalid bstack fallback (fix [bstack] fallback, then bercail update)" + missing=1 + elif [[ -f "$BSTACK_PI_DIR/fallback" && "$fallback" != "$(<"$BSTACK_PI_DIR/fallback")" ]]; then + log " stale bstack fallback (bercail update)" + missing=1 + fi if [[ -f "$BSTACK_PI_DIR/models" ]] && [[ "$(_bstack_resolved_text)" != "$(<"$BSTACK_PI_DIR/models")" ]]; then log " stale bstack models (a login or model changed since the last deploy; bercail update)" missing=1 @@ -548,7 +633,7 @@ _bstack_doctor_pi() { fi done if [[ "$any_login" -eq 0 ]]; then - log " missing bstack login: none of anthropic, openai, xai is ready in Pi" + log " missing bstack login: none of Claude Code, openai, xai is ready in Pi" missing=1 fi if [[ "$missing" -eq 0 ]]; then @@ -621,6 +706,10 @@ deploy_bstack() { remove_bstack quiet return 0 fi + if _bstack_on_pi; then + bstack_pi_fallback >/dev/null || return 1 + _bstack_install_harness || return 1 + fi bstack_load_state reload work="$(mktemp -d)" if ! DRY_RUN=0 deploy_install_file "bstack/MANIFEST" "$work/MANIFEST" >/dev/null; then @@ -638,9 +727,10 @@ deploy_bstack() { done <"$work/MANIFEST" if _bstack_on_pi; then - _bstack_deploy_pi "$work" + local result=0 + _bstack_deploy_pi "$work" || result=$? rm -rf "$work" - return 0 + return "$result" fi [[ "$(bstack_harness)" == opencode ]] || warn "[bstack] harness = \"$(bstack_harness)\" is not pi or opencode; using opencode" _bstack_remove_pi diff --git a/lib/help.sh b/lib/help.sh index d48b296..3a86b44 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -84,10 +84,12 @@ bstack (opencode, bring your own Claude / GPT / Grok subscription): /ready, /handoff, /bstack-setup, /how, /why, /unslop, /autopilot (a PR to green; pushes, never merges), /pr-review (bercail review-pr's read-only decisions pass). Settings: [bstack] in config.toml. - [bstack] harness = "pi" runs bstack on Pi with your own logins (/login anthropic, - /login openai, /login xai inside pi): bercail update renders ~/.local/share/bercail/pi/ - and herdr-core templates, and the lead starts roles as hc siblings. opencode stays - the fallback for existing configs without a harness setting. New configs select Pi. + [bstack] harness = "pi" runs bstack on Pi >=1.2 with pi-claude-bridge@0.11.0: + Claude uses claude auth login; GPT and Grok use /login openai and /login xai in pi. + bercail update renders ~/.local/share/bercail/pi/ and herdr-core templates. Every + role loads the bridge and managed rate-limit fallback extension. [bstack] fallback + sets candidate order (default ["claude", "gpt", "grok"]). The lead starts roles as + hc siblings. Existing configs without harness keep opencode. New configs select Pi. Config: ~/.config/bercail/config.toml desk agent default, review, [bstack] diff --git a/tests/bstack_test.sh b/tests/bstack_test.sh index 71614d6..206abdc 100644 --- a/tests/bstack_test.sh +++ b/tests/bstack_test.sh @@ -64,21 +64,38 @@ cat >"$FAKE_BIN/pi" <<'SH' #!/usr/bin/env bash if [[ "$*" == *--credentials* ]]; then echo "credentials must never be requested" >&2; exit 9; fi case "$1" in - --version) echo "1.5.0" ;; + --version) echo "${FAKE_PI_VERSION:-1.5.0}" ;; + install) + [[ "$2" == npm:pi-claude-bridge@0.11.0 ]] || exit 2 + [[ -z "${FAKE_PI_INSTALL_FAIL:-}" ]] || exit 1 + mkdir -p "$HOME/.pi" + touch "$HOME/.pi/bridge-installed" + mkdir -p "$HOME/.pi/agent" + [[ -f "$HOME/.pi/agent/settings.json" ]] || printf '{}\n' >"$HOME/.pi/agent/settings.json" + jq '.packages = ((.packages // []) + ["npm:pi-claude-bridge@0.11.0"] | unique)' \ + "$HOME/.pi/agent/settings.json" >"$HOME/.pi/settings.tmp" + mv "$HOME/.pi/settings.tmp" "$HOME/.pi/agent/settings.json" + printf '%s\n' "$*" >>"$HOME/.pi/install-log" + ;; + list) + [[ -f "$HOME/.pi/bridge-installed" && -z "${FAKE_PI_BRIDGE_MISSING:-}" ]] || exit 0 + printf 'User packages:\n npm:pi-claude-bridge@0.11.0\n /fake/bridge\n' + ;; auth) for id in ${FAKE_PI_READY:-}; do if [[ "$*" == *"--provider $id "* ]]; then printf '{"status":"ready","provider":"%s"}\n' "$id"; exit 0; fi done printf '{"status":"not_ready"}\n'; exit 1 ;; --offline) - [[ "$2" == --list-models ]] || exit 2 + [[ "$*" == '--offline -e npm:pi-claude-bridge@0.11.0 --list-models' ]] || exit 2 + [[ -f "$HOME/.pi/bridge-installed" && -z "${FAKE_PI_BRIDGE_MISSING:-}" ]] || exit 3 cat <<'EOF' provider model context max-out thinking images -anthropic claude-opus-4-8 1M 128K yes yes -anthropic claude-opus-4-5-20251101 200K 64K yes yes -anthropic claude-opus-5 1M 128K yes yes -anthropic claude-opus-5-5 1M 128K yes yes -anthropic claude-sonnet-5-5 1M 128K yes yes +claude-bridge claude-opus-4-8 1M 128K yes yes +claude-bridge claude-opus-4-5-20251101 200K 64K yes yes +claude-bridge claude-opus-5 1M 128K yes yes +claude-bridge claude-opus-5-5 1M 128K yes yes +claude-bridge claude-sonnet-5-5 1M 128K yes yes openai gpt-6-sol 272K 128K yes yes openai gpt-6.1-sol 272K 128K yes yes openai gpt-6.1-terra 272K 128K yes yes @@ -89,9 +106,28 @@ xai grok-4.7 500K 500K yes yes xai grok-plain 500K 500K no yes EOF ;; - *) exit 2 ;; + *) + [[ -n "${FAKE_PI_LAUNCH_LOG:-}" ]] || exit 2 + printf '%s\n' "$@" >"$FAKE_PI_LAUNCH_LOG" + env >"$FAKE_PI_LAUNCH_LOG.env" + ;; esac SH +cat >"$FAKE_BIN/claude" <<'SH' +#!/usr/bin/env bash +[[ "$*" == 'auth status --json' ]] || exit 2 +[[ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_BASE_URL:-}${ANTHROPIC_AUTH_TOKEN:-}" ]] || exit 4 +method="${FAKE_CLAUDE_AUTH_METHOD:-}" +if [[ -z "$method" && " ${FAKE_PI_READY:-} " == *' claude-bridge '* ]]; then + method=claude.ai +fi +if [[ -z "$method" || "$method" == none ]]; then + printf '{"loggedIn":false,"authMethod":"none"}\n' + exit 1 +fi +printf '{"loggedIn":true,"authMethod":"%s"}\n' "$method" +SH +chmod +x "$FAKE_BIN/claude" chmod +x "$FAKE_BIN/pi" export PATH="$FAKE_BIN:$PATH" @@ -507,27 +543,28 @@ EOF test_pi_models() { reset_home write_pi_config - export FAKE_PI_READY="anthropic openai xai" + export FAKE_PI_READY="claude-bridge openai xai" + _bstack_install_harness >/dev/null bstack_load_state reload - assert_eq "anthropic/claude-opus-5-5#high" "$(bstack_resolve_model claude)" "newest plain opus" + assert_eq "claude-bridge/claude-opus-5-5#high" "$(bstack_resolve_model claude)" "newest plain opus" assert_eq "openai/gpt-6.1-sol#high" "$(bstack_resolve_model gpt)" "newest sol" assert_eq "xai/grok-4.7#high" "$(bstack_resolve_model grok)" "a dated 4.20 build is not newer" - assert_eq "anthropic/claude-opus-5-5:high" "$(_bstack_pi_model_id "$(bstack_role_model lead)")" "#high maps to :high" + assert_eq "claude-bridge/claude-opus-5-5:high" "$(_bstack_pi_model_id "$(bstack_role_model lead)")" "#high maps to :high" assert_eq "xai/grok-4.7#high" "$(bstack_role_model build)" "build defaults to grok" - FAKE_PI_READY="anthropic openai-codex" bstack_load_state reload - assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="anthropic openai-codex" bstack_resolve_model gpt)" "openai-codex stands in for openai" - assert_eq "" "$(FAKE_PI_READY="anthropic openai-codex" bstack_resolve_model grok)" "no xai login: no grok" - assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="anthropic openai-codex" bstack_role_model build)" "build falls through to the next family with a login" + FAKE_PI_READY="claude-bridge openai-codex" bstack_load_state reload + assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="claude-bridge openai-codex" bstack_resolve_model gpt)" "openai-codex stands in for openai" + assert_eq "" "$(FAKE_PI_READY="claude-bridge openai-codex" bstack_resolve_model grok)" "no xai login: no grok" + assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="claude-bridge openai-codex" bstack_role_model build)" "build falls through to the next family with a login" FAKE_PI_READY="" bstack_load_state reload assert_eq "" "$(FAKE_PI_READY="" bstack_role_model lead)" "no login at all: Pi's default" _bstack_toml_set bstack effort '"none"' - FAKE_PI_READY="anthropic" bstack_load_state reload - assert_eq "anthropic/claude-opus-5-5" "$(FAKE_PI_READY="anthropic" bstack_resolve_model claude)" "effort none: no suffix" + FAKE_PI_READY="claude-bridge" bstack_load_state reload + assert_eq "claude-bridge/claude-opus-5-5" "$(FAKE_PI_READY="claude-bridge" bstack_resolve_model claude)" "effort none: no suffix" _bstack_toml_set bstack effort '"max"' - assert_eq "anthropic/claude-opus-5-5#max" "$(FAKE_PI_READY="anthropic" bstack_resolve_model claude)" "effort max" + assert_eq "claude-bridge/claude-opus-5-5#max" "$(FAKE_PI_READY="claude-bridge" bstack_resolve_model claude)" "effort max" local BSTACK_MODELS BSTACK_VARIANTS BSTACK_MODELS="xai/grok-plain" BSTACK_VARIANTS="xai/grok-plain " @@ -542,7 +579,7 @@ test_pi_deploy() { mkdir -p "$HOME/.pi/agent" printf '{"defaultModel":"mine"}\n' >"$HOME/.pi/agent/settings.json" printf '{"token":"secret"}\n' >"$HOME/.pi/agent/auth.json" - export FAKE_PI_READY="anthropic xai" + export FAKE_PI_READY="claude-bridge xai" deploy_bstack >"$TMP_DIR/pi.log" 2>&1 || fail "deploy: $(cat "$TMP_DIR/pi.log")" local p t rel p="$(PI_DIR)" @@ -565,7 +602,8 @@ test_pi_deploy() { grep -q 'Desks only' "$p/instructions.md" || fail "instructions require a desk" grep -rqi 'headless' "$p/prompts" "$p/instructions.md" && fail "Pi has no headless fallback" grep -q -- '--branch NAME --stage bstack --issue ID' "$p/prompts/handoff.md" || fail "handoff opens a desk" - [[ ! -d "$p/extensions" ]] || fail "Pi deploys no extensions" + cmp "$ROOT/bstack/pi/extensions/fallback.ts" "$p/extensions/fallback.ts" || fail "managed fallback extension" + assert_eq "$(printf 'claude\ngpt\ngrok')" "$(<"$p/fallback")" "default runtime fallback" grep -q 'bercail.role=agent' "$p/instructions.md" || fail "instructions warn off the desk's label" for rel in bstack-builder bstack-judge bstack-autopilot bstack-council-claude bstack-council-grok; do @@ -575,20 +613,23 @@ test_pi_deploy() { sed -n 3p "$t/$rel.md" | grep -q "managed by bercail (bstack)" || fail "marker in the description of $rel" sed -n 5p "$t/$rel.md" | grep -qx -- '---' || fail "frontmatter holds only key: value in $rel" grep -q ' pi ' "$t/$rel.md" || fail "$rel runs pi" - grep -Eq -- ' (-e|--extension)( |$)' "$t/$rel.md" && fail "$rel loads no bercail extension" + grep -q -- "-e $p/extensions/fallback.ts" "$t/$rel.md" || fail "$rel loads fallback" + grep -q -- "-e npm:pi-claude-bridge@0.11.0" "$t/$rel.md" || fail "$rel loads bridge" + grep -q -- "env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN " "$t/$rel.md" || fail "$rel unsets Anthropic env" grep -q -- "--prompt-template $p/prompts" "$t/$rel.md" || fail "--prompt-template in $rel" grep -q -- "--append-system-prompt $p/instructions.md" "$t/$rel.md" || fail "instructions in $rel" grep -q -- '--skill .*/skills/council' "$t/$rel.md" || fail "--skill in $rel" done [[ ! -e "$t/bstack-council-gpt.md" ]] || fail "no gpt seat without a login" - grep -q '^command: env BERCAIL_ROLE=builder pi --model xai/grok-4.7:high ' "$t/bstack-builder.md" || fail "builder: $(cat "$t/bstack-builder.md")" - grep -q '^command: env BERCAIL_ROLE=judge pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-judge.md" || fail "judge" - grep -q '^command: env BERCAIL_ROLE=autopilot pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-autopilot.md" || fail "autopilot" - grep -q '^command: env BERCAIL_ROLE=council pi --model xai/grok-4.7:high ' "$t/bstack-council-grok.md" || fail "council seat on its own family" + grep -q '^command: env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN BERCAIL_ROLE=builder pi --model xai/grok-4.7:high ' "$t/bstack-builder.md" || fail "builder: $(cat "$t/bstack-builder.md")" + grep -q '^command: env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN BERCAIL_ROLE=judge pi --model claude-bridge/claude-opus-5-5:high ' "$t/bstack-judge.md" || fail "judge" + grep -q '^command: env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN BERCAIL_ROLE=autopilot pi --model claude-bridge/claude-opus-5-5:high ' "$t/bstack-autopilot.md" || fail "autopilot" + grep -q '^command: env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN BERCAIL_ROLE=council pi --model xai/grok-4.7:high ' "$t/bstack-council-grok.md" || fail "council seat on its own family" grep -q -- "--append-system-prompt $p/agents/bstack-council-grok.md" "$t/bstack-council-grok.md" || fail "council brief" grep -q 'bstack-builder.md' "$t/bstack-builder.md" || fail "builder brief" - assert_eq '{"defaultModel":"mine"}' "$(cat "$HOME/.pi/agent/settings.json")" "settings.json untouched" + assert_eq 'mine' "$(jq -r .defaultModel "$HOME/.pi/agent/settings.json")" "unrelated settings preserved" + jq -e '.packages == ["npm:pi-claude-bridge@0.11.0"]' "$HOME/.pi/agent/settings.json" >/dev/null || fail "bridge installed" assert_eq '{"token":"secret"}' "$(cat "$HOME/.pi/agent/auth.json")" "auth.json untouched" [[ ! -e "$OPENCODE_CONFIG_DIR/opencode.jsonc" && ! -e "$OPENCODE_CONFIG_DIR/agents/bstack.md" ]] \ || fail "the Pi harness renders no opencode files" @@ -598,12 +639,12 @@ test_pi_deploy() { # A family that lost its login loses its seat. mkdir -p "$p/extensions/bercail-guard" printf 'old guard\n' >"$p/extensions/bercail-guard/index.ts" - FAKE_PI_READY="anthropic" deploy_bstack >/dev/null 2>&1 - [[ ! -d "$p/extensions" ]] || fail "update removes the old managed guard" + FAKE_PI_READY="claude-bridge" deploy_bstack >/dev/null 2>&1 + [[ ! -d "$p/extensions/bercail-guard" ]] || fail "update removes the old managed guard" [[ ! -e "$t/bstack-council-grok.md" && ! -e "$p/agents/bstack-council-grok.md" ]] || fail "stale council seat" - grep -q 'BERCAIL_ROLE=builder pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-builder.md" || fail "builder falls through" + grep -q 'BERCAIL_ROLE=builder pi --model claude-bridge/claude-opus-5-5:high ' "$t/bstack-builder.md" || fail "builder falls through" unset FAKE_PI_READY - printf 'PASS: harness = pi renders the profile and hc templates, and leaves ~/.pi alone\n' + printf 'PASS: harness = pi installs bridge and renders profile and hc templates, preserving unrelated settings\n' } test_pi_keeps_their_template() { @@ -611,7 +652,7 @@ test_pi_keeps_their_template() { write_pi_config mkdir -p "$(PI_TEMPLATES)" printf 'my own builder\n' >"$(PI_TEMPLATES)/bstack-builder.md" - FAKE_PI_READY="anthropic" deploy_bstack >"$TMP_DIR/pi.log" 2>&1 || fail "deploy" + FAKE_PI_READY="claude-bridge" deploy_bstack >"$TMP_DIR/pi.log" 2>&1 || fail "deploy" grep -qx 'my own builder' "$(PI_TEMPLATES)/bstack-builder.md" || fail "their template changed" grep -q 'keeping your' "$TMP_DIR/pi.log" || fail "says it kept theirs: $(cat "$TMP_DIR/pi.log")" printf 'PASS: Pi deploy leaves a template without the marker alone\n' @@ -620,7 +661,7 @@ test_pi_keeps_their_template() { test_pi_off_and_switch_back() { reset_home write_pi_config - export FAKE_PI_READY="anthropic" + export FAKE_PI_READY="claude-bridge" deploy_bstack >/dev/null 2>&1 mkdir -p "$(PI_TEMPLATES)" printf 'mine\n' >"$(PI_TEMPLATES)/reviewer.md" @@ -641,27 +682,27 @@ test_pi_off_and_switch_back() { test_pi_status_doctor() { reset_home write_pi_config - export FAKE_PI_READY="anthropic xai" + export FAKE_PI_READY="claude-bridge xai" deploy_bstack >/dev/null 2>&1 local out out="$(bstack_status)" grep -q 'harness: pi' <<<"$out" || fail "status names the harness: $out" - grep -q 'lead = anthropic/claude-opus-5-5:high' <<<"$out" || fail "lead: $out" + grep -q 'lead = claude-bridge/claude-opus-5-5:high' <<<"$out" || fail "lead: $out" grep -q 'build = xai/grok-4.7:high' <<<"$out" || fail "build: $out" - grep -q 'judge = anthropic/claude-opus-5-5:high' <<<"$out" || fail "judge: $out" + grep -q 'judge = claude-bridge/claude-opus-5-5:high' <<<"$out" || fail "judge: $out" grep -q 'council (grok) = xai/grok-4.7:high' <<<"$out" || fail "council: $out" grep -q 'gpt.*/login openai' <<<"$out" || fail "missing login hint: $out" grep -q 'opencode' <<<"$out" && fail "status on Pi does not talk about opencode: $out" # shellcheck source=lib/harness.sh source "$ROOT/lib/harness.sh" - assert_eq '["anthropic/claude-opus-5-5:high"]' "$(harness_pi_models_json)" "bercail harness hints the lead on Pi" + assert_eq '["claude-bridge/claude-opus-5-5:high"]' "$(harness_pi_models_json)" "bercail harness hints the lead on Pi" assert_eq '[]' "$(harness_opencode_models_json)" "and no Pi id for opencode" out="$(doctor_bstack)" || fail "doctor ok: $out" grep -q 'ok bstack on pi' <<<"$out" || fail "doctor: $out" - out="$(FAKE_PI_READY="anthropic xai openai" doctor_bstack)" && fail "doctor: a new login is a due update" + out="$(FAKE_PI_READY="claude-bridge xai openai" doctor_bstack)" && fail "doctor: a new login is a due update" grep -q 'stale bstack models' <<<"$out" || fail "doctor stale: $out" out="$(FAKE_PI_READY="" doctor_bstack)" && fail "doctor: no login" - grep -q 'none of anthropic, openai, xai' <<<"$out" || fail "doctor no login: $out" + grep -q 'none of Claude Code, openai, xai' <<<"$out" || fail "doctor no login: $out" rm -f "$(PI_DIR)/instructions.md" out="$(doctor_bstack)" && fail "doctor: a missing file" grep -q 'missing bstack .*instructions.md' <<<"$out" || fail "doctor missing: $out" @@ -670,7 +711,7 @@ test_pi_status_doctor() { out="$(doctor_bstack)" && fail "doctor: a pin Pi cannot run" grep -q 'claude-agent/opus#high is not in pi --list-models' <<<"$out" || fail "doctor pin: $out" grep -q 'not in pi --list-models' <<<"$(bstack_status)" || fail "status flags the pin" - _bstack_toml_set bstack claude '"anthropic/claude-opus-5-5:high"' + _bstack_toml_set bstack claude '"claude-bridge/claude-opus-5-5:high"' deploy_bstack >/dev/null 2>&1 out="$(doctor_bstack)" || fail "doctor: a pin in Pi's own :level form is known: $out" grep -q 'not in pi --list-models' <<<"$(bstack_status)" && fail "status knows a :level pin" @@ -681,7 +722,7 @@ test_pi_status_doctor() { test_pi_setup() { reset_home write_pi_config - FAKE_PI_READY="anthropic" bstack_setup >"$TMP_DIR/setup.log" 2>&1 || fail "setup: $(cat "$TMP_DIR/setup.log")" + FAKE_PI_READY="claude-bridge" bstack_setup >"$TMP_DIR/setup.log" 2>&1 || fail "setup: $(cat "$TMP_DIR/setup.log")" [[ -f "$(PI_DIR)/instructions.md" ]] || fail "setup deploys" grep -q '/login xai' "$TMP_DIR/setup.log" || fail "setup hints: $(cat "$TMP_DIR/setup.log")" assert_eq "" "$(_bstack_toml_get agent command)" "setup leaves the desk agent to the desk issue" @@ -692,10 +733,108 @@ harness = "pi" [agent] command = "cursor-agent" EOF - FAKE_PI_READY="anthropic" bstack_on >/dev/null 2>&1 + FAKE_PI_READY="claude-bridge" bstack_on >/dev/null 2>&1 assert_eq "cursor-agent" "$(_bstack_toml_get agent command)" "on with harness pi does not move the desk to opencode" printf 'PASS: setup on Pi deploys and prints the logins to run\n' } +test_pi_launch_contract() { + reset_home + write_pi_config + export FAKE_PI_READY="claude-bridge openai xai" + deploy_bstack >/dev/null 2>&1 + export ANTHROPIC_API_KEY=secret ANTHROPIC_BASE_URL=https://wrong ANTHROPIC_AUTH_TOKEN=secret + export FAKE_PI_LAUNCH_LOG="$TMP_DIR/pi-launch" + local template cmd role + for template in "$(PI_TEMPLATES)"/bstack-*.md; do + cmd="$(awk '/^command: / { sub(/^command: /, ""); print }' "$template")" + eval "$cmd" + grep -qxF "$(PI_DIR)/extensions/fallback.ts" "$FAKE_PI_LAUNCH_LOG" || fail "fallback in $template" + grep -qxF 'npm:pi-claude-bridge@0.11.0' "$FAKE_PI_LAUNCH_LOG" || fail "bridge in $template" + grep -q '^ANTHROPIC_' "$FAKE_PI_LAUNCH_LOG.env" && fail "Anthropic env leaked from $template" + done + cmd="$(bstack_pi_command '' "$(bstack_role_model lead)")" + eval "$cmd" + grep -qxF "$(PI_DIR)/extensions/fallback.ts" "$FAKE_PI_LAUNCH_LOG" || fail "lead fallback" + grep -qxF 'npm:pi-claude-bridge@0.11.0' "$FAKE_PI_LAUNCH_LOG" || fail "lead bridge" + grep -q '^ANTHROPIC_' "$FAKE_PI_LAUNCH_LOG.env" && fail "Anthropic env leaked from lead" + _bstack_pi_claude_ready || fail "Claude auth probe also sanitizes env" + unset ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN FAKE_PI_LAUNCH_LOG FAKE_PI_READY + printf 'PASS: every Pi role including GPT/Grok council seats and empty-role lead loads both extensions and unsets Anthropic env\n' +} + +test_pi_fallback_config() { + reset_home + write_pi_config + export FAKE_PI_READY="claude-bridge openai xai" + _bstack_toml_set bstack fallback '["gpt", "grok"]' + deploy_bstack >/dev/null 2>&1 + assert_eq "$(printf 'gpt\ngrok')" "$(<"$(PI_DIR)/fallback")" "configured fallback" + grep -q 'fallback: gpt grok' <<<"$(bstack_status)" || fail "status fallback order" + grep -qx 'claude=claude-bridge/claude-opus-5-5#high' "$(PI_DIR)/models" || fail "models format unchanged" + assert_eq 'claude-bridge/claude-opus-5-5#high' "$(bstack_role_model lead)" "runtime order does not change initial role" + _bstack_toml_set bstack fallback '["grok", "gpt"]' + local out raw + out="$(doctor_bstack)" && fail "doctor stale fallback" + grep -q 'stale bstack fallback' <<<"$out" || fail "doctor says stale fallback: $out" + deploy_bstack >/dev/null 2>&1 + doctor_bstack >/dev/null || fail "doctor after fallback redeploy" + for raw in '["gpt", "gpt"]' '["openai"]' '[1]' '"gpt"' '["gpt",]' ''; do + [[ -n "$raw" ]] || raw='["claude", "grok", "grok"]' + _bstack_toml_set bstack fallback "$raw" + bstack_pi_fallback >/dev/null 2>&1 && fail "invalid fallback accepted: $raw" + deploy_bstack >/dev/null 2>&1 && fail "deploy accepted invalid fallback: $raw" + out="$(doctor_bstack 2>&1)" && fail "doctor accepted invalid fallback: $raw" + grep -q 'invalid bstack fallback' <<<"$out" || fail "doctor invalid fallback: $out" + done + _bstack_toml_set bstack fallback '[]' + deploy_bstack >/dev/null 2>&1 + [[ ! -s "$(PI_DIR)/fallback" ]] || fail "empty fallback disables candidates" + rm "$(PI_DIR)/fallback" "$(PI_DIR)/models" + out="$(doctor_bstack)" && fail "doctor missing data files" + grep -q 'missing bstack .*fallback' <<<"$out" || fail "missing fallback: $out" + grep -q 'missing bstack .*models' <<<"$out" || fail "missing models: $out" + unset FAKE_PI_READY + printf 'PASS: Pi fallback order validates families and duplicates, deploys separately, and doctor catches missing/stale config\n' +} + +test_pi_bridge_doctor() { + reset_home + write_pi_config + export FAKE_PI_READY="claude-bridge openai" + local out + out="$(bstack_status)" + grep -q 'bridge: missing' <<<"$out" || fail "status missing bridge: $out" + [[ ! -e "$HOME/.pi/bridge-installed" ]] || fail "status installed bridge" + FAKE_PI_INSTALL_FAIL=1 deploy_bstack >/dev/null 2>&1 && fail "deploy must fail without required bridge" + deploy_bstack >/dev/null 2>&1 + assert_eq 1 "$(wc -l <"$HOME/.pi/install-log")" "bridge installed once" + deploy_bstack >/dev/null 2>&1 + assert_eq 1 "$(wc -l <"$HOME/.pi/install-log")" "bridge install idempotent" + out="$(bstack_status)" + grep -q 'bridge: present' <<<"$out" || fail "status bridge: $out" + grep -q 'Claude auth: ready' <<<"$out" || fail "status Claude auth: $out" + out="$(FAKE_PI_BRIDGE_MISSING=1 doctor_bstack)" && fail "doctor missing actual bridge despite declaration" + grep -q 'missing bridge npm:pi-claude-bridge@0.11.0' <<<"$out" || fail "doctor bridge: $out" + out="$(FAKE_PI_BRIDGE_MISSING=1 bstack_status)" + grep -q 'bridge: missing' <<<"$out" || fail "status broken bridge: $out" + out="$(FAKE_PI_READY='openai' doctor_bstack)" || true + grep -q 'Claude auth: no subscription login (claude auth login)' <<<"$out" || fail "doctor actual Claude auth, not bridge dummy key: $out" + local method + for method in api_key api_key_helper third_party; do + FAKE_CLAUDE_AUTH_METHOD="$method" _bstack_pi_claude_ready && fail "$method is not a Claude plan" + out="$(FAKE_CLAUDE_AUTH_METHOD="$method" bstack_status)" + grep -q 'Claude auth: no subscription login' <<<"$out" || fail "status rejects $method: $out" + done + FAKE_CLAUDE_AUTH_METHOD=oauth_token _bstack_pi_claude_ready || fail "oauth_token is a Claude plan" + grep -q 'Claude auth: ready' <<<"$(FAKE_CLAUDE_AUTH_METHOD=oauth_token bstack_status)" || fail "status accepts oauth_token" + unset FAKE_CLAUDE_AUTH_METHOD + [[ "$(FAKE_PI_READY=anthropic bstack_connected_ids)" != *claude-bridge* ]] || fail "Pi anthropic login must not imply Claude Code login" + out="$(FAKE_PI_VERSION=1.1.9 doctor_bstack)" && fail "doctor old Pi" + grep -q 'bstack needs >=1.2.0' <<<"$out" || fail "doctor old version: $out" + FAKE_PI_VERSION=1.1.9 deploy_bstack >/dev/null 2>&1 && fail "deploy old Pi" + unset FAKE_PI_READY + printf 'PASS: Pi bridge install is required and idempotent; doctor verifies actual bridge, Claude Code auth and minimum Pi version\n' +} test_harness_defaults_to_opencode() { reset_home @@ -736,3 +875,6 @@ test_pi_keeps_their_template test_pi_off_and_switch_back test_pi_status_doctor test_pi_setup +test_pi_launch_contract +test_pi_fallback_config +test_pi_bridge_doctor diff --git a/tests/pi_fallback_provider.ts b/tests/pi_fallback_provider.ts new file mode 100644 index 0000000..5e934a1 --- /dev/null +++ b/tests/pi_fallback_provider.ts @@ -0,0 +1,82 @@ +// Loaded by the installed Pi binary: no provider SDK mocks and no network calls. +import { appendFileSync } from "node:fs"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const scenario = process.env.PI_FALLBACK_CASE!; +const log = (data: unknown) => appendFileSync(process.env.PI_FALLBACK_LOG!, JSON.stringify(data) + "\n"); +export default function (pi: ExtensionAPI) { + let calls = 0; + let toolSent = false; + let userTurns = 0; + pi.on("before_agent_start", () => { userTurns++; }); + pi.registerTool({ + name: "fallback_test_tool", label: "Fallback test", description: "Offline test tool", + parameters: { type: "object", properties: {} } as any, + async execute() { + log({ type: "tool" }); + if (scenario === "user-reset" || scenario === "followup-reset") { + pi.sendUserMessage("Second real user turn", { deliverAs: scenario === "user-reset" ? "steer" : "followUp" }); + } + return { content: [{ type: "text", text: "offline tool result" }], details: {} }; + }, + }); + for (const provider of ["fake-a", "fake-b", "fake-c", "fake-unlogged", "claude-bridge"]) { + pi.registerProvider(provider, { + api: "pi-fallback-test", baseUrl: "http://invalid.invalid", + apiKey: provider === "fake-unlogged" ? "$PI_FALLBACK_MISSING_KEY" : "offline-test-key", + models: [{ id: "model", name: provider, reasoning: true, input: ["text"], contextWindow: 200000, maxTokens: 1000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + streamSimple(model, context, options) { + const stream = createAssistantMessageEventStream(); + queueMicrotask(async () => { + calls++; + const first = model.provider === "fake-a"; + let error = first ? "429 rate_limit_error" : undefined; + let status = first ? 429 : 200; + let headers: Record = {}; + if (scenario === "exhaustion") error = "429 rate_limit_error"; + if (first && scenario === "401") { error = "401 authentication failed; rate_limit_error"; status = 401; } + if (first && scenario.startsWith("status-only-")) { error = "invalid rate_limit configuration"; status = Number(scenario.slice(12)); headers = { "anthropic-ratelimit-unified-status": "rejected" }; } + if (first && scenario === "500") { error = "500 unrelated server failure"; status = 500; } + if (first && scenario === "403") { error = "403 forbidden"; status = 403; } + if (first && scenario === "400") { error = "400 invalid request"; status = 400; } + if (first && scenario === "context") { error = "context overflow: usage limit"; status = 200; } + if (first && scenario === "usage") { error = "You have hit your usage limit"; status = 200; } + if (first && scenario === "bridge") { error = "Claude Code turn failed"; status = 200; headers = { "anthropic-ratelimit-unified-status": "rejected" }; } + if (first && scenario === "bridge-auth") { error = "401 authentication failed"; status = 200; headers = { "anthropic-ratelimit-unified-status": "rejected" }; } + if (first && scenario === "bridge-success") { error = undefined; status = 200; headers = { "anthropic-ratelimit-unified-status": "rejected" }; } + if (first && scenario === "bridge-warning") { error = "unrelated failure"; status = 200; headers = { "anthropic-ratelimit-unified-status": "allowed_warning", "anthropic-ratelimit-unified-5h-utilization": "1" }; } + if (first && scenario === "retry-success" && calls > 1) { error = undefined; status = 200; } + if (first && scenario === "stale") { error = calls === 1 ? "429 rate_limit_error" : "unrelated failure"; status = calls === 1 ? 429 : 200; } + const toolScenario = ["tool-no-loop", "user-reset", "followup-reset"].includes(scenario); + const emitTool = toolScenario && model.provider === "fake-b" && !toolSent; + if (toolScenario && model.provider === "fake-b") { error = emitTool ? undefined : "429 rate_limit_error"; } + if (["user-reset", "followup-reset"].includes(scenario) && first && toolSent) error = undefined; + if (scenario === "new-user-reset") { + if (userTurns === 1 && !first) error = undefined; + if (userTurns === 2) error = first ? undefined : "429 rate_limit_error"; + } + log({ type: "request", provider: model.provider, calls, reasoning: options?.reasoning, at: Date.now(), messages: context.messages }); + // The stale case deliberately omits the next response hook entirely. + if (!(scenario === "stale" && calls > 1)) await options?.onResponse?.({ status, headers }, model); + const output: any = { role: "assistant", content: error ? [] : [{ type: "text", text: "PI_FALLBACK_OK" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: error ? "error" : "stop", timestamp: Date.now(), ...(error ? { errorMessage: error } : {}) }; + if (emitTool) { + toolSent = true; + output.stopReason = "toolUse"; + output.content = [{ type: "toolCall", id: "offline-call", name: "fallback_test_tool", arguments: {} }]; + } + if (scenario === "abort" && first) output.stopReason = "aborted"; + stream.push(error ? { type: "error", reason: output.stopReason, error: output } : { type: "done", reason: output.stopReason, message: output }); + stream.end(); + }); + return stream; + }, + }); + } + for (const name of ["message_start", "before_agent_start", "agent_start", "turn_start", "turn_end", "agent_before_settle", "agent_settled", "model_select"] as const) { + pi.on(name as any, (event: any, ctx: any) => { + log({ type: "event", event: name, outcome: event.outcome, role: event.message?.role, continue: event.continue, model: ctx.model?.provider, aborted: event.aborted }); + if (name === "agent_settled" && process.env.PI_FALLBACK_TUI_SHUTDOWN) ctx.shutdown(); + }); + } +} diff --git a/tests/pi_fallback_test.mjs b/tests/pi_fallback_test.mjs new file mode 100644 index 0000000..7ce33d6 --- /dev/null +++ b/tests/pi_fallback_test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const pi = process.env.PI_FALLBACK_BIN || "pi"; +const version = spawnSync(pi, ["--version"], { encoding: "utf8" }).stdout.trim(); +const [major, minor] = version.split(".").map(Number); +assert.ok(major > 1 || (major === 1 && minor >= 5), `tests require real Pi >=1.5 (found ${version})`); +const tmp = mkdtempSync(join(tmpdir(), "pi-fallback-")); +try { + for (const scenario of ["429", "401", "403", "400", "500", "context", "usage", "bridge", "bridge-auth", "bridge-success", "bridge-warning", "retry-success", "stale", "abort", "missing-auth", "bridge-missing-auth", "bridge-metered", "bridge-subscription", "bridge-oauth-token", "exhaustion", "configured-order", "tool-no-loop", "user-reset", "followup-reset", "new-user-reset", "empty-order", "default-order", "tui-status", "status-only-400", "status-only-401", "status-only-403"]) { + const dir = join(tmp, scenario); + mkdirSync(join(dir, "profile/extensions"), { recursive: true }); + mkdirSync(join(dir, "agent"), { recursive: true }); + mkdirSync(join(dir, "bin")); + copyFileSync(join(root, "bstack/pi/extensions/fallback.ts"), join(dir, "profile/extensions/fallback.ts")); + writeFileSync(join(dir, "profile/models"), `claude=${["tool-no-loop", "user-reset", "followup-reset", "new-user-reset"].includes(scenario) ? "fake-a" : "fake-c"}/model\ngpt=${scenario === "missing-auth" ? "fake-unlogged" : scenario.startsWith("bridge-") && ["bridge-missing-auth", "bridge-metered", "bridge-subscription", "bridge-oauth-token"].includes(scenario) ? "claude-bridge" : "fake-b"}/model#high\ngrok=fake-c/model:medium\n`); + // Most cases limit candidates to GPT/Grok; default-order is covered separately below. + if (scenario !== "default-order") writeFileSync(join(dir, "profile/fallback"), scenario === "empty-order" ? "" : ["tool-no-loop", "user-reset", "followup-reset", "new-user-reset"].includes(scenario) ? "claude\ngpt\ngrok\n" : "gpt\ngrok\n"); + writeFileSync(join(dir, "agent/settings.json"), JSON.stringify({ retry: { enabled: true, maxRetries: 2, baseDelayMs: 5 }, compaction: { enabled: false } })); + const auth = scenario === "bridge-metered" ? { loggedIn: true, authMethod: "api_key_helper" } : scenario === "bridge-subscription" ? { loggedIn: true, authMethod: "claude.ai" } : scenario === "bridge-oauth-token" ? { loggedIn: true, authMethod: "oauth_token" } : { loggedIn: false }; + writeFileSync(join(dir, "bin/claude"), `#!/bin/sh\nprintf '%s\\n' '${JSON.stringify(auth)}'\n`, { mode: 0o755 }); + const env = { ...process.env, HOME: dir, PI_CODING_AGENT_DIR: join(dir, "agent"), PI_FALLBACK_CASE: scenario, PI_FALLBACK_LOG: join(dir, "events.jsonl"), PI_OFFLINE: "1", PATH: `${dir}/bin:${process.env.PATH}` }; + delete env.HC_TERMINAL_ID; + delete env.PI_FALLBACK_MISSING_KEY; + const args = ["--offline", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-builtin-tools", "--no-mcp", "--mode", "json", "--session", join(dir, "session.jsonl"), "-e", join(dir, "profile/extensions/fallback.ts"), "-e", resolve(root, "tests/pi_fallback_provider.ts"), "--model", "fake-a/model", "-p", "Finish the original request.", ...(scenario === "new-user-reset" ? ["Second real user turn"] : [])]; + let command = pi; + if (scenario === "tui-status") { + command = "script"; + env.HC_TERMINAL_ID = "t-offline-test"; + env.PI_PROGRAM_STATUS = "1"; + env.PI_FALLBACK_TUI_SHUTDOWN = "1"; + args.splice(args.indexOf("--mode"), 2); + args.splice(args.indexOf("-p"), 1); + const quote = s => `'${s.replaceAll("'", "'\\''")}'`; + const shellCommand = [pi, ...args].map(quote).join(" "); + args.splice(0, args.length, "-qefc", shellCommand, "/dev/null"); + } + const result = spawnSync(command, args, { env, cwd: dir, encoding: "utf8", timeout: 30000, maxBuffer: 8 * 1024 * 1024 }); + writeFileSync(join(dir, "stdout.jsonl"), result.stdout || ""); + writeFileSync(join(dir, "stderr.log"), result.stderr || ""); + assert.equal(result.error, undefined, `${scenario}: ${result.error}`); + assert.doesNotMatch(result.stderr, /Failed to load extension|extension.*error|without runnable model context/i, `${scenario}: ${result.stderr}`); + const events = readFileSync(join(dir, "events.jsonl"), "utf8").trim().split("\n").map(JSON.parse); + const requests = events.filter(e => e.type === "request"); + const switches = events.filter(e => e.event === "model_select"); + const session = readFileSync(join(dir, "session.jsonl"), "utf8").trim().split("\n").map(JSON.parse); + const noSwitch = ["401", "403", "400", "500", "context", "bridge-auth", "bridge-success", "bridge-warning", "retry-success", "stale", "abort", "empty-order", "status-only-400", "status-only-401", "status-only-403"].includes(scenario); + if (noSwitch) assert.equal(switches.length, 0, `${scenario}: must not switch`); + else { + assert.ok(switches.length > 0, `${scenario}: must switch; ${result.stderr}`); + const target = ["missing-auth", "bridge-missing-auth", "bridge-metered", "default-order"].includes(scenario) ? "fake-c" : ["bridge-subscription", "bridge-oauth-token"].includes(scenario) ? "claude-bridge" : "fake-b"; + assert.equal(switches[0].model, target, scenario); + if (scenario !== "default-order") assert.equal(requests.find(e => e.provider === target)?.reasoning, target === "fake-c" ? "medium" : "high", `${scenario}: configured #effort/:effort`); + assert.ok(session.some(e => e.type === "model_change" && e.provider === target), `${scenario}: model persisted`); + assert.ok(session.some(e => e.type === "custom_message" && e.customType === "bercail-fallback" && e.display), `${scenario}: visible session note`); + if (scenario !== "exhaustion") assert.match(result.stdout, /PI_FALLBACK_OK/, `${scenario}: turn finishes`); + } + if (["429", "stale", "retry-success", "exhaustion"].includes(scenario)) { + assert.equal(requests.filter(e => e.provider === "fake-a").length, scenario === "retry-success" ? 2 : scenario === "stale" ? 2 : 3, `${scenario}: native retry budget`); + } + if (scenario === "tui-status") { + const records = [...result.stdout.matchAll(/\x1b\]7501;([^\x07\x1b]+)(?:\x07|\x1b\\)/g)].map(m => m[1]); + assert.equal(records.filter(r => r.includes("id=bercail-fallback")).length, 0, "Pi owns program status"); + const done = records.find(r => r.startsWith("state=done:app=pi:msg=")); + assert.ok(done, `Pi done status missing: ${records.join(" | ")}`); + assert.match(Buffer.from(done.split("msg=")[1], "base64").toString(), /fallback: fake-b\/model/, "session name carries the switch"); + assert.match(result.stdout, /fallback: fake-b\/model/, "real Pi footer status"); + } + if (scenario === "429") { + const firstSwitch = events.findIndex(e => e.event === "model_select"); + assert.equal(events.slice(0, firstSwitch).filter(e => e.type === "request").length, 3, "switch only after retry exhaustion"); + assert.ok(requests[2].at - requests[0].at >= 10, "Pi retry backoff precedes fallback"); + const next = requests.find(e => e.provider === "fake-b"); + assert.ok(next.messages.some(m => m.role === "user" && JSON.stringify(m.content).includes("Finish the original request")), "original request preserved"); + assert.equal(next.messages.some(m => m.role === "assistant" && m.stopReason === "error"), false, "failed assistant omitted"); + assert.equal(events.filter(e => e.event === "before_agent_start").length, 1, "same user run"); + assert.equal(events.filter(e => e.event === "agent_settled").length, 1, "settles only after continuation"); + } + if (scenario === "exhaustion") { + assert.equal(requests.length, 9, "each configured provider attempted once with native retries"); + assert.ok(session.some(e => e.type === "custom" && e.customType === "bercail-fallback-error" && /fallback exhausted/.test(e.data?.text ?? "")), "clear persisted exhaustion error"); + assert.equal(session.some(e => e.type === "custom_message" && e.customType === "bercail-fallback-error"), false, "exhaustion is not a later user turn"); + } + if (scenario === "empty-order") assert.ok(session.some(e => e.type === "custom" && e.customType === "bercail-fallback-error" && /no candidates/.test(e.data?.text ?? "")), "empty order stops clearly"); + if (scenario === "tool-no-loop") { + assert.deepEqual(switches.map(e => e.model), ["fake-b", "fake-c"], "fallback attempts survive tool turns"); + assert.equal(events.filter(e => e.type === "tool").length, 1, "real tool executed"); + } + if (["user-reset", "followup-reset", "new-user-reset"].includes(scenario)) { + assert.deepEqual(switches.map(e => e.model), ["fake-b", "fake-a"], "new user message resets attempted models"); + assert.equal(events.filter(e => e.event === "message_start" && e.role === "user").length, 2, "two real user messages"); + assert.equal(events.filter(e => e.event === "before_agent_start").length, scenario === "new-user-reset" ? 2 : 1, "queued messages reset without before_agent_start"); + } + console.log(`PASS: real Pi ${scenario} (${requests.length} requests, ${switches.length} switches)`); + } +} catch (error) { + console.error(`Artifacts: ${tmp}`); + throw error; +} +rmSync(tmp, { recursive: true, force: true }); diff --git a/tests/pi_fallback_test.sh b/tests/pi_fallback_test.sh new file mode 100755 index 0000000..d3ba5cc --- /dev/null +++ b/tests/pi_fallback_test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Real Pi with offline providers: no subscriptions or API requests. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v node >/dev/null 2>&1; then + printf 'SKIP: node not found; Pi fallback proof not run\n' + exit 0 +fi +pi="${PI_FALLBACK_BIN:-}" +# Resolve mise before the test isolates HOME; its shim may otherwise reinstall. +if [[ -z "$pi" ]] && command -v mise >/dev/null 2>&1; then + pi="$(mise which pi 2>/dev/null || true)" +fi +if [[ -z "$pi" || ! -x "$pi" || "$pi" == */mise/shims/* ]]; then + pi="$(command -v pi 2>/dev/null || true)" + [[ -n "$pi" && "$pi" != */mise/shims/* && -x "$pi" ]] || pi="" +fi +if [[ -z "$pi" ]]; then + printf 'SKIP: Pi not found; fallback live proof not run\n' + exit 0 +fi +version="$("$pi" --version)" +if ! node -e 'const [a,b] = process.argv[1].split(".").map(Number); process.exit(a > 1 || (a === 1 && b >= 5) ? 0 : 1)' "$version"; then + printf 'SKIP: Pi %s is older than 1.5; fallback live proof not run\n' "$version" + exit 0 +fi +PI_FALLBACK_BIN="$pi" node "$ROOT/tests/pi_fallback_test.mjs"