mirror of
https://github.com/kochetkov-ma/claude-brewcode.git
synced 2026-09-14 20:16:41 +08:00
v3.8.0: skillOverrides migration + grepai alwaysLoad + teardown --full + hooks.json exec form
This commit is contained in:
@@ -6,13 +6,13 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Claude Code plugin suite: brewcode for infinite task execution, brewdoc for documentation tools, brewtools for text utilities, brewui for UI/visual/creative tools",
|
||||
"version": "3.7.19"
|
||||
"version": "3.8.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "brewcode",
|
||||
"description": "Brewcode - full-featured development platform for Claude Code: infinite focus tasks, prompt optimization, skill/agent creation, quorum reviews, rules management",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"category": "productivity",
|
||||
"keywords": [
|
||||
"brewcode",
|
||||
@@ -46,7 +46,7 @@
|
||||
{
|
||||
"name": "brewdoc",
|
||||
"description": "Brewdoc - Claude Code documentation tools: auto-sync for skills/agents/rules, my-claude installation docs, memory optimization, md-to-pdf conversion",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"category": "productivity",
|
||||
"keywords": [
|
||||
"brewdoc",
|
||||
@@ -74,7 +74,7 @@
|
||||
{
|
||||
"name": "brewtools",
|
||||
"description": "Brewtools - universal utilities for Claude Code: text optimization, humanization, secrets scanning",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"category": "productivity",
|
||||
"keywords": [
|
||||
"brewtools",
|
||||
@@ -102,7 +102,7 @@
|
||||
{
|
||||
"name": "brewui",
|
||||
"description": "Brewui - UI/visual/creative tools for Claude Code: AI image generation, design-to-code conversion",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"category": "productivity",
|
||||
"keywords": [
|
||||
"brewui",
|
||||
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
---
|
||||
|
||||
## v3.8.0 (2026-05-12)
|
||||
|
||||
> Docs: [brewtools/skills/skill-toggle](https://doc-claude.brewcode.app/brewtools/skills/skill-toggle/) | [brewcode/skills/grepai](https://doc-claude.brewcode.app/brewcode/skills/grepai/) | [brewcode/skills/teardown](https://doc-claude.brewcode.app/brewcode/skills/teardown/)
|
||||
|
||||
### All plugins
|
||||
|
||||
#### Fixed
|
||||
- **hooks.json exec form (CRITICAL):** all four plugins (`brewcode`, `brewdoc`, `brewtools`, `brewui`) had `hooks.json` entries using the broken dual `command`+`args` form which Claude Code 2.1.139 silently ignored. Converted every entry to the proper exec form: `"command":"node","args":["${CLAUDE_PLUGIN_ROOT}/hooks/<file>.mjs"]`. Restores SessionStart, PreToolUse, PostToolUse, PreCompact, Stop hook dispatch across the suite.
|
||||
|
||||
### brewtools
|
||||
|
||||
#### Added
|
||||
- **skill-toggle settings.json migration (CC 2.1.115+):** skill enable/disable now uses the official `~/.claude/settings.json` `skillOverrides` mechanism instead of file-rename (`SKILL.md` ⇄ `_SKILL.md`). Atomic writes (`tmp + fsync + rename`), lock-file identity check (PID + start-time), stale-lock recovery on crashed sessions. Survives plugin updates without filesystem mutation.
|
||||
- **reapply-disables embedded migrator:** one-shot SessionStart migrator detects legacy `toggle-state.json` skill entries and rewrites them into `skillOverrides`. Writes pre-migration backup `toggle-state.json.bak.pre-migration-<TS>` BEFORE any deletion. Idempotent — no-op on subsequent sessions.
|
||||
|
||||
#### Changed
|
||||
- **skill-toggle helpers:** new `helpers/` directory consolidating settings.json read/write, lock management, and migration probes.
|
||||
|
||||
### brewcode
|
||||
|
||||
#### Added
|
||||
- **grepai `alwaysLoad:true`:** `mcp-check.sh` now sets `alwaysLoad:true` on the grepai MCP server via CLI-first / JSON-patch fallback (handles both `claude mcp` CLI presence and direct settings.json editing). Ensures grepai is hot at session-start without manual `/mcp` invocation.
|
||||
- **teardown `--full` mode (CC 2.1.115+):** `claude project purge` integration with inline `PURGE` confirmation prompt, Claude Code version probe (`claude --version`), and partial-recoverability backup. Removes plugin data + project state in one atomic flow when user explicitly opts in.
|
||||
|
||||
#### Changed
|
||||
- **teardown SKILL.md:** documents `--full` flag, version-gate behavior, and recovery path.
|
||||
|
||||
#### Migration & Rollback
|
||||
- **Auto-migration:** On first SessionStart after upgrade, brewtools `reapply-disables` hook migrates legacy `toggle-state.json` skill entries to `~/.claude/settings.json` `skillOverrides`. A backup is written to `toggle-state.json.bak.pre-migration-<TS>` BEFORE deletion.
|
||||
- **Downgrade to v3.7.x:** `skillOverrides` in settings.json is NOT read by v3.7.x. Restore the pre-migration backup: `cp toggle-state.json.bak.pre-migration-<TS> toggle-state.json` (path: `~/.claude/plugins/data/brewtools-claude-brewcode/` or scope-equivalent). Then `claude plugin update brewtools@claude-brewcode --version 3.7.19`.
|
||||
- **agent-toggle unaffected** — agent state still uses file-rename approach.
|
||||
|
||||
---
|
||||
|
||||
## v3.7.19 (2026-05-12)
|
||||
|
||||
> Docs: [brewtools/skills/think-short](https://doc-claude.brewcode.app/brewtools/skills/think-short/) | [brewtools/plugin](https://doc-claude.brewcode.app/brewtools/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "brewcode",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"description": "Brewcode - full-featured development platform for Claude Code: infinite focus tasks, prompt optimization, skill/agent creation, quorum reviews, rules management",
|
||||
"author": {
|
||||
"name": "Maksim Kochetkov",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Version | 3.7.19 |
|
||||
| Version | 3.8.0 |
|
||||
| Skills | 15 |
|
||||
| Agents | 12+ |
|
||||
| Hooks | 8 |
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/forced-eval.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/forced-eval.mjs"],
|
||||
"timeout": 1000
|
||||
}
|
||||
]
|
||||
@@ -16,7 +17,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"],
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
@@ -25,7 +27,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/grepai-session.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/grepai-session.mjs"],
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -37,7 +40,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs"],
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -47,7 +51,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/grepai-reminder.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/grepai-reminder.mjs"],
|
||||
"timeout": 1000
|
||||
}
|
||||
]
|
||||
@@ -59,7 +64,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-task.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/post-task.mjs"],
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -70,7 +76,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs"],
|
||||
"timeout": 60000
|
||||
}
|
||||
]
|
||||
@@ -82,7 +89,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/permission-guard.sh\"",
|
||||
"command": "bash",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/permission-guard.sh"],
|
||||
"timeout": 1000
|
||||
}
|
||||
]
|
||||
@@ -93,7 +101,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs"],
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-plugin-brewcode",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"description": "Infinite task execution with automatic handoff for Claude Code",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -36,6 +36,6 @@
|
||||
},
|
||||
"claude-plugin": {
|
||||
"name": "brewcode",
|
||||
"version": "3.7.19"
|
||||
"version": "3.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,113 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# grepai MCP Configuration Check
|
||||
# Ensures grepai MCP is registered AND alwaysLoad=true (CC 2.1.115+)
|
||||
# Falls back to JSON patching if --always-load flag unsupported.
|
||||
|
||||
echo "=== MCP Check ==="
|
||||
|
||||
CLAUDE_JSON="$HOME/.claude.json"
|
||||
TMP_FILE=""
|
||||
TMP_FILE2=""
|
||||
|
||||
# Single trap for all temp files (variables can be empty/unset - rm -f is safe)
|
||||
trap 'rm -f "${TMP_FILE:-}" "${TMP_FILE2:-}"' EXIT
|
||||
|
||||
# Helper: check if alwaysLoad already true for grepai
|
||||
already_always_load() {
|
||||
command -v jq &>/dev/null || return 1
|
||||
[ -f "$CLAUDE_JSON" ] || return 1
|
||||
jq -e '.mcpServers.grepai.alwaysLoad == true' "$CLAUDE_JSON" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Helper: detect --always-load flag support
|
||||
supports_always_load() {
|
||||
claude mcp add --help 2>&1 | grep -q -- '--always-load'
|
||||
}
|
||||
|
||||
# Helper: backup ~/.claude.json once per invocation if we are about to mutate
|
||||
backup_claude_json() {
|
||||
[ -f "$CLAUDE_JSON" ] || return 0
|
||||
local bak
|
||||
bak="$CLAUDE_JSON.bak.$(date +%s)"
|
||||
cp "$CLAUDE_JSON" "$bak" && echo " Backup: $bak"
|
||||
}
|
||||
|
||||
# Phase 1: Check/Add MCP Server
|
||||
echo ""
|
||||
echo "--- MCP Server ---"
|
||||
if grep -q '"grepai"' ~/.claude.json 2>/dev/null; then
|
||||
GREPAI_PRESENT=0
|
||||
if [ -f "$CLAUDE_JSON" ] && grep -q '"grepai"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||
GREPAI_PRESENT=1
|
||||
echo "✅ MCP grepai: already configured"
|
||||
else
|
||||
echo "⚠️ MCP grepai: not configured"
|
||||
echo " Adding via claude CLI..."
|
||||
if claude mcp add --scope user grepai -- grepai mcp-serve; then
|
||||
echo "✅ MCP grepai: added"
|
||||
if supports_always_load; then
|
||||
if claude mcp add grepai --always-load --transport stdio --scope user -- grepai mcp-serve; then
|
||||
echo "✅ MCP grepai: added (alwaysLoad via CLI)"
|
||||
GREPAI_PRESENT=1
|
||||
else
|
||||
echo "❌ MCP grepai: failed to add"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ MCP grepai: failed to add"
|
||||
exit 1
|
||||
if claude mcp add --scope user grepai -- grepai mcp-serve; then
|
||||
echo "✅ MCP grepai: added (legacy CLI, will patch alwaysLoad)"
|
||||
GREPAI_PRESENT=1
|
||||
else
|
||||
echo "❌ MCP grepai: failed to add"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Phase 1b: Ensure alwaysLoad=true for grepai
|
||||
echo ""
|
||||
echo "--- alwaysLoad Flag ---"
|
||||
if [ "$GREPAI_PRESENT" -eq 1 ]; then
|
||||
if already_always_load; then
|
||||
echo "✅ alwaysLoad: already true (no changes)"
|
||||
else
|
||||
# Try CLI re-registration with --always-load first (idempotent in newer CC)
|
||||
PATCHED=0
|
||||
if supports_always_load; then
|
||||
# `claude mcp add` may refuse to overwrite; try and if it fails, fall through to JSON patch
|
||||
if claude mcp add grepai --always-load --transport stdio --scope user -- grepai mcp-serve 2>/dev/null; then
|
||||
if already_always_load; then
|
||||
echo "✅ alwaysLoad: set via CLI re-register"
|
||||
PATCHED=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PATCHED" -eq 0 ]; then
|
||||
# Fallback: JSON patch
|
||||
if [ ! -f "$CLAUDE_JSON" ]; then
|
||||
echo "❌ $CLAUDE_JSON does not exist; cannot patch"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "❌ jq required for fallback patch"
|
||||
exit 1
|
||||
fi
|
||||
backup_claude_json
|
||||
TMP_FILE=$(mktemp)
|
||||
jq '.mcpServers.grepai.alwaysLoad = true' "$CLAUDE_JSON" > "$TMP_FILE" && mv "$TMP_FILE" "$CLAUDE_JSON"
|
||||
# Validate JSON
|
||||
jq empty "$CLAUDE_JSON" >/dev/null 2>&1 || { echo "❌ Invalid JSON after patch"; exit 1; }
|
||||
if already_always_load; then
|
||||
echo "✅ alwaysLoad: set via JSON patch"
|
||||
else
|
||||
echo "❌ alwaysLoad: patch did not take effect"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "⏭️ alwaysLoad: skipped (grepai not present)"
|
||||
fi
|
||||
|
||||
# Phase 2: Check/Add allowedTools (prevents [destructive] permission prompts)
|
||||
echo ""
|
||||
echo "--- Allowed Tools ---"
|
||||
@@ -42,9 +130,8 @@ else
|
||||
# Use jq if available, otherwise use python
|
||||
if command -v jq &>/dev/null; then
|
||||
# jq approach
|
||||
TMP_FILE=$(mktemp)
|
||||
trap 'rm -f "$TMP_FILE"' EXIT
|
||||
jq '.allowedTools = ((.allowedTools // []) + ["mcp__grepai__*"] | unique)' "$SETTINGS_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$SETTINGS_FILE"
|
||||
TMP_FILE2=$(mktemp)
|
||||
jq '.allowedTools = ((.allowedTools // []) + ["mcp__grepai__*"] | unique)' "$SETTINGS_FILE" > "$TMP_FILE2" && mv "$TMP_FILE2" "$SETTINGS_FILE"
|
||||
jq . "$SETTINGS_FILE" >/dev/null 2>&1 || { echo "❌ Invalid JSON"; exit 1; }
|
||||
elif command -v python3 &>/dev/null; then
|
||||
# python approach
|
||||
@@ -79,6 +166,7 @@ fi
|
||||
echo ""
|
||||
echo "=== MCP Check Complete ==="
|
||||
echo "✅ MCP server: configured"
|
||||
echo "✅ alwaysLoad: enabled (no ToolSearch preflight needed)"
|
||||
echo "✅ Permissions: auto-allowed (no prompts)"
|
||||
echo ""
|
||||
echo "ℹ️ Restart Claude Code to apply MCP changes"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: brewcode:teardown
|
||||
description: Removes brewcode project files (templates, configs, logs).
|
||||
description: Removes brewcode project files (templates, configs, logs). Optional --full mode runs `claude project purge` to wipe ALL project state after typed PURGE confirmation.
|
||||
disable-model-invocation: true
|
||||
argument-hint: "[--dry-run]"
|
||||
argument-hint: "[--dry-run] [--full]"
|
||||
allowed-tools: Bash, Read, AskUserQuestion
|
||||
context: fork
|
||||
model: haiku
|
||||
@@ -14,14 +14,77 @@ model: haiku
|
||||
|
||||
**Skill arguments received:** `$ARGUMENTS`
|
||||
|
||||
**If NOT `--dry-run`:** Use AskUserQuestion to confirm before executing:
|
||||
### Argument Parsing
|
||||
|
||||
Detect flags in `$ARGUMENTS`:
|
||||
- `--dry-run` present -> dry-run mode (no deletion)
|
||||
- `--full` present -> FULL PURGE mode (see below)
|
||||
- Neither -> default teardown
|
||||
|
||||
> If `--full` is NOT present, behavior is identical to previous versions. Skip to "Default Teardown".
|
||||
|
||||
### Full Purge Mode (`--full`)
|
||||
|
||||
> WARNING: DESTRUCTIVE (PARTIALLY recoverable). `claude project purge` (Claude Code 2.1.115+) wipes ALL per-project state including `.claude/`, project memory, and session data. The automatic backup captures ONLY the project `.claude/` directory — Claude Code session history and auto-memory under `~/.claude/projects/<projectHash>/` are NOT backed up and CANNOT be recovered after purge. You must restore the `.claude/` backup MANUALLY.
|
||||
|
||||
**Step 1 — Typed confirmation (INLINE PROMPT, NOT AskUserQuestion).**
|
||||
|
||||
Output literally this prompt to the user as plain text in the conversation:
|
||||
|
||||
> ⚠️ DESTRUCTIVE — `claude project purge` will wipe ALL per-project state including `.claude/`, project memory, and session data. A backup will be created at `.claude.backup.<timestamp>/` BUT it captures ONLY the project `.claude/` directory. Claude Code session history and auto-memory under `~/.claude/projects/<projectHash>/` are NOT in the backup and are NOT recoverable. You must restore the `.claude/` backup MANUALLY.
|
||||
>
|
||||
> Reply with the exact word **PURGE** (uppercase, no surrounding text) to proceed. Anything else aborts.
|
||||
|
||||
Then STOP and wait for the user's next message. Inspect that message:
|
||||
- If the trimmed message equals exactly `PURGE` (case-sensitive) -> proceed to Step 2.
|
||||
- Otherwise -> output "Aborted — confirmation token mismatch. No changes made." and STOP.
|
||||
|
||||
Do NOT use AskUserQuestion (option-button form does not accept free-text typing).
|
||||
|
||||
**Step 2 — Version probe.** Verify `claude project purge` is supported on this Claude Code version BEFORE creating any backup.
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
claude project --help 2>&1 | grep -q '\bpurge\b' && echo "OK purge supported" || { echo "FAILED: claude project purge unsupported on this CC version. Aborting --full."; exit 1; }
|
||||
```
|
||||
|
||||
> **STOP if FAILED** — `--full` mode requires Claude Code 2.1.115+. Use default teardown instead.
|
||||
|
||||
**Step 3 — Backup `.claude/` BEFORE purge.**
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
TS=$(date +%s); BACKUP=".claude.backup.${TS}"; cp -r .claude "${BACKUP}" && echo "OK backup created: ${BACKUP}" || echo "FAILED backup — aborting, no purge run"
|
||||
```
|
||||
|
||||
> **STOP if FAILED** — do NOT run purge. Likely disk space or permission issue. Investigate and retry.
|
||||
|
||||
**Step 4 — Run `claude project purge`.**
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
claude project purge 2>&1 && echo "OK purge complete" || echo "FAILED purge command"
|
||||
```
|
||||
|
||||
**Step 5 — Report backup path and recovery scope** to user so manual restore is possible:
|
||||
```
|
||||
Backup location: .claude.backup.<TS>/ (project .claude/ ONLY)
|
||||
Restore manually with: rm -rf .claude && mv .claude.backup.<TS> .claude
|
||||
|
||||
NOT in backup, NOT recoverable: ~/.claude/projects/<projectHash>/
|
||||
(Claude Code session JSONL + auto-memory — wiped by `claude project purge`)
|
||||
```
|
||||
|
||||
### Default Teardown
|
||||
|
||||
**If NOT `--dry-run` and NOT `--full`:** Use AskUserQuestion to confirm before executing:
|
||||
> "This will delete brewcode project files (templates, configs, logs, plans). Task directories are preserved. Proceed?"
|
||||
|
||||
**EXECUTE** using Bash tool — run teardown script:
|
||||
```bash
|
||||
bash "${CLAUDE_SKILL_DIR}/scripts/teardown.sh" ARGS_HERE && echo "✅ done" || echo "❌ FAILED"
|
||||
```
|
||||
**IMPORTANT:** Replace `ARGS_HERE` with the actual value from "Skill arguments received" above. If empty, omit the argument.
|
||||
**IMPORTANT:** Replace `ARGS_HERE` with the actual value from "Skill arguments received" above. If empty, omit the argument. Strip `--full` from args before passing to teardown.sh (script does not know about it).
|
||||
|
||||
> **STOP if ❌** — check script path exists and teardown.sh has execute permissions.
|
||||
|
||||
@@ -30,11 +93,16 @@ bash "${CLAUDE_SKILL_DIR}/scripts/teardown.sh" ARGS_HERE && echo "✅ done" || e
|
||||
| Option | Behavior |
|
||||
|--------|----------|
|
||||
| `--dry-run` | List files to delete without removing them |
|
||||
| `--full` | DESTRUCTIVE (PARTIALLY recoverable — backup covers `.claude/` only, NOT `~/.claude/projects/<hash>/` session+memory): backup `.claude/` then run `claude project purge` after typed `PURGE` confirmation |
|
||||
| (none) | Full removal after user confirmation |
|
||||
|
||||
## Preserved
|
||||
|
||||
Task directories (`.claude/tasks/*_task/`) and user rules (`.claude/rules/`) are always preserved.
|
||||
Task directories (`.claude/tasks/*_task/`) and user rules (`.claude/rules/`) are always preserved in default mode. `--full` mode preserves NOTHING (except the backup directory).
|
||||
|
||||
## Warning — `--full` mode
|
||||
|
||||
> DESTRUCTIVE (PARTIALLY recoverable). Backup at `.claude.backup.<timestamp>/` captures ONLY the project `.claude/` directory. Claude Code session history and auto-memory under `~/.claude/projects/<projectHash>/` are ALSO wiped by `claude project purge` but are NOT in the backup and are NOT recoverable. Use only when you intend to fully reset the project.
|
||||
|
||||
</instructions>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "brewdoc",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"description": "Brewdoc - Claude Code documentation tools: auto-sync for skills/agents/rules, my-claude installation docs, memory optimization",
|
||||
"author": {
|
||||
"name": "Maksim Kochetkov",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs"],
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "brewtools",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"description": "Brewtools - universal utilities for Claude Code: text optimization, humanization, secrets scanning",
|
||||
"author": {
|
||||
"name": "Maksim Kochetkov",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs"],
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
@@ -17,7 +18,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"],
|
||||
"timeout": 2000
|
||||
}
|
||||
]
|
||||
@@ -26,7 +28,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/reapply-disables.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/reapply-disables.mjs"],
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readStdin, output, log } from './lib/utils.mjs';
|
||||
import { readState, writeStateAtomic, mergeStates, globalStatePath, projectStatePath } from '../skills/_shared/toggle/state.mjs';
|
||||
import { enumeratePlugins, resolveTarget } from '../skills/_shared/toggle/cache.mjs';
|
||||
@@ -14,9 +16,83 @@ async function main() {
|
||||
|
||||
const globalState = readState(globalStatePath());
|
||||
const projectState = readState(projectStatePath(cwd));
|
||||
|
||||
let dirtyProject = false;
|
||||
let dirtyGlobal = false;
|
||||
const migrated = [];
|
||||
|
||||
// One-shot migration (W2-T2): move legacy skill entries from toggle-state.json
|
||||
// into ~/.claude/settings.json skillOverrides. Agent entries stay in toggle-state.
|
||||
// Idempotent: after first run no skill entries remain, so this loop is a no-op.
|
||||
// Fast-path (v3.8.0): skip dynamic import on steady-state (no skill entries).
|
||||
const hasSkillEntries = (st) =>
|
||||
st && st.disabled && Object.values(st.disabled).some(e => e && e.kind === 'skill');
|
||||
const scopes = [
|
||||
{ st: projectState, isProject: true, label: 'project', path: projectStatePath(cwd) },
|
||||
{ st: globalState, isProject: false, label: 'global', path: globalStatePath() }
|
||||
];
|
||||
const needsMigration = scopes.some(s => hasSkillEntries(s.st));
|
||||
if (needsMigration) {
|
||||
try {
|
||||
const { writeOverride } = await import('../skills/skill-toggle/helpers/overrides.mjs');
|
||||
const TS = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
for (const scope of scopes) {
|
||||
// Pre-migration backup: snapshot original state file BEFORE any delete.
|
||||
// Skip backup on fast-path scopes (no skill entries — nothing to back up).
|
||||
// If copy fails, abort migration for THIS scope; continue with next.
|
||||
if (!hasSkillEntries(scope.st)) {
|
||||
// no skill entries in this scope — skip backup and migration
|
||||
continue;
|
||||
}
|
||||
const backupPath = `${path.dirname(scope.path)}/toggle-state.json.bak.pre-migration-${TS}`;
|
||||
try {
|
||||
fs.copyFileSync(scope.path, backupPath);
|
||||
log('info', '[reapply-disables]', `migration_backup scope=${scope.label} path=${backupPath}`, cwd, session_id);
|
||||
} catch (copyErr) {
|
||||
log('error', '[reapply-disables]', `migration_backup_failed scope=${scope.label} error=${copyErr.message} — aborting migration for this scope`, cwd, session_id);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(scope.st.disabled || {})) {
|
||||
const entry = scope.st.disabled[key];
|
||||
if (!entry || entry.kind !== 'skill') continue;
|
||||
try {
|
||||
await writeOverride(entry.plugin, entry.name, 'off');
|
||||
// Defense-in-depth: assert kind before delete in case a future
|
||||
// refactor moves the outer filter. Should never trigger today.
|
||||
if (entry.kind !== 'skill') continue;
|
||||
delete scope.st.disabled[key];
|
||||
migrated.push(key);
|
||||
if (scope.isProject) dirtyProject = true; else dirtyGlobal = true;
|
||||
log('info', '[reapply-disables]',
|
||||
`migrated kind=skill plugin=${entry.plugin} name=${entry.name} mode=off legacy_disabled_at=${entry.disabled_at || 'unknown'} source=${scope.label}`,
|
||||
cwd, session_id);
|
||||
} catch (e) {
|
||||
log('warn', '[reapply-disables]', `migrate failed ${key}: ${e.message}`, cwd, session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (migrated.length) {
|
||||
log('info', '[reapply-disables]', `migrated_count=${migrated.length}`, cwd, session_id);
|
||||
}
|
||||
} catch (e) {
|
||||
log('warn', '[reapply-disables]', `migration helper unavailable: ${e.message}`, cwd, session_id);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeStates(globalState, projectState);
|
||||
|
||||
if (!merged.disabled || Object.keys(merged.disabled).length === 0) {
|
||||
if (dirtyProject) writeStateAtomic(projectStatePath(cwd), projectState);
|
||||
if (dirtyGlobal) writeStateAtomic(globalStatePath(), globalState);
|
||||
if (migrated.length) {
|
||||
output({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: `[brewtools:toggle] migrated to skillOverrides: ${migrated.join(', ')}`
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
output({});
|
||||
return;
|
||||
}
|
||||
@@ -25,8 +101,6 @@ async function main() {
|
||||
const reapplied = [];
|
||||
const drift = [];
|
||||
let alreadyOk = 0;
|
||||
let dirtyProject = false;
|
||||
let dirtyGlobal = false;
|
||||
|
||||
for (const [key, entry] of Object.entries(merged.disabled)) {
|
||||
const p = plugins.get(entry.plugin);
|
||||
@@ -74,6 +148,7 @@ async function main() {
|
||||
if (dirtyGlobal) writeStateAtomic(globalStatePath(), globalState);
|
||||
|
||||
const lines = [];
|
||||
if (migrated.length) lines.push(`migrated→skillOverrides: ${migrated.join(', ')}`);
|
||||
if (reapplied.length) lines.push(`reapplied: ${reapplied.map(r => `${r.key}→${r.to}`).join(', ')}`);
|
||||
if (drift.length) lines.push(`drift: ${drift.map(d => `${d.key}(${d.reason})`).join(', ')}`);
|
||||
if (alreadyOk) lines.push(`active: ${alreadyOk}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: brewtools:skill-toggle
|
||||
description: "Disables or enables individual plugin skills by renaming SKILL.md and _SKILL.md in the plugin cache. Persistent state: global (default) or project. Triggers: disable skill, enable skill, toggle skill, hide skill, skill-toggle."
|
||||
argument-hint: "[disable|enable|status|list|reapply|prune] <plugin>:<name> [--scope=global|project]"
|
||||
description: "Disables or enables individual plugin skills by writing skillOverrides to ~/.claude/settings.json (survives plugin updates). Modes: off, user-invocable-only, name-only. Triggers: disable skill, enable skill, toggle skill, hide skill, skill-toggle."
|
||||
argument-hint: "[disable|enable|status|list] <plugin>:<name> [--mode=off|user-invocable-only|name-only]"
|
||||
allowed-tools: Read, Bash, AskUserQuestion
|
||||
model: sonnet
|
||||
user-invocable: true
|
||||
@@ -9,7 +9,7 @@ user-invocable: true
|
||||
|
||||
# Skill Toggle
|
||||
|
||||
> **Disable/enable individual plugin skills** by renaming `SKILL.md` ↔ `_SKILL.md` in the plugin cache. State persisted globally (default) or per-project. See also: `/brewtools:agent-toggle` for agents.
|
||||
> **Disable/enable individual plugin skills** via the stable `skillOverrides` mechanism in `~/.claude/settings.json` (Claude Code 2.1.115+). State survives plugin updates — **no SessionStart reapply hook needed**. See also: `/brewtools:agent-toggle` for agents (uses file-rename, different mechanism).
|
||||
|
||||
<instructions>
|
||||
|
||||
@@ -18,44 +18,56 @@ user-invocable: true
|
||||
| Rule | Applies |
|
||||
|------|---------|
|
||||
| Every Bash call ends with `&& echo "OK ..." \|\| echo "FAILED ..."` | ALL |
|
||||
| Never use `Write`/`Edit` on `~/.claude/plugins/*` — use Bash + Node `fs` | ALL |
|
||||
| This skill mutates **skills only** (`kind='skill'`) — never agents | ALL |
|
||||
| Kind is hardcoded — do NOT call `detectKind`; skill IS the kind | P1 |
|
||||
| Never use `Write`/`Edit` on `~/.claude/settings.json` — use Bash + Node helper | ALL |
|
||||
| This skill mutates **skills only** (`skillOverrides`) — never agents | ALL |
|
||||
| Atomic writes via lockfile + temp + rename (handled by helper) | P2 |
|
||||
|
||||
Paths (substitute literally in Bash):
|
||||
- Shared helpers: `$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/`
|
||||
- Global state: `$CLAUDE_PLUGIN_DATA/toggle-state.json` (fallback: `~/.claude/plugins/data/brewtools-claude-brewcode/toggle-state.json`)
|
||||
- Project state: `$PWD/.claude/brewtools/toggle-state.json`
|
||||
- Helper: `$CLAUDE_PLUGIN_ROOT/skills/skill-toggle/helpers/overrides.mjs`
|
||||
- Settings: `~/.claude/settings.json` (resolved to `$HOME/.claude/settings.json`)
|
||||
- Legacy global state (read-only, backwards visibility): `$CLAUDE_PLUGIN_DATA/toggle-state.json`
|
||||
|
||||
## Override Modes
|
||||
|
||||
| Mode | Effect |
|
||||
|------|--------|
|
||||
| `off` | Fully disabled — invisible to user and LLM |
|
||||
| `user-invocable-only` | Only via `/plugin:skill` slash; LLM cannot auto-invoke |
|
||||
| `name-only` | Name visible in autocomplete, instructions/body not loaded |
|
||||
| `on` | (Re-enable) — entry removed from settings.json |
|
||||
|
||||
Default for "disable" intent without explicit mode: `off`.
|
||||
|
||||
---
|
||||
|
||||
## Phase I — Interactive Flow (entry gate)
|
||||
|
||||
> **Full spec:** `_shared/toggle/interactive-flow.md` (phases I0-I4). Read it fully on entry. This section hardcodes `kind='skill'` — no `detectKind` call.
|
||||
> **Full spec:** `_shared/toggle/interactive-flow.md` (phases I0-I4). Read on entry. This skill hardcodes `kind='skill'`.
|
||||
|
||||
**Enter interactive flow when:**
|
||||
- No args given, OR
|
||||
- User prompt is freeform without a concrete `plugin:name` target (e.g. "отключи лишнее", "hide something noisy"), OR
|
||||
- User prompt is freeform without concrete `plugin:name` (e.g. "отключи лишнее"), OR
|
||||
- Parsed target missing from cache.
|
||||
|
||||
**Skip interactive (go straight to P0 → P1 → P2 → P3)** when op AND target are both explicit: `/brewtools:skill-toggle disable brewui:image-gen`.
|
||||
**Skip interactive (go straight to P0 → P2 → P4)** when op AND target both explicit: `/brewtools:skill-toggle disable brewui:image-gen`.
|
||||
|
||||
| Phase | Action | Tool |
|
||||
|-------|--------|------|
|
||||
| I0 | Decide branch from input shape | — |
|
||||
| I1 | Op picker — single `AskUserQuestion`, 4 options (`status`, `disable`, `enable`, `list`), pre-selected hint: `disable` | AskUserQuestion |
|
||||
| I2 | Catalog one-liner — Bash+Node imports `enumeratePlugins` from `$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/cache.mjs`, emit `AVAILABLE TO {OP} (N total, Ctrl+F to search):` header then single space-separated line of `plugin:name` tokens (filter: `disable`→enabled only, `enable`→disabled only, kind=skill). Free-text "Which one?" prompt | Bash |
|
||||
| I3 | Resolve + confirm once: exact `plugin:name` or unique `name`→no confirm; fuzzy→one AskUserQuestion `Disable X? [yes / pick different / cancel]`; multiple→AskUserQuestion 2-4 options | AskUserQuestion |
|
||||
| I4 | Execute (P1→P2→P3) then ALWAYS print current state (see format below) | Bash |
|
||||
| I1 | Op picker — single `AskUserQuestion`, options: `status`, `disable`, `enable`, `list`, pre-selected: `disable` | AskUserQuestion |
|
||||
| I2 | Catalog one-liner — Bash+Node imports `enumeratePlugins` from `$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/cache.mjs`, emit `AVAILABLE TO {OP} (N total):` then space-separated `plugin:name` tokens (filter: `disable`→not in overrides; `enable`→in overrides; kind=skill) | Bash |
|
||||
| I3 | Resolve + confirm: exact `plugin:name` or unique `name`→no confirm; fuzzy→AskUserQuestion `[yes / pick different / cancel]`; multiple→AskUserQuestion 2-4 options. For `disable`, also ask mode if not specified | AskUserQuestion |
|
||||
| I4 | Execute (P2) then ALWAYS print current state | Bash |
|
||||
|
||||
Terminal ops (`list`, `reapply`, `prune`, `status`) skip to I4 directly — no picker, no catalog.
|
||||
Terminal ops (`list`, `status`) skip to I4 directly.
|
||||
|
||||
**I4 status format (always printed):**
|
||||
```
|
||||
DISABLED RIGHT NOW
|
||||
-------------------
|
||||
brewui:image-gen [skill, global, since 2026-04-16]
|
||||
(none) ← if empty
|
||||
DISABLED RIGHT NOW (via skillOverrides)
|
||||
---------------------------------------
|
||||
brewui:image-gen off
|
||||
brewdoc:md-to-pdf user-invocable-only
|
||||
(none) <-- if empty
|
||||
ENABLED (M skills across P plugins)
|
||||
```
|
||||
|
||||
@@ -63,23 +75,24 @@ ENABLED (M skills across P plugins)
|
||||
|
||||
## P0: Parse Intent
|
||||
|
||||
Parse `$ARGUMENTS` (or the user's NL prompt) into structured form:
|
||||
Parse `$ARGUMENTS` (or NL prompt) into:
|
||||
|
||||
```
|
||||
{ op: disable|enable|status|list|reapply|prune, scope: global|project, targets: [{plugin, name}] }
|
||||
{ action: disable|enable|toggle|status|list, plugin?, skill?, mode?: off|user-invocable-only|name-only }
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Default scope = `global`. Override with `--scope=project` (or phrase "for this project").
|
||||
- Target format: `plugin:name` (e.g., `brewui:image-gen`). If only `name` given → ask AskUserQuestion which plugin.
|
||||
- `status`, `list`, `reapply`, `prune` take no targets.
|
||||
- Multiple targets allowed for `disable`/`enable` — iterate P2–P3 per target.
|
||||
- Default mode for `disable` = `off`. User can specify e.g. `--mode=user-invocable-only` or "make it slash-only".
|
||||
- Target format: `plugin:name` (e.g., `brewui:image-gen`). Bare `name` → AskUserQuestion which plugin.
|
||||
- `status`, `list` take no targets.
|
||||
- Multiple targets allowed for disable/enable — iterate P2 per target.
|
||||
- No `--scope` flag — `skillOverrides` is global-only (per Claude Code design).
|
||||
|
||||
If ambiguous (no plugin prefix for disable/enable) → AskUserQuestion with candidate plugins from P1 enumerate.
|
||||
If ambiguous (no plugin prefix for disable/enable) → AskUserQuestion with candidate plugins from `enumeratePlugins`.
|
||||
|
||||
---
|
||||
|
||||
## P1: Validate Target (per target)
|
||||
## P1: Validate Target
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
@@ -89,65 +102,64 @@ import fs from 'node:fs';
|
||||
const p = enumeratePlugins().get('PLUGIN_NAME');
|
||||
if (!p) { console.log(JSON.stringify({error:'plugin_not_installed', plugin:'PLUGIN_NAME'})); process.exit(0); }
|
||||
const t = resolveTarget(p, 'skill', 'SKILL_NAME');
|
||||
const visibleExists = fs.existsSync(t.visible);
|
||||
const hiddenExists = fs.existsSync(t.hidden);
|
||||
if (!visibleExists && !hiddenExists) { console.log(JSON.stringify({error:'skill_not_found', plugin:p.plugin, name:'SKILL_NAME'})); process.exit(0); }
|
||||
console.log(JSON.stringify({plugin:p.plugin, latest:p.latest, visible:t.visible, hidden:t.hidden, visibleExists, hiddenExists}));
|
||||
const exists = fs.existsSync(t.visible) || fs.existsSync(t.hidden);
|
||||
if (!exists) { console.log(JSON.stringify({error:'skill_not_found', plugin:p.plugin, name:'SKILL_NAME'})); process.exit(0); }
|
||||
console.log(JSON.stringify({plugin:p.plugin, latest:p.latest, name:'SKILL_NAME'}));
|
||||
" && echo "OK validate" || echo "FAILED validate"
|
||||
```
|
||||
|
||||
Replace `PLUGIN_NAME`, `SKILL_NAME`. On `error` → stop, report to user.
|
||||
Replace `PLUGIN_NAME`, `SKILL_NAME`. On `error` → stop, report.
|
||||
|
||||
---
|
||||
|
||||
## P2: Mutate State
|
||||
|
||||
**EXECUTE** using Bash tool (disable example):
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import {readState, writeStateAtomic, stateKey, globalStatePath, projectStatePath} from '$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/state.mjs';
|
||||
const fp = 'SCOPE'==='project' ? projectStatePath(process.cwd()) : globalStatePath();
|
||||
const st = readState(fp);
|
||||
const key = stateKey('PLUGIN','NAME');
|
||||
st.disabled[key] = {kind:'skill', plugin:'PLUGIN', name:'NAME', disabled_at:new Date().toISOString(), last_applied_version:'VERSION'};
|
||||
writeStateAtomic(fp, st);
|
||||
console.log(JSON.stringify({scope:'SCOPE', file:fp, key}));
|
||||
" && echo "OK state" || echo "FAILED state"
|
||||
```
|
||||
|
||||
For `enable` — replace body with `delete st.disabled[key]`.
|
||||
|
||||
---
|
||||
|
||||
## P3: Apply Rename
|
||||
## P2: Write Override
|
||||
|
||||
**EXECUTE** using Bash tool (disable):
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import {disableTarget} from '$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/apply.mjs';
|
||||
const r = disableTarget('VISIBLE_PATH','HIDDEN_PATH');
|
||||
import {writeOverride} from '$CLAUDE_PLUGIN_ROOT/skills/skill-toggle/helpers/overrides.mjs';
|
||||
const r = await writeOverride('PLUGIN','NAME','MODE');
|
||||
console.log(JSON.stringify(r));
|
||||
" && echo "OK apply" || echo "FAILED apply"
|
||||
" && echo "OK override" || echo "FAILED override"
|
||||
```
|
||||
|
||||
For `enable` — `enableTarget(visible, hidden)`. Substitute absolute paths from P1 output.
|
||||
For `disable` → `MODE` ∈ `off | user-invocable-only | name-only` (default `off`).
|
||||
For `enable` → `MODE='on'` (deletes the entry).
|
||||
|
||||
Status meanings: `disabled`, `enabled`, `already_disabled`, `already_enabled`, `missing`.
|
||||
Substitute `PLUGIN`, `NAME`, `MODE` literally.
|
||||
|
||||
---
|
||||
|
||||
## P4: Notify
|
||||
## P3 — DELETED (no rename step needed)
|
||||
|
||||
Render a result table and remind user to reload:
|
||||
> **Persistence:** `~/.claude/settings.json` survives plugin updates — **no SessionStart reapply hook needed for skill-toggle.** The old file-rename approach (P3 in earlier versions) is removed. Plugin cache files are no longer touched by this skill.
|
||||
|
||||
Agent-toggle still uses file-rename (`_shared/toggle/state.mjs` + `apply.mjs`) — that flow is untouched.
|
||||
|
||||
---
|
||||
|
||||
## P4: Verify + Notify
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import {readOverrides} from '$CLAUDE_PLUGIN_ROOT/skills/skill-toggle/helpers/overrides.mjs';
|
||||
const o = readOverrides();
|
||||
console.log(JSON.stringify(o));
|
||||
" && echo "OK verify" || echo "FAILED verify"
|
||||
```
|
||||
|
||||
Confirm `PLUGIN:NAME` present (for disable) or absent (for enable). Render result table:
|
||||
|
||||
```
|
||||
# Skill Toggle — <op>
|
||||
# Skill Toggle — <action>
|
||||
|
||||
| Plugin | Name | Kind | Scope | Action | Result |
|
||||
|--------|------|------|-------|--------|--------|
|
||||
| brewui | image-gen | skill | global | disable | disabled |
|
||||
| Plugin | Name | Mode | Action | File |
|
||||
|--------|------|------|--------|------|
|
||||
| brewui | image-gen | off | written | ~/.claude/settings.json |
|
||||
|
||||
> Run `/reload-plugins` (or restart session) for the change to take effect.
|
||||
> Restart session or `/reload-plugins` for the change to take effect.
|
||||
> Persisted to ~/.claude/settings.json — survives plugin updates.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -156,22 +168,27 @@ Render a result table and remind user to reload:
|
||||
|
||||
### status — merged view
|
||||
|
||||
Reads both `skillOverrides` (current mechanism) AND legacy `toggle-state.json` (backwards visibility, read-only).
|
||||
|
||||
**EXECUTE** using Bash tool:
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import {readState, mergeStates, globalStatePath, projectStatePath} from '$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/state.mjs';
|
||||
const g = readState(globalStatePath());
|
||||
const p = readState(projectStatePath(process.cwd()));
|
||||
const m = mergeStates(g, p);
|
||||
const rows = Object.entries(m.disabled).filter(([,v])=>v.kind==='skill').map(([k,v])=>({
|
||||
key:k, ...v,
|
||||
scope: p.disabled[k] ? 'project' : 'global'
|
||||
}));
|
||||
console.log(JSON.stringify({rows}));
|
||||
import {listOverrides} from '$CLAUDE_PLUGIN_ROOT/skills/skill-toggle/helpers/overrides.mjs';
|
||||
import {readState, globalStatePath, projectStatePath} from '$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/state.mjs';
|
||||
const overrides = listOverrides();
|
||||
const legacyG = readState(globalStatePath()).disabled || {};
|
||||
const legacyP = readState(projectStatePath(process.cwd())).disabled || {};
|
||||
const legacy = [];
|
||||
for (const [k,v] of Object.entries({...legacyG, ...legacyP})) {
|
||||
if (v && v.kind === 'skill') legacy.push({key:k, ...v, scope: legacyP[k] ? 'project' : 'global'});
|
||||
}
|
||||
console.log(JSON.stringify({overrides, legacy}));
|
||||
" && echo "OK status" || echo "FAILED status"
|
||||
```
|
||||
|
||||
Render as table: plugin | name | scope | disabled_at | last_applied_version.
|
||||
Render two tables:
|
||||
1. **Current (skillOverrides):** plugin | name | mode
|
||||
2. **Legacy (toggle-state.json, read-only):** plugin | name | scope | disabled_at — with note: "Legacy state — migrate by re-running disable; legacy file is no longer authoritative for skills."
|
||||
|
||||
### list — enumerate all skills
|
||||
|
||||
@@ -179,30 +196,24 @@ Render as table: plugin | name | scope | disabled_at | last_applied_version.
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import {enumeratePlugins, resolveTarget} from '$CLAUDE_PLUGIN_ROOT/skills/_shared/toggle/cache.mjs';
|
||||
import {readOverrides} from '$CLAUDE_PLUGIN_ROOT/skills/skill-toggle/helpers/overrides.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const overrides = readOverrides();
|
||||
const out = [];
|
||||
for (const [plugin, e] of enumeratePlugins()) {
|
||||
const dir = path.join(e.path, 'skills');
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(dir, {withFileTypes:true}).filter(d=>d.isDirectory() && !d.name.startsWith('_')); } catch {}
|
||||
for (const d of entries) {
|
||||
const t = resolveTarget(e, 'skill', d.name);
|
||||
out.push({plugin, name:d.name, version:e.latest, disabled: fs.existsSync(t.hidden) && !fs.existsSync(t.visible)});
|
||||
const key = plugin + ':' + d.name;
|
||||
out.push({plugin, name:d.name, version:e.latest, override: overrides[key] || null});
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
" && echo "OK list" || echo "FAILED list"
|
||||
```
|
||||
|
||||
Render grouped by plugin; mark disabled rows.
|
||||
|
||||
### reapply — re-run from state
|
||||
|
||||
Read merged state (as in `status`), filter `kind==='skill'`, for each entry call P1 + P3 (no P2 mutate). Report per-target results.
|
||||
|
||||
### prune — drop stale entries
|
||||
|
||||
For each state entry where plugin no longer in `enumeratePlugins()` → remove via `writeStateAtomic`. Report removed keys.
|
||||
Render grouped by plugin; mark rows with override mode.
|
||||
|
||||
</instructions>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// brewtools:skill-toggle — settings.json skillOverrides helper.
|
||||
// Writes survive plugin updates (Claude Code 2.1.115+).
|
||||
// Atomic: temp file + rename. Lockfile: O_CREAT|O_EXCL, 5 retries x 100ms.
|
||||
// Modes: 'off' | 'user-invocable-only' | 'name-only' | 'on' ('on' deletes entry).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const VALID_MODES = new Set(['off', 'user-invocable-only', 'name-only', 'on']);
|
||||
const DEFAULT_SETTINGS = '~/.claude/settings.json';
|
||||
|
||||
function resolveHome(p) {
|
||||
if (!p) return p;
|
||||
if (p === '~') return process.env.HOME || os.homedir();
|
||||
if (p.startsWith('~/')) return path.join(process.env.HOME || os.homedir(), p.slice(2));
|
||||
return p;
|
||||
}
|
||||
|
||||
function readJsonSafe(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// Lock identity hardening: lockfile contents = `${pid}:${randomHex16}`. acquireLock
|
||||
// returns the token; releaseLock verifies match before unlink.
|
||||
// Failure mode: if a writer stalls >60s another process may steal the lock; the
|
||||
// stalled writer detects this on release and aborts unlink. A's already-written
|
||||
// settings.json may have been overwritten by B — token check prevents A from
|
||||
// further clobbering B's lockfile.
|
||||
async function acquireLock(lockPath, { retries = 5, delayMs = 100 } = {}) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`;
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
||||
fs.writeSync(fd, token);
|
||||
fs.closeSync(fd);
|
||||
return token;
|
||||
} catch (e) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
// Stale lock detection: malformed PID, dead process, or age > 60s
|
||||
try {
|
||||
const lockContent = fs.readFileSync(lockPath, 'utf8').trim();
|
||||
const pidPart = lockContent.split(':')[0];
|
||||
const lockPid = parseInt(pidPart, 10);
|
||||
const lockStat = fs.statSync(lockPath);
|
||||
const ageMs = Date.now() - lockStat.mtimeMs;
|
||||
let stale = false;
|
||||
if (!Number.isFinite(lockPid) || lockPid <= 0) {
|
||||
stale = true;
|
||||
} else {
|
||||
try {
|
||||
process.kill(lockPid, 0);
|
||||
} catch (killErr) {
|
||||
if (killErr.code === 'ESRCH') stale = true;
|
||||
}
|
||||
}
|
||||
if (!stale && ageMs > 60_000) stale = true;
|
||||
if (stale) {
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
continue;
|
||||
}
|
||||
} catch (statErr) {
|
||||
// lockfile vanished between EEXIST and stat — race, retry
|
||||
}
|
||||
if (i === retries - 1) {
|
||||
throw new Error(`Could not acquire lock ${lockPath} after ${retries} attempts (${delayMs}ms each). Another process may be writing settings.json. Remove ${lockPath} manually if stale.`);
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function releaseLock(lockPath, token) {
|
||||
try {
|
||||
const current = fs.readFileSync(lockPath, 'utf8').trim();
|
||||
if (token && current !== token) {
|
||||
process.stderr.write(`[skill-toggle] lock stolen: expected token ${token}, found ${current} at ${lockPath}. Not unlinking.\n`);
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read skillOverrides from settings.json.
|
||||
* @param {string} settingsPath - path with optional ~ prefix
|
||||
* @returns {object} skillOverrides object, or {} if missing/unreadable
|
||||
*/
|
||||
export function readOverrides(settingsPath = DEFAULT_SETTINGS) {
|
||||
const fp = resolveHome(settingsPath);
|
||||
const settings = readJsonSafe(fp);
|
||||
const o = settings.skillOverrides;
|
||||
return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single skill override entry atomically.
|
||||
* @param {string} plugin - plugin name (e.g. 'brewui')
|
||||
* @param {string} name - skill name (e.g. 'image-gen')
|
||||
* @param {string} mode - 'off' | 'user-invocable-only' | 'name-only' | 'on'
|
||||
* @param {object} opts - { settingsPath, retries, delayMs }
|
||||
* @returns {object} { key, mode, action, file }
|
||||
*/
|
||||
export async function writeOverride(plugin, name, mode, opts = {}) {
|
||||
if (!plugin || typeof plugin !== 'string') throw new Error('plugin required');
|
||||
if (!name || typeof name !== 'string') throw new Error('name required');
|
||||
if (!VALID_MODES.has(mode)) throw new Error(`invalid mode '${mode}' — must be one of: ${[...VALID_MODES].join(', ')}`);
|
||||
|
||||
const settingsPath = resolveHome(opts.settingsPath || DEFAULT_SETTINGS);
|
||||
const lockPath = `${settingsPath}.lock`;
|
||||
const dir = path.dirname(settingsPath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const lockToken = await acquireLock(lockPath, { retries: opts.retries ?? 5, delayMs: opts.delayMs ?? 100 });
|
||||
try {
|
||||
const settings = readJsonSafe(settingsPath);
|
||||
const overrides = (settings.skillOverrides && typeof settings.skillOverrides === 'object' && !Array.isArray(settings.skillOverrides))
|
||||
? { ...settings.skillOverrides }
|
||||
: {};
|
||||
const key = `${plugin}:${name}`;
|
||||
let action;
|
||||
if (mode === 'on') {
|
||||
if (key in overrides) { delete overrides[key]; action = 'deleted'; }
|
||||
else action = 'noop';
|
||||
} else {
|
||||
overrides[key] = mode;
|
||||
action = 'written';
|
||||
}
|
||||
const merged = { ...settings, skillOverrides: overrides };
|
||||
if (Object.keys(overrides).length === 0) delete merged.skillOverrides;
|
||||
|
||||
const tmp = `${settingsPath}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmp); } catch {}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { key, mode, action, file: settingsPath };
|
||||
} finally {
|
||||
releaseLock(lockPath, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat list of all current overrides.
|
||||
* @param {string} settingsPath
|
||||
* @returns {Array<{plugin, name, mode}>}
|
||||
*/
|
||||
export function listOverrides(settingsPath = DEFAULT_SETTINGS) {
|
||||
const overrides = readOverrides(settingsPath);
|
||||
const out = [];
|
||||
for (const [key, mode] of Object.entries(overrides)) {
|
||||
const idx = key.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
out.push({ plugin: key.slice(0, idx), name: key.slice(idx + 1), mode });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "brewui",
|
||||
"version": "3.7.19",
|
||||
"version": "3.8.0",
|
||||
"description": "Brewui - UI/visual/creative tools for Claude Code: AI image generation, design-to-code conversion",
|
||||
"author": {
|
||||
"name": "Maksim Kochetkov",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/pre-task.mjs"],
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
@@ -17,7 +18,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"],
|
||||
"timeout": 2000
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user