#!/usr/bin/env bash # ============================================================ # lib/profile.sh — Partition Claude skills + plugins + MCPs by purpose # # Profiles group skills (gstack + external + personal), plugins, MCPs, # and CLIs for a specific kind of work: web, seo, web-full, backend, # design, dev, qa, audit, minimal. Apply a profile to enable just the # relevant tools and disable the rest, instead of carrying every gstack # skill + every plugin in every session. # # Mechanism: # - Skills (gstack/external/personal): symlink toggle skills/ ↔ skills-disabled/ # - Plugins: `claude plugin enable|disable @` # - MCPs: advisory (none managed since BDR-093 — MANAGED_MCPS is empty), # advisory otherwise # - CLIs: advisory only (rtk, gsd, ctx7, graphify — installed externally) # - `set` is SYMMETRIC on managed items (BDR-079): plugins, external packs # and MCPs in the MANAGED_* allowlists are disabled when the profile # does not list them — nothing outside those lists is ever auto-toggled. # # Always-on plugins (never toggled by `set`): security-guidance, # superpowers + rtk hook + .claude internal. The script refuses to disable # anything in PROTECTED_PLUGINS. # # Usage: # profile.sh list list available profiles # profile.sh show show contents of a profile (grouped by type) # profile.sh show --plain parsable type+name list (no status, no claude) # profile.sh current report the active profile (label + match) # profile.sh apply enable items in profile (additive) # profile.sh set enable only profile (disables rest) # profile.sh reset go to the default profile (full): enable its # list, park non-listed gstack/managed items # profile.sh gstack on|off toggle gstack, keeping active-profile label # profile.sh diff compare two profiles # # Profile file format (lib/profiles/.profile): # # DESC: # # type defaults to "gstack" # personal # personal skill (skills//SKILL.md is real) # external # symlinked into skills-external/ # plugin@ # Claude plugin — auto-toggle # mcp # MCP — advisory or via toggle-external # cli # standalone CLI — advisory only # # ============================================================ set -euo pipefail REPO="${PROFILE_REPO_OVERRIDE:-$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" SKILLS_DIR="$REPO/skills" DISABLED_DIR="$REPO/skills-disabled" GSTACK_SRC="$REPO/skills-external/gstack" # gstack submodule — source of truth for gstack skills PROFILES_DIR="$REPO/lib/profiles" ACTIVE_CACHE="$REPO/.active-profile" # statusline reads this — keep fast (single-line file, profile name only) DEFAULT_PROFILE="full" # profile in force when none is selected (cache absent, empty, or legacy "none") # Plugins that are toggle-managed by `set`. Anything NOT in this list is # never auto-disabled — protects always-on plugins (security-guidance, # superpowers) and unrelated user plugins. Add a plugin here only when its # enabled state is meaningfully driven by task type. MANAGED_PLUGINS=( "ui-ux-pro-max@ui-ux-pro-max-skill" "plugin-dev@claude-code-plugins" "pr-review-toolkit@claude-code-plugins" ) # External skill packs that are toggle-managed by `set` — same allowlist # doctrine as MANAGED_PLUGINS: listed here only when the enabled state is # task-type-driven. `set` disables these when the profile does not list # them; anything else external (e.g. darwin-skill) is never auto-touched. MANAGED_EXTERNALS=( emil-design-eng frontend-design design-motion-principles impeccable 21st-ui-build 21st-ui-explore 21st-ui-review 21st-cli-use 21st-ai ) # MCP servers that are toggle-managed by `set`, both ways (enable AND # disable), delegated to lib/toggle-external.sh. Same allowlist doctrine. # Empty: no MCP server is managed today (the 21st design skills are managed # as externals above). The `mcp` type itself stays supported — a profile can # still list an MCP, it is then advisory rather than auto-toggled. MANAGED_MCPS=() # Plugins that MUST stay enabled — `set` will refuse to disable these even if # they're not in the profile. (Defensive: belt-and-suspenders alongside # MANAGED_PLUGINS allowlist.) PROTECTED_PLUGINS=( "security-guidance@claude-code-plugins" "superpowers@superpowers-marketplace" ) GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; NC='\033[0m' ok() { echo -e "${GREEN}✓${NC} $1"; } warn() { echo -e "${YELLOW}⚠${NC} $1"; } err() { echo -e "${RED}✗${NC} $1" >&2; } info() { echo -e "${BLUE}ℹ${NC} $1"; } # Persist active-profile name for fast statusline lookup (cmd_current is slow # — iterates every profile + every entry). Write profile name only; statusline # reads the file directly without re-invoking this script. write_active() { local name="$1" printf '%s\n' "$name" > "$ACTIVE_CACHE" 2>/dev/null || true } # First line of the cache, all whitespace stripped (tr -d, CRLF-proof). # Empty string when the cache is missing or empty. Never trips # `set -euo pipefail` (head's failure on a missing file is absorbed). read_cache() { head -n1 "$ACTIVE_CACHE" 2>/dev/null | tr -d '[:space:]' || true } # The profile actually in force: the cached label, or DEFAULT_PROFILE when # the cache is absent, empty, or the legacy "none" sentinel. The ONE place # that resolves "no profile selected" — every reader of the cache goes # through this (or read_cache() when it needs the raw value). active_profile() { local cached; cached="$(read_cache)" if [ -z "$cached" ] || [ "$cached" = "none" ]; then printf '%s' "$DEFAULT_PROFILE" else printf '%s' "$cached" fi } # ── Profile parsing ──────────────────────────────────────── # Read a profile file. Output one line per entry: "\t" # Comments (#…) and blank lines are stripped. Default type is "gstack". read_profile() { local prof="$1" local file="$PROFILES_DIR/$prof.profile" [ -f "$file" ] || { err "Profile not found: $prof (looked in $PROFILES_DIR)"; return 1; } local skill type rest while IFS= read -r line || [ -n "$line" ]; do line="${line%%#*}" # trim leading whitespace + tabs while [[ "$line" =~ ^[[:space:]] ]]; do line="${line#?}"; done # trim trailing whitespace + tabs while [[ "$line" =~ [[:space:]]$ ]]; do line="${line%?}"; done [ -z "$line" ] && continue # split on first whitespace run skill="${line%%[[:space:]]*}" rest="${line#"$skill"}" while [[ "$rest" =~ ^[[:space:]] ]]; do rest="${rest#?}"; done type="${rest:-gstack}" # Validate type. Accepted forms: # gstack | external | personal — skill (symlink toggle) # plugin@ — Claude plugin (auto-toggle) # plugin — legacy/advisory (no marketplace known) # mcp — MCP server (advisory or via toggle-external) # cli — standalone CLI (advisory only) case "$type" in gstack|external|personal|plugin|mcp|cli) : ;; plugin@*) : ;; *) warn "unknown type '$type' for entry '$skill' in $prof — defaulting to gstack"; type=gstack ;; esac printf '%s\t%s\n' "$skill" "$type" done < "$file" } # All skills bundled in skills-external/gstack/ gstack_skills() { local src="$REPO/skills-external/gstack" [ -d "$src" ] || return 0 for d in "$src"/*/; do [ -f "${d}SKILL.md" ] || continue basename "$d" done } # Profile description (line starting with "# DESC: …") profile_desc() { local file="$1" grep -m1 '^# DESC:' "$file" 2>/dev/null | sed 's/^# DESC:[[:space:]]*//' || true } # ── Counting & formatting helpers ───────────────────────── # Tally a profile's entries by category (claude-free, reads the .profile only). # Echo: " " — skills = gstack+external+personal. count_profile() { local prof="$1" type local g=0 e=0 p=0 pl=0 m=0 c=0 while IFS=$'\t' read -r _ type; do case "$type" in gstack) g=$((g + 1)) ;; external) e=$((e + 1)) ;; personal) p=$((p + 1)) ;; plugin@*|plugin) pl=$((pl + 1)) ;; mcp) m=$((m + 1)) ;; cli) c=$((c + 1)) ;; esac done < <(read_profile "$prof") printf '%d %d %d %d\n' "$((g + e + p))" "$pl" "$m" "$c" } # " " with a plural "s" when n != 1. _plur() { if [ "$1" -eq 1 ]; then printf '%d %s' "$1" "$2"; else printf '%d %ss' "$1" "$2"; fi } # Format four category counts. style=compact -> "12s·1p·1m·1c"; # style=long -> "12 skills · 1 plugin · 1 mcp · 1 cli". Zero categories are # skipped; all-zero -> "—". fmt_counts() { local style="$1" skills="$2" pl="$3" m="$4" c="$5" out="" if [ "$style" = compact ]; then [ "$skills" -gt 0 ] && out="${skills}s" [ "$pl" -gt 0 ] && out="${out:+$out·}${pl}p" [ "$m" -gt 0 ] && out="${out:+$out·}${m}m" [ "$c" -gt 0 ] && out="${out:+$out·}${c}c" else [ "$skills" -gt 0 ] && out="$(_plur "$skills" skill)" [ "$pl" -gt 0 ] && out="${out:+$out · }$(_plur "$pl" plugin)" [ "$m" -gt 0 ] && out="${out:+$out · }$(_plur "$m" mcp)" [ "$c" -gt 0 ] && out="${out:+$out · }$(_plur "$c" cli)" fi printf '%s' "${out:-—}" } # Right-pad a string to display width $2 (character count, UTF-8 aware). rpad() { local s="$1" w="$2" len=${#1} if [ "$len" -lt "$w" ]; then printf '%s%*s' "$s" "$((w - len))" ''; else printf '%s' "$s"; fi } # ── Status detection ────────────────────────────────────── skill_status() { local skill="$1" type="$2" case "$type" in gstack|external|personal) if [ -e "$SKILLS_DIR/$skill" ]; then echo "enabled" elif [ -e "$DISABLED_DIR/gstack__$skill" ] || [ -e "$DISABLED_DIR/$skill" ]; then echo "disabled" else echo "missing" fi ;; plugin|plugin@*) # `claude plugin list` is the source of truth — settings.json may be # ahead of or behind reality if the user toggled outside this tool. if command -v "$CLAUDE_BIN" >/dev/null 2>&1; then # Match the plugin block by name then check Status line if "$CLAUDE_BIN" plugin list 2>/dev/null \ | awk -v p="$skill" ' /^[[:space:]]*❯ '"$skill"'@/ { found=1; next } found && /Status:/ { print; exit } ' \ | grep -q "✔ enabled"; then echo "enabled" else echo "disabled" fi else echo "unknown" fi ;; mcp) if command -v "$CLAUDE_BIN" >/dev/null 2>&1 && \ "$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${skill}"; then echo "enabled" else echo "disabled" fi ;; cli) command -v "$skill" >/dev/null 2>&1 && echo "installed" || echo "not-installed" ;; *) echo "unknown" ;; esac } # How much of one profile is actually available right now. An item counts # as available when its status is "enabled" (skills, plugins, MCPs) or # "installed" (CLIs). Echo: " " — total 0 for an empty # profile. Used by cmd_current to score the ACTIVE label only (BDR-030: # scanning every profile for a best guess doesn't apply — gstack is off by # default, so a parked count says nothing about which profile is on). profile_match() { local prof="$1" skill type status local total=0 available=0 while IFS=$'\t' read -r skill type; do total=$((total + 1)) status="$(skill_status "$skill" "$type")" case "$status" in enabled|installed) available=$((available + 1)) ;; esac done < <(read_profile "$prof") printf '%d %d\n' "$available" "$total" } # ── Enable / disable ────────────────────────────────────── enable_skill() { local skill="$1" type="$2" case "$type" in gstack) if [ -e "$DISABLED_DIR/gstack__$skill" ]; then rm -rf "${SKILLS_DIR:?}/${skill:?}" mv "$DISABLED_DIR/gstack__$skill" "$SKILLS_DIR/$skill" ok "enabled: $skill" elif [ -e "$DISABLED_DIR/$skill" ]; then rm -rf "${SKILLS_DIR:?}/${skill:?}" mv "$DISABLED_DIR/$skill" "$SKILLS_DIR/$skill" ok "enabled: $skill" elif [ -e "$SKILLS_DIR/$skill" ]; then : # already enabled — silent elif [ -d "$GSTACK_SRC/$skill" ]; then # gstack is OFF by default: its skills live only in the submodule, # never pre-symlinked into skills/. A profile that lists this gstack # skill activates it on demand by symlinking the submodule skill dir # in. disable_gstack_not_in() parks it again when an unrelated profile # is set. The gstack/bin + browse/dist infra it relies on is created # by link.sh, independent of this. ln -sf "$GSTACK_SRC/$skill" "$SKILLS_DIR/$skill" ok "enabled: $skill (gstack on-demand)" elif [ ! -d "$GSTACK_SRC" ]; then warn "missing: $skill — gstack submodule absent, run: git submodule update --init" else warn "missing: $skill — not found in gstack submodule ($GSTACK_SRC)" fi ;; external|personal) if [ -e "$DISABLED_DIR/$skill" ]; then rm -rf "${SKILLS_DIR:?}/${skill:?}" mv "$DISABLED_DIR/$skill" "$SKILLS_DIR/$skill" ok "enabled: $skill ($type)" elif [ -e "$SKILLS_DIR/$skill" ]; then : elif [ "$type" = external ] && [ -d "$REPO/skills-external/$skill" ]; then # Symlink never created (or hand-removed): recreate it from the # vendored pack — mirrors toggle-external.sh's from-source path. ln -sf "$REPO/skills-external/$skill" "$SKILLS_DIR/$skill" ok "enabled: $skill (external, symlink created)" else warn "missing: $skill ($type)" fi ;; plugin@*) # type holds the marketplace: plugin@ local marketplace="${type#plugin@}" if [ "$(skill_status "$skill" "$type")" = "enabled" ]; then : # already on elif command -v "$CLAUDE_BIN" >/dev/null 2>&1; then if "$CLAUDE_BIN" plugin enable "${skill}@${marketplace}" 2>&1 | grep -qiE "enabled|already"; then ok "enabled plugin: ${skill}@${marketplace}" else warn "could not enable plugin: ${skill}@${marketplace}" fi else info "claude CLI not in PATH — manual: claude plugin enable ${skill}@${marketplace}" fi ;; plugin) # No marketplace specified — purely advisory. if [ "$(skill_status "$skill" plugin)" = "enabled" ]; then : # already on else info "plugin '$skill' not enabled — run: claude plugin enable $skill@" fi ;; mcp) # Advisory only: MANAGED_MCPS is empty, nothing is auto-registered. # Re-add a delegation branch here the day a profile owns an MCP server. if [ "$(skill_status "$skill" mcp)" = "enabled" ]; then : # already on else info "MCP '$skill' not registered — run: claude mcp add $skill -- " fi ;; cli) # CLIs install externally; we never auto-install. Just report status. if command -v "$skill" >/dev/null 2>&1; then : # installed — silent else info "CLI '$skill' not installed — install separately (npm/cargo/pipx)" fi ;; esac } disable_skill() { local skill="$1" type="$2" case "$type" in gstack) if [ -e "$SKILLS_DIR/$skill" ]; then mkdir -p "$DISABLED_DIR" rm -rf "$DISABLED_DIR/gstack__$skill" mv "$SKILLS_DIR/$skill" "$DISABLED_DIR/gstack__$skill" ok "disabled: $skill" fi ;; external|personal) if [ -e "$SKILLS_DIR/$skill" ]; then mkdir -p "$DISABLED_DIR" rm -rf "${DISABLED_DIR:?}/${skill:?}" mv "$SKILLS_DIR/$skill" "$DISABLED_DIR/$skill" ok "disabled: $skill ($type)" fi ;; plugin@*) local marketplace="${type#plugin@}" local key="${skill}@${marketplace}" # Defensive check against PROTECTED_PLUGINS (always-on). local p for p in "${PROTECTED_PLUGINS[@]}"; do if [ "$key" = "$p" ]; then warn "refusing to disable protected plugin: $key" return 0 fi done if [ "$(skill_status "$skill" "$type")" = "disabled" ]; then : # already off elif command -v "$CLAUDE_BIN" >/dev/null 2>&1; then if "$CLAUDE_BIN" plugin disable "$key" 2>&1 | grep -qiE "disabled|already"; then ok "disabled plugin: $key" else warn "could not disable plugin: $key" fi else info "claude CLI not in PATH — manual: claude plugin disable $key" fi ;; plugin) info "plugin '$skill' — manual: claude plugin disable $skill@" ;; mcp) info "MCP '$skill' — manual: claude mcp remove $skill" ;; cli) : # never auto-uninstall CLIs ;; esac } # ── Shared gstack operations ────────────────────────────── # Re-enable every gstack skill parked in skills-disabled/ (move gstack__* # back into skills/). Shared by cmd_reset and `gstack on`. Side effects # only; prints one confirmation per restored skill. enable_all_gstack() { local entry name [ -d "$DISABLED_DIR" ] || return 0 for entry in "$DISABLED_DIR"/gstack__*; do [ -e "$entry" ] || continue name="$(basename "$entry" | sed 's/^gstack__//')" rm -rf "${SKILLS_DIR:?}/${name:?}" mv "$entry" "$SKILLS_DIR/$name" ok "re-enabled: $name" done } # Disable gstack-origin skills not listed in the given profile. Shared by # cmd_set and `gstack off`. Caller is responsible for validating the profile. disable_gstack_not_in() { local prof="$1" local keep_file name keep_file="$(mktemp)" read_profile "$prof" | cut -f1 | sort -u > "$keep_file" while read -r name; do [ -n "$name" ] || continue grep -qx "$name" "$keep_file" || disable_skill "$name" gstack done < <(gstack_skills | sort -u) rm -f "$keep_file" } # Count gstack skills currently parked in skills-disabled/. parked_gstack_count() { [ -d "$DISABLED_DIR" ] || { echo 0; return 0; } find "$DISABLED_DIR" -maxdepth 1 -name 'gstack__*' 2>/dev/null | wc -l | tr -d ' ' } # ── `set` trim helpers — one per managed category ───────────── # Each disables the managed items NOT listed in the given profile. Allowlist # doctrine: only MANAGED_* entries are ever auto-disabled. disable_plugins_not_in() { local prof="$1" keep_file p plugin_name marketplace keep_file="$(mktemp)" read_profile "$prof" \ | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' \ | sort -u > "$keep_file" for p in "${MANAGED_PLUGINS[@]}"; do if ! grep -qx "$p" "$keep_file"; then plugin_name="${p%@*}" marketplace="${p#*@}" disable_skill "$plugin_name" "plugin@${marketplace}" fi done rm -f "$keep_file" } disable_externals_not_in() { local prof="$1" keep_file x keep_file="$(mktemp)" read_profile "$prof" | awk -F'\t' '$2 == "external" { print $1 }' \ | sort -u > "$keep_file" for x in "${MANAGED_EXTERNALS[@]}"; do grep -qx "$x" "$keep_file" || disable_skill "$x" external done rm -f "$keep_file" } disable_mcps_not_in() { local prof="$1" keep_file s keep_file="$(mktemp)" read_profile "$prof" | awk -F'\t' '$2 == "mcp" { print $1 }' \ | sort -u > "$keep_file" for s in "${MANAGED_MCPS[@]}"; do grep -qx "$s" "$keep_file" || disable_skill "$s" mcp done rm -f "$keep_file" } # ── Commands ────────────────────────────────────────────── cmd_list() { printf "%-9s %-13s %s\n" "PROFILE" "ITEMS" "DESCRIPTION" printf "%-9s %-13s %s\n" "-------" "-----" "-----------" local f name desc skills pl m c contents for f in "$PROFILES_DIR"/*.profile; do [ -f "$f" ] || continue name="$(basename "$f" .profile)" desc="$(profile_desc "$f")" read -r skills pl m c <<<"$(count_profile "$name")" contents="$(fmt_counts compact "$skills" "$pl" "$m" "$c")" printf "%-9s %s %s\n" "$name" "$(rpad "$contents" 13)" "${desc:--}" done } cmd_show() { local prof="$1" plain=0 [ "${2:-}" = "--plain" ] && plain=1 local file="$PROFILES_DIR/$prof.profile" [ -f "$file" ] || { err "Profile not found: $prof"; return 1; } # Snapshot entries once (claude-free): "", canonical name. # A plugin's marketplace (plugin@) collapses to category "plugin". local entries=() skill type cat while IFS=$'\t' read -r skill type; do case "$type" in plugin@*|plugin) cat=plugin ;; *) cat="$type" ;; esac entries+=("$cat"$'\t'"$skill") done < <(read_profile "$prof") # --plain: parsable contract for the design gate. One "" # per line, grouped by type, NO status, NO claude calls. if [ "$plain" -eq 1 ]; then local e for cat in gstack external personal plugin mcp cli; do for e in "${entries[@]}"; do [ "${e%%$'\t'*}" = "$cat" ] && printf '%s\t%s\n' "$cat" "${e#*$'\t'}" done done return 0 fi echo "Profile: $prof" local desc; desc="$(profile_desc "$file")" [ -n "$desc" ] && echo "Description: $desc" local skills pl m c total read -r skills pl m c <<<"$(count_profile "$prof")" total=$((skills + pl + m + c)) if [ "$total" -eq 0 ]; then echo "Total: 0 items (empty — strips all gstack)" else echo "Total: $total items — $(fmt_counts long "$skills" "$pl" "$m" "$c")" fi echo "" # Grouped by type, fixed order; empty groups skipped. Canonical name + # runtime status (existing skill_status — degrades to disabled if no claude). local e names status for cat in gstack external personal plugin mcp cli; do names=() for e in "${entries[@]}"; do [ "${e%%$'\t'*}" = "$cat" ] && names+=("${e#*$'\t'}") done [ "${#names[@]}" -eq 0 ] && continue printf '%s (%d):\n' "$cat" "${#names[@]}" for skill in "${names[@]}"; do status="$(skill_status "$skill" "$cat")" printf ' %-24s %s\n' "$skill" "$status" done done } cmd_apply() { local prof="$1" info "Applying profile: $prof (additive — leaves other skills alone)" local skill type while IFS=$'\t' read -r skill type; do enable_skill "$skill" "$type" done < <(read_profile "$prof") write_active "$prof" } cmd_set() { local prof="$1" info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins/externals/MCPs)" # Disable gstack-origin skills not in profile. disable_gstack_not_in "$prof" # Disable managed plugins not in profile (PROTECTED_PLUGINS are excluded # by disable_skill itself — belt and suspenders). disable_plugins_not_in "$prof" # Symmetry (BDR-079): a profile switch also parks the managed external # packs and unregisters the managed MCPs the new profile does not need — # design leftovers (emil, the 21st pack…) no longer survive a `set backend`. disable_externals_not_in "$prof" disable_mcps_not_in "$prof" # Enable everything listed in the profile. cmd_apply "$prof" } cmd_reset() { info "Resetting to the default profile: $DEFAULT_PROFILE (exclusive — enables its list, parks any non-listed gstack or managed item currently on)" cmd_set "$DEFAULT_PROFILE" } # gstack on|off — focused gstack-only toggle that keeps the active-profile # label intact (unlike reset, which switches to the default profile). Lets # the user layer all gstack on top of their current profile, or trim it # back down to just what the active profile needs. cmd_gstack() { local action="${1:-}" case "$action" in on) # Restore whatever is parked, but DON'T touch active-profile — the # user is adding gstack on top of their current profile, not clearing it. local parked parked="$(parked_gstack_count)" if [ "$parked" -eq 0 ]; then info "nothing parked — gstack skills are linked per profile (set/apply/reset)" else enable_all_gstack ok "$parked parked gstack skills restored" fi ;; off) # Disable gstack skills not needed by the active profile. A cache # naming a profile with no lib/profiles/.profile still errors; # absent/empty/"none" resolve to the default profile via # active_profile() and no longer hit that error. local active active="$(active_profile)" if [ ! -f "$PROFILES_DIR/$active.profile" ]; then err "no active profile — 'gstack off' needs one to know what to keep" info "run: bash lib/profile.sh set then: gstack off" return 1 fi info "Disabling gstack skills not in active profile: $active" disable_gstack_not_in "$active" ok "gstack trimmed to profile: $active" ;; ""|-h|--help|help) cat <<'EOF' profile gstack on|off — toggle gstack without losing the active-profile label on re-enable ALL gstack skills (keeps active-profile label) off disable gstack skills not in the active profile EOF ;; *) err "Unknown gstack action: '$action' (use: on | off)"; return 1 ;; esac } cmd_current() { # Label-driven (BDR-030): name active_profile() and score ONLY that # profile — no cross-profile best-guess scan, no fast path keyed on the # parked-gstack count (that count says nothing about which profile is on # when gstack starts off, as it does on a real tree). local label; label="$(active_profile)" if [ ! -f "$PROFILES_DIR/$label.profile" ]; then echo "$label (unknown profile — no lib/profiles/$label.profile; run: profile reset)" return 0 fi local available total pct read -r available total <<<"$(profile_match "$label")" pct=0 [ "$total" -gt 0 ] && pct=$((available * 100 / total)) local parked; parked="$(parked_gstack_count)" local cached; cached="$(read_cache)" if [ -z "$cached" ] || [ "$cached" = "none" ]; then echo "$label (default — not applied yet, ${pct}% of its items enabled; run: profile reset)" else echo "$label (${pct}% match, $parked gstack skills disabled)" fi } cmd_diff() { local a="$1" b="$2" local fa="$PROFILES_DIR/$a.profile" fb="$PROFILES_DIR/$b.profile" [ -f "$fa" ] || { err "Profile not found: $a"; return 1; } [ -f "$fb" ] || { err "Profile not found: $b"; return 1; } local list_a list_b list_a="$(mktemp)"; list_b="$(mktemp)" read_profile "$a" | cut -f1 | sort -u > "$list_a" read_profile "$b" | cut -f1 | sort -u > "$list_b" echo "Only in $a:"; comm -23 "$list_a" "$list_b" | sed 's/^/ - /' echo "Only in $b:"; comm -13 "$list_a" "$list_b" | sed 's/^/ + /' echo "Common:" ; comm -12 "$list_a" "$list_b" | sed 's/^/ = /' rm -f "$list_a" "$list_b" } usage() { cat < show profile contents grouped by type + status profile show --plain parsable type+name list (no status, no claude) profile current report the active profile (label + match) profile apply enable skills in profile (additive) profile set enable only listed skills (disables rest of gstack) profile reset go to the default profile (full): enable its list, park non-listed gstack/managed items profile gstack on|off toggle gstack only, keep active-profile label profile diff compare two profiles PROFILES (in $PROFILES_DIR): EOF local f name desc for f in "$PROFILES_DIR"/*.profile; do [ -f "$f" ] || continue name="$(basename "$f" .profile)" desc="$(profile_desc "$f")" printf " %-10s %s\n" "$name" "${desc:--}" done cat <" yourself. EOF } main() { local cmd="${1:-}" case "$cmd" in list) cmd_list ;; show) [ $# -ge 2 ] || { usage; exit 1; }; cmd_show "$2" "${3:-}" ;; current) cmd_current ;; apply) [ $# -ge 2 ] || { usage; exit 1; }; cmd_apply "$2" ;; set) [ $# -ge 2 ] || { usage; exit 1; }; cmd_set "$2" ;; reset) cmd_reset ;; gstack) cmd_gstack "${2:-}" ;; diff) [ $# -ge 3 ] || { usage; exit 1; }; cmd_diff "$2" "$3" ;; ""|-h|--help|help) usage ;; *) err "Unknown command: $cmd"; usage; exit 1 ;; esac } main "$@"