claude/lib/design-tool-gate.sh
Bastien Chanot 3eefb8ad7c feat(design-gate): profile-based toolchain gate + design-tool-gate.sh
design-tool-gate.sh: deterministic design-toolchain state check. Reads the
design-core tools from design.profile's `# GATE-BLOCK:` allowlist + their
types via `profile.sh show design --plain` (claude-free parse contract),
checks each on its own channel (skill symlink / claude plugin list / claude
mcp list / command -v). Never reads disabledMcpServers. Exit 0 ready · 10
incomplete · 2 error.

Remedy is always a profile (/profile design), never an atomic tool toggle —
the profile system stays the single source of truth for activation. magic is
required-but-manual: it TRIPS the gate (not advisory) and the output names
the MAGIC_API_KEY step. Non-design tools bundled in the profile (browse,
plan-*, design-shotgun, graphify) are excluded from the trip via GATE-BLOCK,
so the gate fires only on real design tools.

design-gate.md: §DECISION rewritten profile-based (tier → run script → branch
on 3 groups), replacing the old atomic "ask user to activate ui-ux-pro-max".
§DETECTION unchanged. design.profile: add the `# GATE-BLOCK:` allowlist
(8 design-core tools); it is a comment, so read_profile/--plain are unaffected.

Verified: shellcheck clean; magic-off (real design profile) → exit 10 +
API-key line; all active → exit 0; browse-off (non-GATE-BLOCK) → exit 0,
no trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:21:48 +02:00

142 lines
6.0 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.
#
# 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 magic lands: required, never silent.
# 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
# (magic/context7 can appear there yet be active via another channel).
#
# Exit: 0 = ready (proceed) · 10 = incomplete (gate trips) · 2 = error.
# Usage: design-tool-gate.sh [profile] (default profile: design)
# ============================================================
set -euo pipefail
REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROFILE_SH="$REPO/lib/profile.sh"
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; }
# 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.
# Echoes: active | inactive | unknown (unknown = can't verify, claude absent)
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 >/dev/null 2>&1; then echo unknown; return; fi
if claude 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 >/dev/null 2>&1; then echo unknown; return; fi
if claude mcp list 2>/dev/null | grep -q "^${name}"; then echo active; else echo inactive; fi
;;
cli)
if command -v "$name" >/dev/null 2>&1; then echo active; else echo inactive; fi
;;
*) 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)
while IFS=$'\t' read -r type name; do
[ -n "$type" ] || continue
in_scope "$name" || continue # ignore non-core tooling (browser, plan-*, graphify)
case "$(tool_active "$name" "$type")" in
active) ;;
unknown) unverified+=("$name") ;;
*)
case "$type" in
gstack|external|personal|plugin) blocking+=("$name") ;;
*) manual+=("$name") ;;
esac
;;
esac
done <<< "$plain"
# Verdict. Either class trips the gate.
trip=0
if [ "${#blocking[@]}" -gt 0 ] || [ "${#manual[@]}" -gt 0 ]; then trip=1; fi
if [ "$trip" -eq 0 ]; then
echo "design toolchain: READY — profile '$PROFILE' design tools active"
if [ "${#unverified[@]}" -gt 0 ]; then
echo " note: could not verify (claude CLI absent): ${unverified[*]}"
fi
exit 0
fi
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 (API key / external install): ${manual[*]}"
case " ${manual[*]} " in
*" magic "*) echo " magic needs MAGIC_API_KEY in .env (/profile $PROFILE runs toggle-external.sh)" ;;
esac
fi
if [ "${#unverified[@]}" -gt 0 ]; then
echo " unverified (claude CLI absent): ${unverified[*]}"
fi
echo " → run: /profile $PROFILE"
exit 10