Files
claude_mac/lib/design-tool-gate.sh
T
bastien bd3e525bb3 feat(design-gate): ask for 21st login and wait instead of skipping
The design gate checked the 21st CLI with command -v only, so an
installed-but-signed-out CLI read as READY and every 21st step failed
downstream. tool_active now probes 21st whoami through a three-state
function: signed in (TWENTYFIRST_TOKEN or API_KEY_21ST set, or 'Logged in
as'), signed out (exact 'Not logged in'), unknown (rc != 0, timeout,
unexpected output). Signed out is a new verdict, SIGN-IN REQUIRED, exit
12: design-gate.md tells the orchestrator to ask the user to run
! 21st login, end the turn, re-run the gate on their reply, and to skip
21st only on an explicit 'proceed without 21st', never silently. Unknown
surfaces as exit 11 with the whoami diagnostic and a CLI-runtime remedy,
so a node/PATH failure can never loop on a sign-in prompt. INCOMPLETE
still wins. Hermetic suite lib/tests/design-tool-gate.test.sh (stub CLI,
fixture repo through DESIGN_GATE_REPO_OVERRIDE) covers every state.
2026-09-28 12:51:55 +02:00

282 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================
# lib/design-tool-gate.sh — Deterministic design-toolchain state check.
#
# Answers ONE question for the design gate (design-gate.md §DECISION):
# "Is the design toolchain active enough to proceed?"
#
# Source of truth = the profile system. The gate never activates a single
# tool atomically; it checks whether a profile's DESIGN-CORE tools (default
# profile: `design`) are active and, if not, points at `/profile <name>`.
#
# Two inputs from the profile, both claude-free:
# - structure: profile.sh show <profile> --plain -> "<type>\t<name>"
# - gate scope: the "# GATE-BLOCK:" line(s) in <profile>.profile — the
# allowlist of tools the gate trips on. A comment, so
# read_profile strips it and --plain never shows it. Absent
# -> fall back to every skill/plugin/mcp entry (coarse).
#
# State (active or not) is checked per channel, by type. These per-type
# checks MIRROR profile.sh:skill_status() — change one, sync the other,
# except the 21st auth state: gate-only, no skill_status counterpart.
#
# type channel class
# gstack|external|personal skill symlink in skills/ blocking
# plugin `claude plugin list` -> enabled blocking
# mcp | cli `claude mcp list` / command -v required-manual
#
# Class:
# blocking required + `/profile design` activates it directly.
# required-manual required but the profile can't flip it silently (API
# key / external install) — the gate STILL trips, names
# it, and the remedy is `/profile design` + a manual step.
# This is where the `21st` CLI lands: required, never
# silent (npm i -g @21st-dev/cli, then 21st login).
# 21st's sign-in state is three-valued: in (active),
# out (exit 12, ask to sign in), unknown (exit 11).
# Both classes trip the gate. Tools NOT on the GATE-BLOCK allowlist are
# ignored entirely (browser/plan/shotgun tooling, graphify).
#
# disabledMcpServers is NEVER read — unreliable for bi-modal servers
# (context7 can appear there yet be active via another channel).
#
# Exit: 0 = ready · 11 = ready-but-unverified (proceed, say so) ·
# 10 = incomplete (trips) · 12 = sign-in required (21st) · 2 = error.
# Usage: design-tool-gate.sh [profile] (default profile: design)
# ============================================================
set -euo pipefail
REPO="${DESIGN_GATE_REPO_OVERRIDE:-$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PROFILE_SH="${DESIGN_GATE_PROFILE_SH:-$REPO/lib/profile.sh}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
PROFILES_DIR="$REPO/lib/profiles"
SKILLS_DIR="$REPO/skills"
PROFILE="${1:-design}"
PROFILE_FILE="$PROFILES_DIR/$PROFILE.profile"
[ -x "$PROFILE_SH" ] || { echo "design-gate: profile.sh not executable at $PROFILE_SH" >&2; exit 2; }
[ -f "$PROFILE_FILE" ] || { echo "design-gate: profile '$PROFILE' not found" >&2; exit 2; }
# Ensure the claude CLI + its node runtime are reachable even when a skill/hook
# shells this script out with a sanitized PATH. The interactive alias
# claude->dtach_claude never reaches a non-interactive subshell; the real binary
# AND its node bin dir are what matter (claude's shebang needs node, same dir).
# If `command -v claude` already resolves, do nothing; else probe known install
# dirs and prepend. nvm keeps old node versions after an upgrade, so pick the
# newest that actually ships claude (sort -V), not the first glob match.
ensure_claude_on_path() {
command -v "$CLAUDE_BIN" >/dev/null 2>&1 && return
local cand
for cand in \
"$HOME/.claude/local/claude" \
"$HOME/.local/bin/claude" \
/usr/local/bin/claude; do
[ -x "$cand" ] && { PATH="$(dirname "$cand"):$PATH"; return; }
done
local m newest matches=()
for m in "$HOME"/.nvm/versions/node/*/bin/claude; do
[ -x "$m" ] && matches+=("$m")
done
if [ "${#matches[@]}" -gt 0 ]; then
newest="$(printf '%s\n' "${matches[@]}" | sort -V | tail -1)"
PATH="$(dirname "$newest"):$PATH"
fi
}
ensure_claude_on_path
# Same sanitized-PATH problem for `21st` (an npm global bin), with a twist:
# the repair above only fires when claude ITSELF is unresolvable, and claude
# often lives in ~/.local/bin while the npm global bin dir is missing from a
# hook's PATH. Probe for the binary directly and prepend the dir that has it,
# otherwise a perfectly installed CLI reads as "missing" and trips the gate.
ensure_21st_on_path() {
command -v 21st >/dev/null 2>&1 && return
local cand
for cand in \
"$HOME/.local/bin/21st" \
/usr/local/bin/21st; do
[ -x "$cand" ] && { PATH="$(dirname "$cand"):$PATH"; return; }
done
local m newest matches=()
for m in "$HOME"/.nvm/versions/node/*/bin/21st; do
[ -x "$m" ] && matches+=("$m")
done
if [ "${#matches[@]}" -gt 0 ]; then
newest="$(printf '%s\n' "${matches[@]}" | sort -V | tail -1)"
PATH="$(dirname "$newest"):$PATH"
fi
}
ensure_21st_on_path
# 21st's sign-in state, three-valued. `whoami` is a local token read (no
# network), rc 0 either way — so rc alone can't tell signed-in from signed-
# out; the FIRST LINE of stdout does. A token env, when already exported by
# the user's shell profile, wins without a CLI call (never requested here:
# tool calls don't share a shell, and a secret doesn't belong in a comment
# or the transcript). `timeout 15` bounds a hung CLI; stdin is closed so a
# CLI that reads stdin can't eat the gate's own `read` loop; stderr never
# enters the match (stdout only). Echoes: in | out | unknown:<diagnostic>.
twentyfirst_auth_state() {
if [ -n "${TWENTYFIRST_TOKEN:-}" ] || [ -n "${API_KEY_21ST:-}" ]; then
echo in
return
fi
local line rc
if line="$(timeout 15 21st whoami 2>/dev/null </dev/null | head -1)"; then
rc=0
else
rc=$?
fi
if [ "$rc" -eq 0 ]; then
case "$line" in
"Logged in as "*) echo in; return ;;
"Not logged in"*) echo out; return ;;
esac
fi
[ -n "$line" ] || line="no output"
echo "unknown:whoami: rc=$rc ${line:0:60}"
}
# Gate scope: the "# GATE-BLOCK:" allowlist (one or more lines, concatenated).
# Empty => fall back to "every gate-relevant entry is in scope" (coarse).
core_set="$(grep '^# GATE-BLOCK:' "$PROFILE_FILE" 2>/dev/null \
| sed 's/^# GATE-BLOCK:[[:space:]]*//' | tr '\n' ' ' || true)"
# Membership in the allowlist. Empty allowlist = everything in scope.
in_scope() {
[ -z "$core_set" ] && return 0
case " $core_set " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}
# State of one tool, by type. Mirrors profile.sh:skill_status() — keep in
# sync, except the 21st auth state (gate-only, no skill_status counterpart).
# Echoes: active | inactive | unknown (can't verify, claude absent) |
# signedout | unknown:<diagnostic> (last two: 21st CLI only)
tool_active() {
local name="$1" type="$2"
case "$type" in
gstack|external|personal)
if [ -e "$SKILLS_DIR/$name" ]; then echo active; else echo inactive; fi
;;
plugin)
if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then echo unknown; return; fi
if "$CLAUDE_BIN" plugin list 2>/dev/null \
| awk -v p="^[[:space:]]*❯ ${name}@" '$0 ~ p {f=1; next} f && /Status:/ {print; exit}' \
| grep -q "✔ enabled"
then echo active; else echo inactive; fi
;;
mcp)
if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then echo unknown; return; fi
if "$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${name}"; then echo active; else echo inactive; fi
;;
cli)
command -v "$name" >/dev/null 2>&1 || { echo inactive; return; }
[ "$name" = "21st" ] || { echo active; return; }
local auth
auth="$(twentyfirst_auth_state)"
case "$auth" in
in) echo active ;;
out) echo signedout ;;
unknown:*) echo "$auth" ;;
esac
;;
*) echo inactive ;;
esac
}
# Structure via the parse contract (claude-free). Fail loud on a bad profile —
# an empty read must NOT silently report "ready".
plain="$("$PROFILE_SH" show "$PROFILE" --plain 2>/dev/null)" \
|| { echo "design-gate: 'profile.sh show $PROFILE --plain' failed" >&2; exit 2; }
[ -n "$plain" ] || { echo "design-gate: profile '$PROFILE' is empty or unreadable" >&2; exit 2; }
blocking=() # inactive, /profile design activates it (skill/plugin)
manual=() # inactive, required but needs a manual step (mcp key / cli install)
unverified=() # can't check (claude CLI absent)
signedout=() # 21st CLI installed, not signed in
unverified_cli=() # 21st CLI: whoami answered something unexpected
while IFS=$'\t' read -r type name; do
[ -n "$type" ] || continue
in_scope "$name" || continue # ignore non-core tooling (browser, plan-*, graphify)
state="$(tool_active "$name" "$type")"
case "$state" in
active) ;;
signedout) signedout+=("$name") ;;
unknown) unverified+=("$name") ;;
unknown:*) unverified_cli+=("$name (${state#unknown:})") ;;
*)
case "$type" in
gstack|external|personal|plugin) blocking+=("$name") ;;
*) manual+=("$name") ;;
esac
;;
esac
done <<< "$plain"
# print_unverified — the "also unverified" lines shared by the 10, 11 and 12
# blocks: a claude-unreachable tool keeps its existing remedy; a 21st CLI
# that answered whoami with something unexpected gets its own — the two are
# never merged, so no block blames 21st for a claude problem or vice versa.
print_unverified() {
if [ "${#unverified[@]}" -gt 0 ]; then
echo " also unverified (claude CLI unreachable): ${unverified[*]}"
fi
local entry name diag
for entry in "${unverified_cli[@]}"; do
name="${entry%% (*}"
diag="${entry#*\(}"; diag="${diag%\)}"
echo " $name could not answer: $diag —" \
"a CLI runtime/PATH problem (node under nvm?)," \
"not a sign-in problem; fix it, then re-run"
done
}
# Verdict — four outcomes, checked in order:
# blocking/manual non-empty -> INCOMPLETE (exit 10): the gate trips.
# else signedout non-empty -> SIGN-IN REQUIRED (exit 12): ask the user to
# run `21st login`, end the turn, wait, re-run — never a silent skip.
# else unverified/unverified_cli non-empty -> READY BUT UNVERIFIED (exit
# 11): fail-VISIBLE. claude unreachable and/or 21st couldn't answer
# whoami — never pass either as a silent READY.
# nothing pending -> READY (exit 0).
if [ "${#blocking[@]}" -gt 0 ] || [ "${#manual[@]}" -gt 0 ]; then
echo "design toolchain: INCOMPLETE"
if [ "${#blocking[@]}" -gt 0 ]; then
echo " activate with /profile $PROFILE: ${blocking[*]}"
fi
if [ "${#manual[@]}" -gt 0 ]; then
echo " required + manual step (external install / sign-in): ${manual[*]}"
case " ${manual[*]} " in
*" 21st "*) echo " 21st needs the CLI: npm i -g @21st-dev/cli then 21st login" ;;
esac
fi
print_unverified
echo " → run: /profile $PROFILE"
exit 10
fi
if [ "${#signedout[@]}" -gt 0 ]; then
echo "design toolchain: SIGN-IN REQUIRED — 21st CLI installed, not signed in"
echo " ask the user to run in this session:" \
" ! 21st login" \
" (browser flow, saves a local token)"
echo " then re-run this gate before any 21st step — never skip 21st silently"
print_unverified
exit 12
fi
if [ "${#unverified[@]}" -gt 0 ] || [ "${#unverified_cli[@]}" -gt 0 ]; then
echo "design toolchain: READY BUT UNVERIFIED —" \
"$(( ${#unverified[@]} + ${#unverified_cli[@]} )) tool(s) not checked"
print_unverified
if [ "${#unverified[@]}" -gt 0 ]; then
echo " the gate could NOT confirm the design plugin (ui-ux-pro-max) is"
echo " active. Proceed only after checking manually:"
echo " claude plugin list"
fi
exit 11
fi
echo "design toolchain: READY — profile '$PROFILE' design tools active"
exit 0