profile.sh 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. #!/usr/bin/env bash
  2. # ============================================================
  3. # lib/profile.sh — Partition Claude skills + plugins + MCPs by purpose
  4. #
  5. # Profiles group skills (gstack + external + personal), plugins, MCPs,
  6. # and CLIs for a specific kind of work: web, seo, web-full, backend,
  7. # design, dev, qa, audit, minimal. Apply a profile to enable just the
  8. # relevant tools and disable the rest, instead of carrying every gstack
  9. # skill + every plugin in every session.
  10. #
  11. # Mechanism:
  12. # - Skills (gstack/external/personal): symlink toggle skills/ ↔ skills-disabled/
  13. # - Plugins: `claude plugin enable|disable <name>@<marketplace>`
  14. # - MCPs: delegated to lib/toggle-external.sh for known servers (magic),
  15. # advisory otherwise
  16. # - CLIs: advisory only (rtk, gsd, ctx7, graphify — installed externally)
  17. #
  18. # Always-on plugins (never toggled by `set`): caveman, security-guidance,
  19. # superpowers + rtk hook + .claude internal. The script refuses to disable
  20. # anything in PROTECTED_PLUGINS.
  21. #
  22. # Usage:
  23. # profile.sh list list available profiles
  24. # profile.sh show <name> show contents of a profile
  25. # profile.sh current detect which profile is active
  26. # profile.sh apply <name> enable items in profile (additive)
  27. # profile.sh set <name> enable only profile (disables rest)
  28. # profile.sh reset re-enable all gstack skills + managed plugins
  29. # profile.sh diff <a> <b> compare two profiles
  30. #
  31. # Profile file format (lib/profiles/<name>.profile):
  32. # # DESC: <one-line description>
  33. # <skill-name> # type defaults to "gstack"
  34. # <skill-name> personal # personal skill (skills/<x>/SKILL.md is real)
  35. # <skill-name> external # symlinked into skills-external/
  36. # <plugin-name> plugin@<marketplace> # Claude plugin — auto-toggle
  37. # <mcp-name> mcp # MCP — advisory or via toggle-external
  38. # <cli-name> cli # standalone CLI — advisory only
  39. #
  40. # ============================================================
  41. set -euo pipefail
  42. REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
  43. SKILLS_DIR="$REPO/skills"
  44. DISABLED_DIR="$REPO/skills-disabled"
  45. PROFILES_DIR="$REPO/lib/profiles"
  46. TOGGLE_EXTERNAL="$REPO/lib/toggle-external.sh"
  47. # Plugins that are toggle-managed by `set`. Anything NOT in this list is
  48. # never auto-disabled — protects always-on plugins (caveman, security-guidance,
  49. # superpowers) and unrelated user plugins. Add a plugin here only when its
  50. # enabled state is meaningfully driven by task type.
  51. MANAGED_PLUGINS=(
  52. "ui-ux-pro-max@ui-ux-pro-max-skill"
  53. "plugin-dev@claude-code-plugins"
  54. "pr-review-toolkit@claude-code-plugins"
  55. )
  56. # Plugins that MUST stay enabled — `set` will refuse to disable these even if
  57. # they're not in the profile. (Defensive: belt-and-suspenders alongside
  58. # MANAGED_PLUGINS allowlist.)
  59. PROTECTED_PLUGINS=(
  60. "caveman@caveman"
  61. "security-guidance@claude-code-plugins"
  62. "superpowers@superpowers-marketplace"
  63. )
  64. GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; NC='\033[0m'
  65. ok() { echo -e "${GREEN}✓${NC} $1"; }
  66. warn() { echo -e "${YELLOW}⚠${NC} $1"; }
  67. err() { echo -e "${RED}✗${NC} $1" >&2; }
  68. info() { echo -e "${BLUE}ℹ${NC} $1"; }
  69. # ── Profile parsing ────────────────────────────────────────
  70. # Read a profile file. Output one line per entry: "<skill>\t<type>"
  71. # Comments (#…) and blank lines are stripped. Default type is "gstack".
  72. read_profile() {
  73. local prof="$1"
  74. local file="$PROFILES_DIR/$prof.profile"
  75. [ -f "$file" ] || { err "Profile not found: $prof (looked in $PROFILES_DIR)"; return 1; }
  76. local skill type rest
  77. while IFS= read -r line || [ -n "$line" ]; do
  78. line="${line%%#*}"
  79. # trim leading whitespace + tabs
  80. while [[ "$line" =~ ^[[:space:]] ]]; do line="${line#?}"; done
  81. # trim trailing whitespace + tabs
  82. while [[ "$line" =~ [[:space:]]$ ]]; do line="${line%?}"; done
  83. [ -z "$line" ] && continue
  84. # split on first whitespace run
  85. skill="${line%%[[:space:]]*}"
  86. rest="${line#"$skill"}"
  87. while [[ "$rest" =~ ^[[:space:]] ]]; do rest="${rest#?}"; done
  88. type="${rest:-gstack}"
  89. # Validate type. Accepted forms:
  90. # gstack | external | personal — skill (symlink toggle)
  91. # plugin@<marketplace> — Claude plugin (auto-toggle)
  92. # plugin — legacy/advisory (no marketplace known)
  93. # mcp — MCP server (advisory or via toggle-external)
  94. # cli — standalone CLI (advisory only)
  95. case "$type" in
  96. gstack|external|personal|plugin|mcp|cli) : ;;
  97. plugin@*) : ;;
  98. *) warn "unknown type '$type' for entry '$skill' in $prof — defaulting to gstack"; type=gstack ;;
  99. esac
  100. printf '%s\t%s\n' "$skill" "$type"
  101. done < "$file"
  102. }
  103. # All skills bundled in skills-external/gstack/
  104. gstack_skills() {
  105. local src="$REPO/skills-external/gstack"
  106. [ -d "$src" ] || return 0
  107. for d in "$src"/*/; do
  108. [ -f "${d}SKILL.md" ] || continue
  109. basename "$d"
  110. done
  111. }
  112. # Profile description (line starting with "# DESC: …")
  113. profile_desc() {
  114. local file="$1"
  115. grep -m1 '^# DESC:' "$file" 2>/dev/null | sed 's/^# DESC:[[:space:]]*//' || true
  116. }
  117. # ── Status detection ──────────────────────────────────────
  118. skill_status() {
  119. local skill="$1" type="$2"
  120. case "$type" in
  121. gstack|external|personal)
  122. if [ -e "$SKILLS_DIR/$skill" ]; then
  123. echo "enabled"
  124. elif [ -e "$DISABLED_DIR/gstack__$skill" ] || [ -e "$DISABLED_DIR/$skill" ]; then
  125. echo "disabled"
  126. else
  127. echo "missing"
  128. fi
  129. ;;
  130. plugin|plugin@*)
  131. # `claude plugin list` is the source of truth — settings.json may be
  132. # ahead of or behind reality if the user toggled outside this tool.
  133. if command -v claude >/dev/null 2>&1; then
  134. # Match the plugin block by name then check Status line
  135. if claude plugin list 2>/dev/null \
  136. | awk -v p="$skill" '
  137. /^[[:space:]]*❯ '"$skill"'@/ { found=1; next }
  138. found && /Status:/ { print; exit }
  139. ' \
  140. | grep -q "✔ enabled"; then
  141. echo "enabled"
  142. else
  143. echo "disabled"
  144. fi
  145. else
  146. echo "unknown"
  147. fi
  148. ;;
  149. mcp)
  150. if command -v claude >/dev/null 2>&1 && \
  151. claude mcp list 2>/dev/null | grep -q "^${skill}"; then
  152. echo "enabled"
  153. else
  154. echo "disabled"
  155. fi
  156. ;;
  157. cli)
  158. command -v "$skill" >/dev/null 2>&1 && echo "installed" || echo "not-installed"
  159. ;;
  160. *) echo "unknown" ;;
  161. esac
  162. }
  163. # ── Enable / disable ──────────────────────────────────────
  164. enable_skill() {
  165. local skill="$1" type="$2"
  166. case "$type" in
  167. gstack)
  168. if [ -e "$DISABLED_DIR/gstack__$skill" ]; then
  169. rm -rf "${SKILLS_DIR:?}/${skill:?}"
  170. mv "$DISABLED_DIR/gstack__$skill" "$SKILLS_DIR/$skill"
  171. ok "enabled: $skill"
  172. elif [ -e "$DISABLED_DIR/$skill" ]; then
  173. rm -rf "${SKILLS_DIR:?}/${skill:?}"
  174. mv "$DISABLED_DIR/$skill" "$SKILLS_DIR/$skill"
  175. ok "enabled: $skill"
  176. elif [ -e "$SKILLS_DIR/$skill" ]; then
  177. : # already enabled — silent
  178. else
  179. warn "missing: $skill — try: bash link.sh"
  180. fi
  181. ;;
  182. external|personal)
  183. if [ -e "$DISABLED_DIR/$skill" ]; then
  184. rm -rf "${SKILLS_DIR:?}/${skill:?}"
  185. mv "$DISABLED_DIR/$skill" "$SKILLS_DIR/$skill"
  186. ok "enabled: $skill ($type)"
  187. elif [ -e "$SKILLS_DIR/$skill" ]; then
  188. :
  189. else
  190. warn "missing: $skill ($type)"
  191. fi
  192. ;;
  193. plugin@*)
  194. # type holds the marketplace: plugin@<marketplace>
  195. local marketplace="${type#plugin@}"
  196. if [ "$(skill_status "$skill" "$type")" = "enabled" ]; then
  197. : # already on
  198. elif command -v claude >/dev/null 2>&1; then
  199. if claude plugin enable "${skill}@${marketplace}" 2>&1 | grep -qiE "enabled|already"; then
  200. ok "enabled plugin: ${skill}@${marketplace}"
  201. else
  202. warn "could not enable plugin: ${skill}@${marketplace}"
  203. fi
  204. else
  205. info "claude CLI not in PATH — manual: claude plugin enable ${skill}@${marketplace}"
  206. fi
  207. ;;
  208. plugin)
  209. # No marketplace specified — purely advisory.
  210. if [ "$(skill_status "$skill" plugin)" = "enabled" ]; then
  211. : # already on
  212. else
  213. info "plugin '$skill' not enabled — run: claude plugin enable $skill@<marketplace>"
  214. fi
  215. ;;
  216. mcp)
  217. if [ "$(skill_status "$skill" mcp)" = "enabled" ]; then
  218. : # already on
  219. elif [ "$skill" = "magic" ] && [ -x "$TOGGLE_EXTERNAL" ]; then
  220. # Known MCP — delegate to lib/toggle-external.sh which handles env vars.
  221. if bash "$TOGGLE_EXTERNAL" enable magic 2>&1 | grep -qE "enabled|already"; then
  222. ok "enabled MCP: magic"
  223. else
  224. info "MCP 'magic' could not be enabled (check .env for MAGIC_API_KEY)"
  225. fi
  226. else
  227. info "MCP '$skill' not registered — run: claude mcp add $skill -- <command>"
  228. fi
  229. ;;
  230. cli)
  231. # CLIs install externally; we never auto-install. Just report status.
  232. if command -v "$skill" >/dev/null 2>&1; then
  233. : # installed — silent
  234. else
  235. info "CLI '$skill' not installed — install separately (npm/cargo/pipx)"
  236. fi
  237. ;;
  238. esac
  239. }
  240. disable_skill() {
  241. local skill="$1" type="$2"
  242. case "$type" in
  243. gstack)
  244. if [ -e "$SKILLS_DIR/$skill" ]; then
  245. mkdir -p "$DISABLED_DIR"
  246. rm -rf "$DISABLED_DIR/gstack__$skill"
  247. mv "$SKILLS_DIR/$skill" "$DISABLED_DIR/gstack__$skill"
  248. ok "disabled: $skill"
  249. fi
  250. ;;
  251. external|personal)
  252. if [ -e "$SKILLS_DIR/$skill" ]; then
  253. mkdir -p "$DISABLED_DIR"
  254. rm -rf "${DISABLED_DIR:?}/${skill:?}"
  255. mv "$SKILLS_DIR/$skill" "$DISABLED_DIR/$skill"
  256. ok "disabled: $skill ($type)"
  257. fi
  258. ;;
  259. plugin@*)
  260. local marketplace="${type#plugin@}"
  261. local key="${skill}@${marketplace}"
  262. # Defensive check against PROTECTED_PLUGINS (always-on).
  263. local p
  264. for p in "${PROTECTED_PLUGINS[@]}"; do
  265. if [ "$key" = "$p" ]; then
  266. warn "refusing to disable protected plugin: $key"
  267. return 0
  268. fi
  269. done
  270. if [ "$(skill_status "$skill" "$type")" = "disabled" ]; then
  271. : # already off
  272. elif command -v claude >/dev/null 2>&1; then
  273. if claude plugin disable "$key" 2>&1 | grep -qiE "disabled|already"; then
  274. ok "disabled plugin: $key"
  275. else
  276. warn "could not disable plugin: $key"
  277. fi
  278. else
  279. info "claude CLI not in PATH — manual: claude plugin disable $key"
  280. fi
  281. ;;
  282. plugin)
  283. info "plugin '$skill' — manual: claude plugin disable $skill@<marketplace>"
  284. ;;
  285. mcp)
  286. if [ "$skill" = "magic" ] && [ -x "$TOGGLE_EXTERNAL" ]; then
  287. if bash "$TOGGLE_EXTERNAL" disable magic 2>&1 | grep -qE "disabled|already"; then
  288. ok "disabled MCP: magic"
  289. else
  290. info "MCP 'magic' — manual disable failed"
  291. fi
  292. else
  293. info "MCP '$skill' — manual: claude mcp remove $skill"
  294. fi
  295. ;;
  296. cli)
  297. : # never auto-uninstall CLIs
  298. ;;
  299. esac
  300. }
  301. # ── Commands ──────────────────────────────────────────────
  302. cmd_list() {
  303. printf "%-12s %s\n" "PROFILE" "DESCRIPTION"
  304. printf "%-12s %s\n" "-------" "-----------"
  305. local f name desc
  306. for f in "$PROFILES_DIR"/*.profile; do
  307. [ -f "$f" ] || continue
  308. name="$(basename "$f" .profile)"
  309. desc="$(profile_desc "$f")"
  310. printf "%-12s %s\n" "$name" "${desc:--}"
  311. done
  312. }
  313. cmd_show() {
  314. local prof="$1"
  315. local file="$PROFILES_DIR/$prof.profile"
  316. [ -f "$file" ] || { err "Profile not found: $prof"; return 1; }
  317. echo "Profile: $prof"
  318. local desc
  319. desc="$(profile_desc "$file")"
  320. [ -n "$desc" ] && echo "Description: $desc"
  321. echo ""
  322. printf "%-25s %-30s %s\n" "ITEM" "TYPE" "STATUS"
  323. printf "%-25s %-30s %s\n" "----" "----" "------"
  324. local skill type status
  325. while IFS=$'\t' read -r skill type; do
  326. status="$(skill_status "$skill" "$type")"
  327. printf "%-25s %-30s %s\n" "$skill" "$type" "$status"
  328. done < <(read_profile "$prof")
  329. }
  330. cmd_apply() {
  331. local prof="$1"
  332. info "Applying profile: $prof (additive — leaves other skills alone)"
  333. local skill type
  334. while IFS=$'\t' read -r skill type; do
  335. enable_skill "$skill" "$type"
  336. done < <(read_profile "$prof")
  337. }
  338. cmd_set() {
  339. local prof="$1"
  340. info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins)"
  341. # Index of items in profile (skill names + plugin keys "name@marketplace").
  342. local keep_file
  343. keep_file="$(mktemp)"
  344. # Skill names (col 1) — used to keep gstack skills.
  345. read_profile "$prof" | cut -f1 | sort -u > "$keep_file"
  346. # Plugin keys "name@marketplace" — used to keep managed plugins.
  347. local plugin_keep_file
  348. plugin_keep_file="$(mktemp)"
  349. read_profile "$prof" | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' | sort -u > "$plugin_keep_file"
  350. # Disable gstack-origin skills not in profile.
  351. local name
  352. while read -r name; do
  353. [ -n "$name" ] || continue
  354. if ! grep -qx "$name" "$keep_file"; then
  355. disable_skill "$name" gstack
  356. fi
  357. done < <(gstack_skills | sort -u)
  358. # Disable managed plugins not in profile (PROTECTED_PLUGINS are excluded
  359. # by disable_skill itself — belt and suspenders).
  360. local p key plugin_name marketplace
  361. for p in "${MANAGED_PLUGINS[@]}"; do
  362. if ! grep -qx "$p" "$plugin_keep_file"; then
  363. plugin_name="${p%@*}"
  364. marketplace="${p#*@}"
  365. disable_skill "$plugin_name" "plugin@${marketplace}"
  366. fi
  367. done
  368. rm -f "$keep_file" "$plugin_keep_file"
  369. # Enable everything listed in the profile.
  370. cmd_apply "$prof"
  371. }
  372. cmd_reset() {
  373. info "Re-enabling all gstack skills (move skills-disabled/gstack__* back)"
  374. local entry name
  375. if [ -d "$DISABLED_DIR" ]; then
  376. for entry in "$DISABLED_DIR"/gstack__*; do
  377. [ -e "$entry" ] || continue
  378. name="$(basename "$entry" | sed 's/^gstack__//')"
  379. rm -rf "${SKILLS_DIR:?}/${name:?}"
  380. mv "$entry" "$SKILLS_DIR/$name"
  381. ok "re-enabled: $name"
  382. done
  383. fi
  384. info "Plugin state NOT touched. To re-enable a managed plugin disabled by 'set',"
  385. info "run: claude plugin enable <name>@<marketplace> (or: profile apply <profile>)"
  386. }
  387. cmd_current() {
  388. # A profile is "active" only if (a) most of its skills are enabled AND
  389. # (b) at least one non-listed gstack skill is currently disabled (i.e. a
  390. # `set` has actually been applied). Without (b), every profile reports
  391. # 100% trivially because the full gstack is on.
  392. local disabled_count=0
  393. if [ -d "$DISABLED_DIR" ]; then
  394. disabled_count=$(find "$DISABLED_DIR" -maxdepth 1 -name 'gstack__*' 2>/dev/null | wc -l | tr -d ' ')
  395. fi
  396. if [ "$disabled_count" -eq 0 ]; then
  397. echo "full (all gstack skills enabled — no profile set)"
  398. return 0
  399. fi
  400. # Pick the profile with the highest "available" ratio. An item counts as
  401. # available when its status is "enabled" (skills, plugins, MCPs) or
  402. # "installed" (CLIs). On ties, the profile with the larger total wins
  403. # — superset profiles describe state more completely than subsets.
  404. local f name total available score skill type status
  405. local best="" best_score=0 best_total=0
  406. for f in "$PROFILES_DIR"/*.profile; do
  407. [ -f "$f" ] || continue
  408. name="$(basename "$f" .profile)"
  409. total=0; available=0
  410. while IFS=$'\t' read -r skill type; do
  411. total=$((total + 1))
  412. status="$(skill_status "$skill" "$type")"
  413. case "$status" in
  414. enabled|installed) available=$((available + 1)) ;;
  415. esac
  416. done < <(read_profile "$name")
  417. [ "$total" -eq 0 ] && continue
  418. score=$((available * 100 / total))
  419. if [ "$score" -gt "$best_score" ] || \
  420. { [ "$score" -eq "$best_score" ] && [ "$total" -gt "$best_total" ]; }; then
  421. best_score="$score"
  422. best_total="$total"
  423. best="$name"
  424. fi
  425. done
  426. if [ -n "$best" ] && [ "$best_score" -ge 80 ]; then
  427. echo "$best (${best_score}% match, $disabled_count gstack skills disabled)"
  428. else
  429. echo "custom (best guess: ${best:-none} ${best_score}%, $disabled_count gstack skills disabled)"
  430. fi
  431. }
  432. cmd_diff() {
  433. local a="$1" b="$2"
  434. local fa="$PROFILES_DIR/$a.profile" fb="$PROFILES_DIR/$b.profile"
  435. [ -f "$fa" ] || { err "Profile not found: $a"; return 1; }
  436. [ -f "$fb" ] || { err "Profile not found: $b"; return 1; }
  437. local list_a list_b
  438. list_a="$(mktemp)"; list_b="$(mktemp)"
  439. read_profile "$a" | cut -f1 | sort -u > "$list_a"
  440. read_profile "$b" | cut -f1 | sort -u > "$list_b"
  441. echo "Only in $a:"; comm -23 "$list_a" "$list_b" | sed 's/^/ - /'
  442. echo "Only in $b:"; comm -13 "$list_a" "$list_b" | sed 's/^/ + /'
  443. echo "Common:" ; comm -12 "$list_a" "$list_b" | sed 's/^/ = /'
  444. rm -f "$list_a" "$list_b"
  445. }
  446. usage() {
  447. cat <<EOF
  448. profile.sh — partition Claude skills by purpose
  449. USAGE:
  450. profile list list all available profiles
  451. profile show <name> show profile contents + per-skill status
  452. profile current detect which profile is currently active
  453. profile apply <name> enable skills in profile (additive)
  454. profile set <name> enable only listed skills (disables rest of gstack)
  455. profile reset re-enable all gstack skills
  456. profile diff <a> <b> compare two profiles
  457. PROFILES (in $PROFILES_DIR):
  458. EOF
  459. local f name desc
  460. for f in "$PROFILES_DIR"/*.profile; do
  461. [ -f "$f" ] || continue
  462. name="$(basename "$f" .profile)"
  463. desc="$(profile_desc "$f")"
  464. printf " %-10s %s\n" "$name" "${desc:--}"
  465. done
  466. cat <<EOF
  467. EXAMPLES:
  468. bash lib/profile.sh list
  469. bash lib/profile.sh show design
  470. bash lib/profile.sh set design # only design skills active
  471. bash lib/profile.sh apply qa # add QA skills on top
  472. bash lib/profile.sh reset # restore everything
  473. NOTE:
  474. Plugin and MCP entries print advisory commands — they are NOT toggled
  475. automatically. Run "claude plugin enable|disable" or "claude mcp add|remove"
  476. yourself for those.
  477. EOF
  478. }
  479. main() {
  480. local cmd="${1:-}"
  481. case "$cmd" in
  482. list) cmd_list ;;
  483. show) [ $# -ge 2 ] || { usage; exit 1; }; cmd_show "$2" ;;
  484. current) cmd_current ;;
  485. apply) [ $# -ge 2 ] || { usage; exit 1; }; cmd_apply "$2" ;;
  486. set) [ $# -ge 2 ] || { usage; exit 1; }; cmd_set "$2" ;;
  487. reset) cmd_reset ;;
  488. diff) [ $# -ge 3 ] || { usage; exit 1; }; cmd_diff "$2" "$3" ;;
  489. ""|-h|--help|help) usage ;;
  490. *) err "Unknown command: $cmd"; usage; exit 1 ;;
  491. esac
  492. }
  493. main "$@"