diff --git a/plugins/README.md b/plugins/README.md index cf4a21ecc5..0ff14930b9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -25,6 +25,7 @@ Learn more in the [official plugins documentation](https://docs.claude.com/en/do | [pr-review-toolkit](./pr-review-toolkit/) | Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification | **Command:** `/pr-review-toolkit:review-pr` - Run with optional review aspects (comments, tests, errors, types, code, simplify, all)
**Agents:** `comment-analyzer`, `pr-test-analyzer`, `silent-failure-hunter`, `type-design-analyzer`, `code-reviewer`, `code-simplifier` | | [ralph-wiggum](./ralph-wiggum/) | Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly until completion | **Commands:** `/ralph-loop`, `/cancel-ralph` - Start/stop autonomous iteration loops
**Hook:** Stop - Intercepts exit attempts to continue iteration | | [security-guidance](./security-guidance/) | Security reminder hook that warns about potential security issues when editing files | **Hook:** PreToolUse - Monitors 9 security patterns including command injection, XSS, eval usage, dangerous HTML, pickle deserialization, and os.system calls | +| [stash](./stash/) | Git-stash-like message storage with push, pop, apply, and list operations persisted to disk | **Commands:** `/stash`, `/stash-pop`, `/stash-apply`, `/stash-list` - Multi-slot message stash stack | ## Installation diff --git a/plugins/stash/.claude-plugin/plugin.json b/plugins/stash/.claude-plugin/plugin.json new file mode 100644 index 0000000000..a868bcf3a4 --- /dev/null +++ b/plugins/stash/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "stash", + "description": "Git-stash-like message storage with push, pop, apply, and list operations. Persists messages to disk in a JSONL stack so they survive crashes and session changes.", + "version": "1.0.0", + "author": { + "name": "Leonardo Cardoso", + "url": "https://github.com/LeonardoCardoso" + } +} diff --git a/plugins/stash/README.md b/plugins/stash/README.md new file mode 100644 index 0000000000..1cec0921d0 --- /dev/null +++ b/plugins/stash/README.md @@ -0,0 +1,116 @@ +# Stash Plugin + +Git-stash-like message storage for Claude Code. Push, pop, apply, and list messages persisted to disk so they survive crashes and session changes. + +Addresses [#26615](https://github.com/anthropics/claude-code/issues/26615) — the built-in Ctrl+S stash is single-slot and not persisted to disk. + +## Commands + +### `/stash ` + +Save a message to the stash stack. + +```bash +/stash Refactor the auth module to use JWT tokens instead of session cookies. Start with the middleware in src/auth/... +``` + +Output: +``` +Message stashed. +#0 Refactor the auth module to use JWT tokens instead of session cookies. Start with the middleware in src/auth/... +``` + +### `/stash-pop` + +Apply and remove the **last** stashed message. + +```bash +/stash-pop +``` + +Output: +``` +#2 2026-02-27T14:30:00Z + +Refactor the auth module to use JWT tokens... +``` + +### `/stash-apply ` + +Apply and remove a **specific** stashed message by ID. + +```bash +/stash-apply 0 +``` + +Output: +``` +#0 2026-02-27T12:00:00Z + +Add error handling to the payment service... +``` + +### `/stash-list` + +List all stashed messages (truncated to 80 characters). + +```bash +/stash-list +``` + +Output: +``` +#0 2026-02-27T12:00:00Z Add error handling to the payment service when Stripe returns a 429... +#1 2026-02-27T13:15:00Z Write integration tests for the new search endpoint covering edge ca... +#2 2026-02-27T14:30:00Z Refactor the auth module to use JWT tokens instead of session cookie... + +3 stashed message(s) +``` + +## Storage + +Messages are stored in `~/.claude/stash.jsonl` — one JSON object per line: + +```json +{"id": 0, "message": "Add error handling to the payment service...", "timestamp": "2026-02-27T12:00:00Z"} +{"id": 1, "message": "Write integration tests for the new search endpoint...", "timestamp": "2026-02-27T13:15:00Z"} +``` + +The file is human-readable and can be edited manually if needed. + +## Why This Exists + +The built-in Ctrl+S stash has several limitations: +- **Single slot** — stashing again overwrites the previous stash +- **Not persisted to disk** — lost on crashes, errors, or session changes +- **No history** — no way to browse or select from multiple stashes + +This plugin provides a proper stack with push/pop/apply semantics, persisted to a JSONL file that survives any disruption. + +## Requirements + +- Python 3 (pre-installed on macOS and most Linux distributions) + +## Installation + +Install via the Claude Code plugin command: + +```bash +claude plugin add --path ./stash +``` + +Or add to your project/user settings: + +```json +{ + "plugins": ["./path/to/stash"] +} +``` + +## Version + +1.0.0 + +## Author + +Leonardo Cardoso — [github.com/LeonardoCardoso](https://github.com/LeonardoCardoso) diff --git a/plugins/stash/commands/stash-apply.md b/plugins/stash/commands/stash-apply.md new file mode 100644 index 0000000000..593ac282bc --- /dev/null +++ b/plugins/stash/commands/stash-apply.md @@ -0,0 +1,51 @@ +--- +allowed-tools: Bash(python3:*) +description: Apply a stashed message by ID (and remove it) +--- + +## Your task + +Apply and remove a specific message from `~/.claude/stash.jsonl` by its ID. The ID is `$ARGUMENTS`. + +If `$ARGUMENTS` is empty, reply with: `Usage: /stash-apply ` and stop. + +Run a single bash command: + +```bash +python3 -c " +import json, os, sys + +stash_path = os.path.expanduser('~/.claude/stash.jsonl') +target_id = int(sys.argv[1]) + +if not os.path.exists(stash_path): + print('Stash is empty.') + sys.exit(0) + +entries = [] +found = None +with open(stash_path, 'r') as f: + for line in f: + line = line.strip() + if line: + entry = json.loads(line) + if entry['id'] == target_id: + found = entry + else: + entries.append(entry) + +if not found: + print(f'Stash #{target_id} not found.') + sys.exit(0) + +with open(stash_path, 'w') as f: + for e in entries: + f.write(json.dumps(e) + '\n') + +print(f'#{found[\"id\"]} {found[\"timestamp\"]}') +print() +print(found['message']) +" "$ARGUMENTS" +``` + +Print only the output of that command. Do not send any other text. diff --git a/plugins/stash/commands/stash-list.md b/plugins/stash/commands/stash-list.md new file mode 100644 index 0000000000..b8ea360120 --- /dev/null +++ b/plugins/stash/commands/stash-list.md @@ -0,0 +1,43 @@ +--- +allowed-tools: Bash(python3:*) +description: List all stashed messages +--- + +## Your task + +List all messages in `~/.claude/stash.jsonl`. + +Run a single bash command: + +```bash +python3 -c " +import json, os + +stash_path = os.path.expanduser('~/.claude/stash.jsonl') + +if not os.path.exists(stash_path): + print('Stash is empty.') + raise SystemExit(0) + +entries = [] +with open(stash_path, 'r') as f: + for line in f: + line = line.strip() + if line: + entries.append(json.loads(line)) + +if not entries: + print('Stash is empty.') + raise SystemExit(0) + +for e in entries: + msg = e['message'] + preview = (msg[:77] + '...') if len(msg) > 80 else msg + preview = preview.replace('\n', ' ') + print(f'#{e[\"id\"]} {e[\"timestamp\"]} {preview}') + +print(f'\n{len(entries)} stashed message(s)') +" +``` + +Print only the output of that command. Do not send any other text. diff --git a/plugins/stash/commands/stash-pop.md b/plugins/stash/commands/stash-pop.md new file mode 100644 index 0000000000..ac4fd5deb8 --- /dev/null +++ b/plugins/stash/commands/stash-pop.md @@ -0,0 +1,45 @@ +--- +allowed-tools: Bash(python3:*) +description: Pop the last stashed message (apply and remove) +--- + +## Your task + +Pop the last message from `~/.claude/stash.jsonl` — print it and remove it from the file. + +Run a single bash command: + +```bash +python3 -c " +import json, os, sys + +stash_path = os.path.expanduser('~/.claude/stash.jsonl') + +if not os.path.exists(stash_path): + print('Stash is empty.') + sys.exit(0) + +entries = [] +with open(stash_path, 'r') as f: + for line in f: + line = line.strip() + if line: + entries.append(json.loads(line)) + +if not entries: + print('Stash is empty.') + sys.exit(0) + +last = entries.pop() + +with open(stash_path, 'w') as f: + for e in entries: + f.write(json.dumps(e) + '\n') + +print(f'#{last[\"id\"]} {last[\"timestamp\"]}') +print() +print(last['message']) +" +``` + +Print only the output of that command. Do not send any other text. diff --git a/plugins/stash/commands/stash.md b/plugins/stash/commands/stash.md new file mode 100644 index 0000000000..d5298d26ed --- /dev/null +++ b/plugins/stash/commands/stash.md @@ -0,0 +1,64 @@ +--- +allowed-tools: Bash(mkdir:*), Bash(python3:*) +description: Stash a message for later use +--- + +## Your task + +Stash the user's message to `~/.claude/stash.jsonl`. The message is everything in `$ARGUMENTS`. + +If `$ARGUMENTS` is empty, reply with: `Usage: /stash ` and stop. + +Run a single bash command to do the following atomically: + +1. Create `~/.claude/` if it doesn't exist +2. Read the current max ID from the stash file (or start at 0 if the file doesn't exist) +3. Append a new JSON line with `{"id": , "message": "", "timestamp": ""}` +4. Print: `Message stashed. #` + +Use `python3` for this: + +```bash +python3 -c " +import json, os, sys +from datetime import datetime, timezone + +stash_path = os.path.expanduser('~/.claude/stash.jsonl') +os.makedirs(os.path.dirname(stash_path), exist_ok=True) + +max_id = -1 +if os.path.exists(stash_path): + with open(stash_path, 'r') as f: + for line in f: + line = line.strip() + if line: + entry = json.loads(line) + max_id = max(max_id, entry['id']) + +new_id = max_id + 1 +entry = { + 'id': new_id, + 'message': sys.argv[1], + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') +} + +with open(stash_path, 'a') as f: + f.write(json.dumps(entry) + '\n') + +msg = sys.argv[1] +if len(msg) > 100: + # Find the nearest word boundary around 100 chars + cut = msg.rfind(' ', 0, 110) + if cut <= 50: + cut = 100 + preview = msg[:cut] + '...' +else: + preview = msg +preview = preview.replace('\n', ' ') + +print('Message stashed.') +print(f'#{new_id} {preview}') +" "$ARGUMENTS" +``` + +Print only the output of that command. Do not send any other text.