#!/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 detect which profile is active # profile.sh apply enable items in profile (additive) # profile.sh set enable only profile (disables rest) # profile.sh reset re-enable all gstack skills + managed plugins # 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) # 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 since 2026-09-22: `magic` was the only entry and 21st.dev replaced # its MCP server with a CLI + skill pack (the 5 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 } # ── 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 } # ── 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. The delegation branch that lived here served `magic`, # the single managed MCP; 21st.dev replaced it with a CLI (BDR-093), so # MANAGED_MCPS is empty and nothing is auto-registered. Re-add a branch # here the day a profile owns an MCP server again. 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, magic…) 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 "Re-enabling all gstack skills (move skills-disabled/gstack__* back)" enable_all_gstack info "Plugin state NOT touched. To re-enable a managed plugin disabled by 'set'," info "run: claude plugin enable @ (or: profile apply )" write_active "none" } # gstack on|off — focused gstack-only toggle that keeps the active-profile # label intact (unlike reset, which clears it to "none"). 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) # Re-enable ALL gstack skills, 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 "all gstack skills already enabled" else enable_all_gstack ok "all gstack enabled ($parked skills restored)" fi ;; off) # Disable gstack skills not needed by the active profile. Needs a real # active profile to know what to keep. local active active="$(head -n1 "$ACTIVE_CACHE" 2>/dev/null || echo none)" [ -z "$active" ] && active="none" if [ "$active" = "none" ] || [ ! -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() { # A profile is "active" only if (a) most of its skills are enabled AND # (b) at least one non-listed gstack skill is currently disabled (i.e. a # `set` has actually been applied). Without (b), every profile reports # 100% trivially because the full gstack is on. local disabled_count=0 if [ -d "$DISABLED_DIR" ]; then disabled_count=$(find "$DISABLED_DIR" -maxdepth 1 -name 'gstack__*' 2>/dev/null | wc -l | tr -d ' ') fi if [ "$disabled_count" -eq 0 ]; then echo "none (all gstack skills enabled — no profile set)" return 0 fi # Pick the profile with the highest "available" ratio. An item counts as # available when its status is "enabled" (skills, plugins, MCPs) or # "installed" (CLIs). On ties, the profile with the larger total wins # — superset profiles describe state more completely than subsets. local f name total available score skill type status local best="" best_score=0 best_total=0 for f in "$PROFILES_DIR"/*.profile; do [ -f "$f" ] || continue name="$(basename "$f" .profile)" 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 "$name") [ "$total" -eq 0 ] && continue score=$((available * 100 / total)) if [ "$score" -gt "$best_score" ] || \ { [ "$score" -eq "$best_score" ] && [ "$total" -gt "$best_total" ]; }; then best_score="$score" best_total="$total" best="$name" fi done if [ -n "$best" ] && [ "$best_score" -ge 80 ]; then echo "$best (${best_score}% match, $disabled_count gstack skills disabled)" else echo "custom (best guess: ${best:-none} ${best_score}%, $disabled_count 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 detect which profile is currently active profile apply enable skills in profile (additive) profile set enable only listed skills (disables rest of gstack) profile reset re-enable all gstack skills 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 <