From 9877331747f8e76248b59449acf1b8cefd9669ec Mon Sep 17 00:00:00 2001 From: Matthew Tibbits Date: Mon, 16 Mar 2026 02:44:14 +0000 Subject: [PATCH] feat: add `cleanup` CLI subcommand for backlog artifact purge Adds `claude-queue cleanup [--dry-run]` to remove rate-limit artifacts from ~/.claude/. Identifies rate-limited sessions by scanning debug transcripts for 'rate_limit_error', then deletes correlated JSONL, todo, and telemetry files by UUID. No claude binary needed (E3 pattern). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matthew Tibbits --- CLAUDE.md | 1 + src/claude_code_queue/cli.py | 100 +++++++++++++++++++++++++++++++ tests/test_cli.py | 112 +++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c154483..76dba7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,5 +181,6 @@ retry_not_before: null | `test` | Verify claude CLI | Yes | | `bank save/list/use/delete` | Template bank ops | No | | `batch generate/validate/variables` | Batch job generation | No | +| `cleanup [--dry-run]` | Remove rate-limit artifacts from ~/.claude/ | No | | `install-skill [--force]` | Copy SKILL.md to ~/.claude/skills/ | No | | `prompt-box` | Launch Rust TUI | No (needs Rust binary) | diff --git a/src/claude_code_queue/cli.py b/src/claude_code_queue/cli.py index 489fe42..3420b91 100644 --- a/src/claude_code_queue/cli.py +++ b/src/claude_code_queue/cli.py @@ -12,6 +12,7 @@ import sys from datetime import datetime from pathlib import Path +from typing import List from .batch import ( extract_variables, @@ -241,6 +242,15 @@ def main(): "--force", action="store_true", help="Overwrite existing skill file" ) + # Cleanup subcommand + cleanup_parser = subparsers.add_parser( + "cleanup", help="Remove rate-limit artifacts from ~/.claude/" + ) + cleanup_parser.add_argument( + "--dry-run", action="store_true", + help="Report what would be deleted without acting", + ) + # Prompt box subcommand prompt_box_parser = subparsers.add_parser( "prompt-box", help="Launch the interactive prompt box CLI", add_help=False @@ -279,6 +289,8 @@ def main(): return cmd_batch(args) elif args.command == "install-skill": return cmd_install_skill(args) + elif args.command == "cleanup": + return cmd_cleanup(args) elif args.command == "prompt-box": return cmd_prompt_box(args) else: @@ -721,6 +733,94 @@ def cmd_install_skill(args) -> int: return 0 +def cmd_cleanup(args) -> int: + """Remove rate-limit artifacts from ~/.claude/. + + Primary identification: scan debug transcripts for 'rate_limit_error' in + the content (authoritative signal). Then delete correlated JSONL, todo, + and telemetry files by UUID. + + This is the E3 pattern: no claude binary needed. + """ + claude_dir = Path.home() / ".claude" + dry_run = args.dry_run + matched = 0 + skipped = 0 + rate_limited_uuids: List[str] = [] + + # 1. Debug transcripts — primary identification via content grep. + # Read the full file (max ~90 KB for successful runs) since this is a + # one-time tool where correctness matters more than speed. + debug_dir = claude_dir / "debug" + if debug_dir.is_dir(): + for debug_file in debug_dir.glob("*.txt"): + try: + with open(debug_file, "r", errors="replace") as fh: + content = fh.read() + if "rate_limit_error" in content: + rate_limited_uuids.append(debug_file.stem) + if dry_run: + print(f" [dry-run] would delete {debug_file}") + else: + debug_file.unlink() + matched += 1 + except OSError: + skipped += 1 + + if rate_limited_uuids: + print(f"Identified {len(rate_limited_uuids)} rate-limited session(s)") + + # 2. JSONL conversation logs — by UUID correlation + projects_dir = claude_dir / "projects" + if projects_dir.is_dir(): + for session_uuid in rate_limited_uuids: + for jsonl_file in projects_dir.glob(f"*/{session_uuid}.jsonl"): + try: + if dry_run: + print(f" [dry-run] would delete {jsonl_file}") + else: + jsonl_file.unlink() + matched += 1 + except OSError: + skipped += 1 + + # 3. Todo stubs — by UUID correlation + 2-byte size guard + todos_dir = claude_dir / "todos" + if todos_dir.is_dir(): + for session_uuid in rate_limited_uuids: + todo_file = todos_dir / f"{session_uuid}-agent-{session_uuid}.json" + try: + st = todo_file.stat() + if st.st_size <= 2: + if dry_run: + print(f" [dry-run] would delete {todo_file}") + else: + todo_file.unlink() + matched += 1 + except OSError: + skipped += 1 + + # 4. Telemetry — by UUID correlation + telemetry_dir = claude_dir / "telemetry" + if telemetry_dir.is_dir(): + for session_uuid in rate_limited_uuids: + for f in telemetry_dir.glob(f"1p_failed_events.{session_uuid}.*.json"): + try: + if dry_run: + print(f" [dry-run] would delete {f}") + else: + f.unlink() + matched += 1 + except OSError: + skipped += 1 + + action = "Would delete" if dry_run else "Deleted" + print(f"{action} {matched} rate-limit artifact(s)") + if skipped: + print(f"Skipped {skipped} file(s) due to errors") + return 0 + + def cmd_prompt_box(args) -> int: """Launch the interactive prompt box CLI.""" try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a6cd06..8a5c941 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1202,3 +1202,115 @@ def test_batch_variables_template_not_found(self, tmp_path, capsys): def test_batch_variables_returns_0(self, tmp_path): code = self._run(tmp_path, "---\npriority: 0\n---\n\nProcess {{item}}") assert code == 0 + + +# =========================================================================== +# Cleanup Command +# =========================================================================== + +class TestCleanup: + """Tests for `claude-queue cleanup [--dry-run]`.""" + + def _make_artifacts(self, tmp_path, session_uuid="aaa-bbb-ccc"): + """Create fake rate-limit artifacts under tmp_path/.claude/.""" + claude_dir = tmp_path / ".claude" + debug_dir = claude_dir / "debug" + projects_dir = claude_dir / "projects" / "-home-testuser-project" + todos_dir = claude_dir / "todos" + telemetry_dir = claude_dir / "telemetry" + for d in (debug_dir, projects_dir, todos_dir, telemetry_dir): + d.mkdir(parents=True) + + # Debug file with rate_limit_error content + debug_file = debug_dir / f"{session_uuid}.txt" + debug_file.write_text("startup\nrate_limit_error\n") + + # Correlated JSONL + jsonl_file = projects_dir / f"{session_uuid}.jsonl" + jsonl_file.write_bytes(b"x" * 5000) + + # Correlated todo stub + todo_file = todos_dir / f"{session_uuid}-agent-{session_uuid}.json" + todo_file.write_text("[]") + + # Correlated telemetry file + telemetry_file = telemetry_dir / f"1p_failed_events.{session_uuid}.other-uuid.json" + telemetry_file.write_text('{"events": []}') + + return debug_file, jsonl_file, todo_file, telemetry_file + + def test_cleanup_dry_run_does_not_delete(self, tmp_path, capsys): + debug_file, jsonl_file, todo_file, telemetry_file = self._make_artifacts(tmp_path) + + with patch("sys.argv", ["claude-queue", "cleanup", "--dry-run"]): + with patch("pathlib.Path.home", return_value=tmp_path): + code = main() + + assert code == 0 + assert debug_file.exists(), "dry-run must not delete files" + assert jsonl_file.exists() + assert todo_file.exists() + assert telemetry_file.exists() + out = capsys.readouterr().out + assert "Would delete" in out + assert "dry-run" in out + + def test_cleanup_deletes_artifacts(self, tmp_path, capsys): + debug_file, jsonl_file, todo_file, telemetry_file = self._make_artifacts(tmp_path) + + with patch("sys.argv", ["claude-queue", "cleanup"]): + with patch("pathlib.Path.home", return_value=tmp_path): + code = main() + + assert code == 0 + assert not debug_file.exists() + assert not jsonl_file.exists() + assert not todo_file.exists() + assert not telemetry_file.exists() + out = capsys.readouterr().out + assert "Deleted 4 rate-limit artifact(s)" in out + + def test_cleanup_preserves_non_rate_limited_debug(self, tmp_path, capsys): + """Debug files without rate_limit_error are not deleted.""" + claude_dir = tmp_path / ".claude" + debug_dir = claude_dir / "debug" + debug_dir.mkdir(parents=True) + + good_file = debug_dir / "good-session.txt" + good_file.write_text("startup\nall good\nstream completed\n") + + with patch("sys.argv", ["claude-queue", "cleanup"]): + with patch("pathlib.Path.home", return_value=tmp_path): + code = main() + + assert code == 0 + assert good_file.exists() + assert "Deleted 0" in capsys.readouterr().out + + def test_cleanup_preserves_real_todo_file(self, tmp_path, capsys): + """Todo files > 2 bytes are preserved even if UUID matches a rate-limited session.""" + debug_file, jsonl_file, todo_file, telemetry_file = self._make_artifacts(tmp_path) + # Overwrite the stub with realistic todo content (> 2 bytes) + todo_file.write_text('[{"task": "implement feature", "status": "in_progress"}]') + + with patch("sys.argv", ["claude-queue", "cleanup"]): + with patch("pathlib.Path.home", return_value=tmp_path): + code = main() + + assert code == 0 + assert todo_file.exists(), "real todo file (> 2 bytes) must be preserved" + assert not debug_file.exists() + assert not jsonl_file.exists() + # 3 deleted: debug + jsonl + telemetry (todo preserved by size guard) + assert "Deleted 3" in capsys.readouterr().out + + def test_cleanup_handles_empty_claude_dir(self, tmp_path, capsys): + """Cleanup succeeds when ~/.claude/ has no artifact directories.""" + (tmp_path / ".claude").mkdir() + + with patch("sys.argv", ["claude-queue", "cleanup"]): + with patch("pathlib.Path.home", return_value=tmp_path): + code = main() + + assert code == 0 + assert "Deleted 0" in capsys.readouterr().out