feat(profile): add gstack on|off verb to lib/profile.sh

Centralize gstack toggling in the `profile` command without losing the
active-profile label.

  - `gstack on`  re-enables ALL parked gstack skills (moves
    skills-disabled/gstack__* back) but does NOT touch .active-profile,
    so the user layers full gstack on top of their current profile and
    the statusline label is preserved. Unlike `reset`, which clears the
    label to "none".
  - `gstack off` disables gstack skills not listed in the active profile;
    errors cleanly when no profile is active (needs one to know what to
    keep).

Refactor (behavior-preserving): extract three shared helpers
`enable_all_gstack`, `disable_gstack_not_in`, `parked_gstack_count` and
rewire `cmd_reset` + `cmd_set` to reuse them instead of duplicating the
symlink-toggle loops. Wire `gstack` into main() dispatch, usage(), and the
header usage block.

Docs: SKILL.md argument-hint, examples, and output-policy updated. The
generic `make profile cmd="gstack on"` target already covers Make usage.

Verified: shellcheck CLEAN, `bash -n` OK, 6-case test (help, bad-action,
off-with-no-profile, on, off-trim, on-cycle) with final assertion that the
live symlink state was restored exactly to its pre-test value.

Memory: capitalize BDR-018 (decision), LRN-024 (DRY helper-extraction
pattern), BLK-007 (6 gstack source skills ios-*/spec unlinked post
submodule bump — open follow-up), EVAL-002 (self-eval, false "full.profile
bug" flag corrected pre-edit). Backfill index drift: BDR-017, BLK-005/006.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bastien Chanot 2026-06-02 18:31:48 +02:00
parent 0d9f3d41eb
commit da4e6b9590
8 changed files with 174 additions and 35 deletions

View File

@ -24,6 +24,9 @@ rules:
| BLK-002 | 2026-04-23 | `rmdir` denied in sandbox on empty directory | resolved | | BLK-002 | 2026-04-23 | `rmdir` denied in sandbox on empty directory | resolved |
| BLK-003 | 2026-05-12 | `scripts/screenshot.mjs` hardcoded macOS path blocks PNG cards on Linux | upstream | | BLK-003 | 2026-05-12 | `scripts/screenshot.mjs` hardcoded macOS path blocks PNG cards on Linux | upstream |
| BLK-004 | 2026-05-20 | `/ship-feature` wrapper at `~/.claude/commands/` points to deleted agent files post-refactor | resolved | | BLK-004 | 2026-05-20 | `/ship-feature` wrapper at `~/.claude/commands/` points to deleted agent files post-refactor | resolved |
| BLK-005 | 2026-05-21 | gstack submodule rename (checkpoint→context-save) breaks profile entries | resolved |
| BLK-006 | 2026-05-21 | `profile.sh current` false-negative via `~/.claude` symlink (`cd` not `cd -P`) | resolved |
| BLK-007 | 2026-06-02 | 6 gstack source skills (ios-*, spec) unlinked post-bump — invisible to profiles + `gstack on` | open |
--- ---
@ -82,3 +85,11 @@ rules:
- **Real cause**: `lib/profile.sh:43` set `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`. Default bash `cd` preserves symlinks (logical pathname mode, `set -P` off). When the script is invoked via the `~/.claude/lib/profile.sh` symlink (link.sh wires `~/.claude/lib -> <repo>/lib`), `$BASH_SOURCE[0]` is the symlinked path, `dirname` returns `~/.claude/lib`, `cd ..` lands at `~/.claude`, and `pwd` returns the logical path `/home/bchanot-ubuntu/.claude`. `$SKILLS_DIR="$REPO/skills"` still works because `~/.claude/skills` happens to be a symlink to the repo's `skills/`. But `$DISABLED_DIR="$REPO/skills-disabled"` resolves to `~/.claude/skills-disabled` — a real sibling directory created at some earlier point containing only 2 stale npx-skill symlinks (`darwin-skill`, `find-skills`). `cmd_current` scans this near-empty dir, finds 0 `gstack__*` entries, returns the "none" sentinel. - **Real cause**: `lib/profile.sh:43` set `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`. Default bash `cd` preserves symlinks (logical pathname mode, `set -P` off). When the script is invoked via the `~/.claude/lib/profile.sh` symlink (link.sh wires `~/.claude/lib -> <repo>/lib`), `$BASH_SOURCE[0]` is the symlinked path, `dirname` returns `~/.claude/lib`, `cd ..` lands at `~/.claude`, and `pwd` returns the logical path `/home/bchanot-ubuntu/.claude`. `$SKILLS_DIR="$REPO/skills"` still works because `~/.claude/skills` happens to be a symlink to the repo's `skills/`. But `$DISABLED_DIR="$REPO/skills-disabled"` resolves to `~/.claude/skills-disabled` — a real sibling directory created at some earlier point containing only 2 stale npx-skill symlinks (`darwin-skill`, `find-skills`). `cmd_current` scans this near-empty dir, finds 0 `gstack__*` entries, returns the "none" sentinel.
- **Solution**: `REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` (commit `a4558ee`). `-P` forces physical-path resolution so `$REPO` is always the real repo path regardless of how the script is invoked. Verify: `bash "$HOME/.claude/lib/profile.sh" current` now returns `full (100% match, 14 gstack skills disabled)`. - **Solution**: `REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` (commit `a4558ee`). `-P` forces physical-path resolution so `$REPO` is always the real repo path regardless of how the script is invoked. Verify: `bash "$HOME/.claude/lib/profile.sh" current` now returns `full (100% match, 14 gstack skills disabled)`.
- **Status**: resolved. Follow-up: `~/.claude/skills-disabled/` (real dir with only `darwin-skill`/`find-skills` symlinks) is orphaned — these npx skills are already symlinked into `<repo>/skills/` by link.sh, so the disabled-side copies serve no purpose. Could be deleted to remove confusion, but harmless as-is. - **Status**: resolved. Follow-up: `~/.claude/skills-disabled/` (real dir with only `darwin-skill`/`find-skills` symlinks) is orphaned — these npx skills are already symlinked into `<repo>/skills/` by link.sh, so the disabled-side copies serve no purpose. Could be deleted to remove confusion, but harmless as-is.
## BLK-007 — 6 gstack source skills (ios-*, spec) unlinked — invisible to profile system + `gstack on`
- **Date**: 2026-06-02
- **Friction**: `skills-external/gstack/` has 53 source skills; 6 (`ios-clean`, `ios-design-review`, `ios-fix`, `ios-qa`, `ios-sync`, `spec`) exist ONLY as source — NOT symlinked into `skills/` (enabled) nor `skills-disabled/gstack__*` (parked). So invisible to Claude AND untouched by `reset`/`gstack on` (both operate on parked `gstack__*` only). Surfaced while adding `gstack on|off`: `comm` of gstack source vs `full.profile`.
- **Real cause**: gstack submodule bump added new skills; gstack's own `./setup` (source of truth for per-skill symlinks per link.sh) not re-run → symlinks never created. Same lifecycle gap class as [[toggle-external-source-only-state]] (LRN-007). NOT a `full.profile` bug — full curated by design (BDR-017 caveat: "full excludes rarely-used gstack skills"). Initial "full omits ios = bug" flag was WRONG, self-corrected (see EVAL-002).
- **Solution**: re-run gstack setup to link new skills, then reconcile profiles (decide if iOS skills belong in web/dev profiles — likely NOT). Per [[gstack-rename-profile-audit]] (LRN-022): diff `skills-external/gstack/` vs `lib/profiles/*.profile` after every submodule bump. NOT auto-fixed — gstack installer domain + iOS-in-web judgment call.
- **Status**: open. Low impact (skills unused today, never linked). Next: run gstack `./setup`, audit profile membership.

View File

@ -38,6 +38,8 @@ rules:
| BDR-014 | 2026-05-11 | Personal SKILL.md descriptions: "Use when [triggers]…" pattern + 1024-char spec limit | accepted | | BDR-014 | 2026-05-11 | Personal SKILL.md descriptions: "Use when [triggers]…" pattern + 1024-char spec limit | accepted |
| BDR-015 | 2026-05-12 | Exclude broken gstack symlinks from /darwin-skill scope (external ownership) | accepted | | BDR-015 | 2026-05-12 | Exclude broken gstack symlinks from /darwin-skill scope (external ownership) | accepted |
| BDR-016 | 2026-05-15 | doc-syncer: README AUTO+unconditional, DEPLOY.md prod-only + 14-section VPS template | accepted | | BDR-016 | 2026-05-15 | doc-syncer: README AUTO+unconditional, DEPLOY.md prod-only + 14-section VPS template | accepted |
| BDR-017 | 2026-05-18 | `full` profile = web-full + plan + dev superset for /init-project MVP | accepted |
| BDR-018 | 2026-06-02 | `profile gstack on/off` verb — toggle gstack keeping active-profile label | accepted |
--- ---
@ -321,3 +323,17 @@ rules:
- `full` excludes a few rarely-used gstack skills (devex-review, pair-agent, gstack-upgrade, skills-perso). `set full` will disable those; user can `apply <profile>` after to add back. - `full` excludes a few rarely-used gstack skills (devex-review, pair-agent, gstack-upgrade, skills-perso). `set full` will disable those; user can `apply <profile>` after to add back.
- Sentinel rename "full" → "none" is breaking for any tooling that grepped `cmd_current` output for literal "full". No known consumers in this repo. - Sentinel rename "full" → "none" is breaking for any tooling that grepped `cmd_current` output for literal "full". No known consumers in this repo.
- **Reference**: commit message references `lib/profiles/full.profile` (new), `lib/profile.sh:421` sentinel, `skills/profile/SKILL.md` table row. Linked to [[profile-sentinel-collision]] (LRN-020). - **Reference**: commit message references `lib/profiles/full.profile` (new), `lib/profile.sh:421` sentinel, `skills/profile/SKILL.md` table row. Linked to [[profile-sentinel-collision]] (LRN-020).
---
## BDR-018 — `profile gstack on|off` verb keeps active-profile label
- **Date**: 2026-06-02
- **Status**: accepted
- **Decision**: New `cmd_gstack()` in `lib/profile.sh`. `gstack on` = re-enable all parked gstack (move `skills-disabled/gstack__*` back), DON'T touch `.active-profile`. `gstack off` = disable gstack skills not in active profile (errors if active=none). Wired into `main()` dispatch + `usage()` + header block + `skills/profile/SKILL.md` (argument-hint + examples + output-policy).
- **Why**: User wanted central command for "enable all gstack" + "disable gstack not needed by profile". Both ops existed (`reset`, `set`) but `reset` clobbers `.active-profile` to "none" — loses profile context in statusline. New verb does same skill-toggle WITHOUT clearing label, so user layers full gstack on top of current profile (e.g. `dev`) and statusline still reads `dev`.
- **Alternatives rejected**:
- 3 new profiles (current+gstack, current+gsd, current+gsd+gstack) — rejected: `gsd` = standalone CLI (not profile-toggleable, always-on, 0 passive token), so 2 of 3 meaningless. `full` already = current+gstack+gsd advisory. `apply` already additive.
- Just document `reset`/`set` — rejected: user wanted clearer centralized verb + label preservation.
- **Impl note**: extracted 3 shared helpers (`enable_all_gstack`, `disable_gstack_not_in`, `parked_gstack_count`); `cmd_reset`+`cmd_set` refactored to reuse (behavior preserved exact, verified by test). See [[dry-helper-extract-sibling-command]] (LRN-024).
- **Reference**: `lib/profile.sh` cmd_gstack + helpers, `skills/profile/SKILL.md`. Linked to [[full-profile-superset-init-project]] (BDR-017), [[gstack-source-only-skills-unlinked]] (BLK-007).

View File

@ -22,6 +22,7 @@ rules:
| ID | Date | Output | Action | | ID | Date | Output | Action |
|----|------|--------|--------| |----|------|--------|--------|
| EVAL-001 | 2026-04-23 | `.claude/` restructure plan (ship-feature STEP 2) | keep | | EVAL-001 | 2026-04-23 | `.claude/` restructure plan (ship-feature STEP 2) | keep |
| EVAL-002 | 2026-06-02 | `profile gstack on/off` verb implementation | keep |
--- ---
@ -31,4 +32,14 @@ rules:
- **Output**: 21-task plan migrate `tasks/` to `.claude/tasks/` + create `.claude/memory/` + `.claude/audits/` + integrate CAPITALIZE across 5 skills + add `/close` skill. - **Output**: 21-task plan migrate `tasks/` to `.claude/tasks/` + create `.claude/memory/` + `.claude/audits/` + integrate CAPITALIZE across 5 skills + add `/close` skill.
- **Method**: manual review of 5 impacted skills/agents; verified `rtk` path-agnostic; confirmed `~/.claude/CLAUDE.md` symlinks to project (single file edit). Radical-honesty check on session-close ritual: confirmed aspirational without skill integration → scope expanded to Option D. - **Method**: manual review of 5 impacted skills/agents; verified `rtk` path-agnostic; confirmed `~/.claude/CLAUDE.md` symlinks to project (single file edit). Radical-honesty check on session-close ritual: confirmed aspirational without skill integration → scope expanded to Option D.
- **Anomalies**: none blocking. Note: `tasks/LESSONS.md` empty (101B, header only) — migration to `learnings.md` symbolic. - **Anomalies**: none blocking. Note: `tasks/LESSONS.md` empty (101B, header only) — migration to `learnings.md` symbolic.
- **Action**: keep — plan validated, ready for execution. - **Action**: keep — plan validated, ready for execution.
---
## EVAL-002 — `profile gstack on|off` verb implementation
- **Date**: 2026-06-02
- **Output**: `cmd_gstack()` + 3 extracted helpers in `lib/profile.sh`; `cmd_reset`/`cmd_set` refactored to reuse; `skills/profile/SKILL.md` doc updated.
- **Method**: shellcheck 0.10.0 (CLEAN) + `bash -n`; 6-case live test (help; bad-action exit 1; `off` with active=none → exit 1 zero-mutation; `on` restores 14 + label `full` preserved NOT cleared; `off` trim; `on` cycle) with saved manifest + final assertion final-state == original (PASS, live env untouched).
- **Anomalies**: (1) Initial flag "full.profile omits ios/spec = bug" WRONG — full curated by design, confirmed by BDR-017 caveat. Self-corrected BEFORE any edit, no bad change shipped. Lesson: verify profile INTENT vs source completeness before calling omission a bug. (2) Surfaced real source-only gap → BLK-007 (open).
- **Action**: keep — verb works, tested, documented; false bug-flag caught pre-edit.

View File

@ -129,3 +129,11 @@ rules:
- `/hotfix` follow-up — `bash "$HOME/.claude/lib/profile.sh" current` falsely reported `none (all gstack skills enabled — no profile set)` even with profile applied + 14 gstack__* entries in repo's `skills-disabled/`. Root cause: `lib/profile.sh:43` used `cd "$(dirname $BASH_SOURCE)/.."` — default bash `cd` preserves symlinks, so `$REPO` resolved to `/home/bchanot-ubuntu/.claude` (symlink dir) instead of real repo path. `$DISABLED_DIR` then pointed at near-empty `~/.claude/skills-disabled/` (2 stale npx symlinks only). Fixed by adding `-P` to `cd` (commit `a4558ee`). `cmd_current` now correctly reports `full (100% match, 14 gstack skills disabled)`. - `/hotfix` follow-up — `bash "$HOME/.claude/lib/profile.sh" current` falsely reported `none (all gstack skills enabled — no profile set)` even with profile applied + 14 gstack__* entries in repo's `skills-disabled/`. Root cause: `lib/profile.sh:43` used `cd "$(dirname $BASH_SOURCE)/.."` — default bash `cd` preserves symlinks, so `$REPO` resolved to `/home/bchanot-ubuntu/.claude` (symlink dir) instead of real repo path. `$DISABLED_DIR` then pointed at near-empty `~/.claude/skills-disabled/` (2 stale npx symlinks only). Fixed by adding `-P` to `cd` (commit `a4558ee`). `cmd_current` now correctly reports `full (100% match, 14 gstack skills disabled)`.
- BLK-006 capitalized — `cmd_current` false-negative when invoked via `~/.claude/lib/profile.sh` symlink; status: resolved. - BLK-006 capitalized — `cmd_current` false-negative when invoked via `~/.claude/lib/profile.sh` symlink; status: resolved.
- LRN-023 capitalized — `$REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` mandatory pattern for any script meant to be invoked via a symlink into the install location. - LRN-023 capitalized — `$REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` mandatory pattern for any script meant to be invoked via a symlink into the install location.
## 2026-06-02
- Added `profile gstack on|off` verb to `lib/profile.sh`. `on` = re-enable all parked gstack keeping `.active-profile` label intact (vs `reset` which clears to "none"); `off` = disable gstack not in active profile (errors if none). User wanted centralized toggle without losing profile context.
- Extracted 3 helpers (`enable_all_gstack`/`disable_gstack_not_in`/`parked_gstack_count`); refactored `cmd_reset`+`cmd_set` to reuse — behavior preserved, 6-case test + exact state-restore assertion PASS, shellcheck CLEAN. Doc: SKILL.md argument-hint + examples + output-policy. Makefile generic `make profile cmd="gstack on"` already covers it.
- Corrected own false flag: full.profile omitting ios-*/spec is curation by design (BDR-017 caveat), NOT a bug — caught before any edit. Surfaced real gap: 6 gstack source skills unlinked post-submodule-bump → BLK-007 (open, gstack ./setup domain, not auto-fixed).
- Backfilled index drift: decisions (BDR-017), blockers (BLK-005/006).
- BDR-018 + LRN-024 + BLK-007 + EVAL-002 capitalized.

View File

@ -40,6 +40,7 @@ rules:
| LRN-017 | 2026-05-12 | Thin-dispatcher SKILL.md round-1 win = fallback + frontmatter triggers (+15 to +30) | any `/darwin-skill` round-1 on a dispatcher SKILL.md | | LRN-017 | 2026-05-12 | Thin-dispatcher SKILL.md round-1 win = fallback + frontmatter triggers (+15 to +30) | any `/darwin-skill` round-1 on a dispatcher SKILL.md |
| LRN-018 | 2026-05-12 | Darwin eval subagents drift on total math — recompute in main thread | any subagent-driven SKILL.md rescore | | LRN-018 | 2026-05-12 | Darwin eval subagents drift on total math — recompute in main thread | any subagent-driven SKILL.md rescore |
| LRN-019 | 2026-05-15 | Deployable-project doc split: README dev-quickstart + DEPLOY 14-section prod-VPS topology | any onboard/doc-syncer/scaffold producing docs for a deployable project | | LRN-019 | 2026-05-15 | Deployable-project doc split: README dev-quickstart + DEPLOY 14-section prod-VPS topology | any onboard/doc-syncer/scaffold producing docs for a deployable project |
| LRN-024 | 2026-06-02 | New sibling command sharing logic → extract helper + refactor existing caller, never copy-paste; assert pre/post state equality | adding a subcommand/branch reusing logic inline in a peer command |
--- ---
@ -359,3 +360,14 @@ rules:
- Lint via: `grep -n 'cd "$(dirname "${BASH_SOURCE' <script>` — every match should also contain `cd -P` (or be followed by an explicit `realpath` call). - Lint via: `grep -n 'cd "$(dirname "${BASH_SOURCE' <script>` — every match should also contain `cd -P` (or be followed by an explicit `realpath` call).
- **Cost when missed**: state lands in two parallel directories. Reads from one, writes from the other. False-negative status reports. Worst case: silent data loss when one dir is cleaned by a tool that thinks the other is canonical. - **Cost when missed**: state lands in two parallel directories. Reads from one, writes from the other. False-negative status reports. Worst case: silent data loss when one dir is cleaned by a tool that thinks the other is canonical.
- **Reference**: BLK-006, commit `a4558ee`. Linked to [[gstack-rename-profile-audit]] (LRN-022) — both bugs surfaced from the same `/profile set full` invocation, but root causes are independent. - **Reference**: BLK-006, commit `a4558ee`. Linked to [[gstack-rename-profile-audit]] (LRN-022) — both bugs surfaced from the same `/profile set full` invocation, but root causes are independent.
---
## LRN-024 — New sibling command sharing logic → extract helper + refactor caller, never copy-paste
- **Date**: 2026-06-02
- **Pattern**: New `gstack on|off` needed same skill-toggle loops already inline in `cmd_reset` (enable-all-parked) + `cmd_set` (disable-not-in-profile). Copy-paste = divergence risk (gstack__ prefix logic, mktemp keep-file). Instead extracted `enable_all_gstack()` + `disable_gstack_not_in()` + `parked_gstack_count()`; refactored `cmd_reset`/`cmd_set` to call them, then added `cmd_gstack` as 3rd caller. Behavior preserved exact (code MOVED not changed).
- **Why matters**: CLAUDE.md "more elegant solution exists?" — slight scope expansion (touch existing fns) beats duplication. Risk contained by test: snapshot original symlink state → run on/off cycle → re-park exact original → assert final == original. PASS, live env untouched.
- **Key trick**: when mutating shared resource (symlinks, files, db), verify refactor by asserting `final_state == original_state` after a round-trip, not just "command exited 0".
- **Applies to**: any new subcommand/branch reusing logic inline in a peer command — extract first, refactor existing caller, then add new caller. shellcheck after.
- **Reference**: BDR-018, `lib/profile.sh` enable_all_gstack/disable_gstack_not_in/parked_gstack_count. Linked to [[gstack-on-off-verb]] (BDR-018).

View File

@ -1,5 +1,18 @@
# TODO # TODO
## profile.sh — verbe `gstack on|off`
- [x] Extraire helper `enable_all_gstack()` (boucle de cmd_reset) — anti-duplication
- [x] Extraire helper `disable_gstack_not_in(prof)` (boucle gstack de cmd_set) — anti-duplication
- [x] Extraire helper `parked_gstack_count()` (réutilise pattern cmd_current)
- [x] Refactor cmd_reset + cmd_set pour utiliser les helpers (comportement préservé)
- [x] `cmd_gstack()` : `on` = enable tout gstack (garde label active-profile), `off` = disable gstack hors profil actif
- [x] Wire main() dispatch `gstack)` + usage() + bloc header
- [x] Doc : SKILL.md argument-hint + exemples + output-policy (Makefile générique suffit)
- [x] shellcheck propre + tests (help/bad-action/none-error/on/off cycle) — état live restauré exact
- [x] Investigué "fix" full.profile : PAS un bug — curation par design (BDR-017 caveat). Aucun fix code.
- [ ] FOLLOW-UP (BLK-007) : 6 skills gstack source (ios-*, spec) unlinkés post-bump → re-run gstack ./setup + reconcilier profils (iOS dans profils web ? probablement non)
- [x] Capitalize : BDR-018, LRN-024, BLK-007, EVAL-002, journal 2026-06-02 + backfill index (BDR-017, BLK-005/006)
## README.md overhaul ## README.md overhaul
- [x] Plan - [x] Plan
- [x] Corriger section install ctx7 (retirer MCP, clarifier CLI + API key) - [x] Corriger section install ctx7 (retirer MCP, clarifier CLI + API key)

View File

@ -26,6 +26,7 @@
# profile.sh apply <name> enable items in profile (additive) # profile.sh apply <name> enable items in profile (additive)
# profile.sh set <name> enable only profile (disables rest) # profile.sh set <name> enable only profile (disables rest)
# profile.sh reset re-enable all gstack skills + managed plugins # profile.sh reset re-enable all gstack skills + managed plugins
# profile.sh gstack on|off toggle gstack, keeping active-profile label
# profile.sh diff <a> <b> compare two profiles # profile.sh diff <a> <b> compare two profiles
# #
# Profile file format (lib/profiles/<name>.profile): # Profile file format (lib/profiles/<name>.profile):
@ -321,6 +322,43 @@ disable_skill() {
esac 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 ' '
}
# ── Commands ────────────────────────────────────────────── # ── Commands ──────────────────────────────────────────────
cmd_list() { cmd_list() {
@ -367,28 +405,14 @@ cmd_set() {
local prof="$1" local prof="$1"
info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins)" info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins)"
# Index of items in profile (skill names + plugin keys "name@marketplace").
local keep_file
keep_file="$(mktemp)"
# Skill names (col 1) — used to keep gstack skills.
read_profile "$prof" | cut -f1 | sort -u > "$keep_file"
# Plugin keys "name@marketplace" — used to keep managed plugins.
local plugin_keep_file
plugin_keep_file="$(mktemp)"
read_profile "$prof" | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' | sort -u > "$plugin_keep_file"
# Disable gstack-origin skills not in profile. # Disable gstack-origin skills not in profile.
local name disable_gstack_not_in "$prof"
while read -r name; do
[ -n "$name" ] || continue
if ! grep -qx "$name" "$keep_file"; then
disable_skill "$name" gstack
fi
done < <(gstack_skills | sort -u)
# Disable managed plugins not in profile (PROTECTED_PLUGINS are excluded # Disable managed plugins not in profile (PROTECTED_PLUGINS are excluded
# by disable_skill itself — belt and suspenders). # by disable_skill itself — belt and suspenders).
local p key plugin_name marketplace local plugin_keep_file p plugin_name marketplace
plugin_keep_file="$(mktemp)"
read_profile "$prof" | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' | sort -u > "$plugin_keep_file"
for p in "${MANAGED_PLUGINS[@]}"; do for p in "${MANAGED_PLUGINS[@]}"; do
if ! grep -qx "$p" "$plugin_keep_file"; then if ! grep -qx "$p" "$plugin_keep_file"; then
plugin_name="${p%@*}" plugin_name="${p%@*}"
@ -396,29 +420,67 @@ cmd_set() {
disable_skill "$plugin_name" "plugin@${marketplace}" disable_skill "$plugin_name" "plugin@${marketplace}"
fi fi
done done
rm -f "$plugin_keep_file"
rm -f "$keep_file" "$plugin_keep_file"
# Enable everything listed in the profile. # Enable everything listed in the profile.
cmd_apply "$prof" cmd_apply "$prof"
} }
cmd_reset() { cmd_reset() {
info "Re-enabling all gstack skills (move skills-disabled/gstack__* back)" info "Re-enabling all gstack skills (move skills-disabled/gstack__* back)"
local entry name enable_all_gstack
if [ -d "$DISABLED_DIR" ]; then
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
fi
info "Plugin state NOT touched. To re-enable a managed plugin disabled by 'set'," info "Plugin state NOT touched. To re-enable a managed plugin disabled by 'set',"
info "run: claude plugin enable <name>@<marketplace> (or: profile apply <profile>)" info "run: claude plugin enable <name>@<marketplace> (or: profile apply <profile>)"
write_active "none" 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 <name> 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() { cmd_current() {
# A profile is "active" only if (a) most of its skills are enabled AND # 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 # (b) at least one non-listed gstack skill is currently disabled (i.e. a
@ -491,6 +553,7 @@ USAGE:
profile apply <name> enable skills in profile (additive) profile apply <name> enable skills in profile (additive)
profile set <name> enable only listed skills (disables rest of gstack) profile set <name> enable only listed skills (disables rest of gstack)
profile reset re-enable all gstack skills profile reset re-enable all gstack skills
profile gstack on|off toggle gstack only, keep active-profile label
profile diff <a> <b> compare two profiles profile diff <a> <b> compare two profiles
PROFILES (in $PROFILES_DIR): PROFILES (in $PROFILES_DIR):
@ -527,6 +590,7 @@ main() {
apply) [ $# -ge 2 ] || { usage; exit 1; }; cmd_apply "$2" ;; apply) [ $# -ge 2 ] || { usage; exit 1; }; cmd_apply "$2" ;;
set) [ $# -ge 2 ] || { usage; exit 1; }; cmd_set "$2" ;; set) [ $# -ge 2 ] || { usage; exit 1; }; cmd_set "$2" ;;
reset) cmd_reset ;; reset) cmd_reset ;;
gstack) cmd_gstack "${2:-}" ;;
diff) [ $# -ge 3 ] || { usage; exit 1; }; cmd_diff "$2" "$3" ;; diff) [ $# -ge 3 ] || { usage; exit 1; }; cmd_diff "$2" "$3" ;;
""|-h|--help|help) usage ;; ""|-h|--help|help) usage ;;
*) err "Unknown command: $cmd"; usage; exit 1 ;; *) err "Unknown command: $cmd"; usage; exit 1 ;;

View File

@ -8,7 +8,7 @@ description: |
"switch to design", "set profile", "active profile", "quel profil", "switch to design", "set profile", "active profile", "quel profil",
"profil design", "active les skills design", "désactive gstack", "profil design", "active les skills design", "désactive gstack",
"réduire le bruit gstack". "réduire le bruit gstack".
argument-hint: list | show <name> | current | apply <name> | set <name> | reset | diff <a> <b> argument-hint: list | show <name> | current | apply <name> | set <name> | reset | gstack on|off | diff <a> <b>
disable-model-invocation: false disable-model-invocation: false
allowed-tools: allowed-tools:
- Bash - Bash
@ -81,9 +81,13 @@ bash "$HOME/.claude/lib/profile.sh" apply <name>
# Enable only skills in profile (disables non-listed gstack skills) # Enable only skills in profile (disables non-listed gstack skills)
bash "$HOME/.claude/lib/profile.sh" set <name> bash "$HOME/.claude/lib/profile.sh" set <name>
# Re-enable every gstack skill (undo any set/apply) # Re-enable every gstack skill (undo any set/apply) — resets active label to "none"
bash "$HOME/.claude/lib/profile.sh" reset bash "$HOME/.claude/lib/profile.sh" reset
# Toggle gstack only, keeping the active-profile label intact
bash "$HOME/.claude/lib/profile.sh" gstack on # re-enable ALL gstack on top of current profile
bash "$HOME/.claude/lib/profile.sh" gstack off # disable gstack skills not in the active profile
# Compare two profiles # Compare two profiles
bash "$HOME/.claude/lib/profile.sh" diff <a> <b> bash "$HOME/.claude/lib/profile.sh" diff <a> <b>
``` ```
@ -101,9 +105,9 @@ bash "$HOME/.claude/lib/profile.sh" $ARGUMENTS
## Output policy ## Output policy
- After `set` / `apply` / `reset`: show the count of skills moved + tell the - After `set` / `apply` / `reset` / `gstack on|off`: show the count of skills
user to start a new Claude session to pick up the changes (Claude scans moved + tell the user to start a new Claude session to pick up the changes
`skills/` at session start). (Claude scans `skills/` at session start).
- After `current`: report the active profile + match percentage. - After `current`: report the active profile + match percentage.
- After `show`: render the table directly — no extra commentary unless the user - After `show`: render the table directly — no extra commentary unless the user
asks. asks.