Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
100 changes: 100 additions & 0 deletions src/claude_code_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys
from datetime import datetime
from pathlib import Path
from typing import List

from .batch import (
extract_variables,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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