cursor-clean 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """cursor-clean: scan and clean Cursor Roaming data on Windows."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from cursor_clean.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,147 @@
1
+ """Execute cleanup actions against Cursor Roaming data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sqlite3
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from cursor_clean.scanner import AgentVersionInfo, ChatCleanupPlan, ScanReport, _dir_size, _file_size
11
+
12
+
13
+ @dataclass
14
+ class CleanResult:
15
+ chat_deleted_composers: int = 0
16
+ chat_deleted_kv_rows: int = 0
17
+ state_db_before: int = 0
18
+ state_db_after: int = 0
19
+ agent_deleted: list[str] = field(default_factory=list)
20
+ agent_bytes_freed: int = 0
21
+ cache_bytes_freed: int = 0
22
+ errors: list[str] = field(default_factory=list)
23
+
24
+ @property
25
+ def state_db_bytes_freed(self) -> int:
26
+ return max(0, self.state_db_before - self.state_db_after)
27
+
28
+
29
+ def _open_rw(db_path: Path) -> sqlite3.Connection:
30
+ con = sqlite3.connect(str(db_path), timeout=60)
31
+ con.execute("PRAGMA journal_mode=WAL")
32
+ return con
33
+
34
+
35
+ def clean_old_chats(db_path: Path, plan: ChatCleanupPlan) -> tuple[int, int]:
36
+ """Delete old composers and related cursorDiskKV rows. Returns (composers, kv_rows)."""
37
+ if not plan.old_composer_ids:
38
+ return 0, 0
39
+
40
+ con = _open_rw(db_path)
41
+ try:
42
+ con.execute("CREATE TEMP TABLE old_c (id TEXT PRIMARY KEY)")
43
+ con.executemany(
44
+ "INSERT OR IGNORE INTO old_c(id) VALUES (?)",
45
+ [(i,) for i in plan.old_composer_ids],
46
+ )
47
+
48
+ before_kv = con.total_changes
49
+ con.execute(
50
+ """
51
+ DELETE FROM cursorDiskKV
52
+ WHERE
53
+ (key LIKE 'composerData:%'
54
+ AND substr(key, 14, 36) IN (SELECT id FROM old_c))
55
+ OR
56
+ (key LIKE 'bubbleId:%'
57
+ AND substr(key, 10, 36) IN (SELECT id FROM old_c))
58
+ OR
59
+ (key LIKE 'checkpointId:%'
60
+ AND substr(key, 14, 36) IN (SELECT id FROM old_c))
61
+ OR
62
+ (key LIKE 'messageRequestContext:%'
63
+ AND substr(key, 23, 36) IN (SELECT id FROM old_c))
64
+ """
65
+ )
66
+ kv_deleted = con.total_changes - before_kv
67
+
68
+ before_h = con.total_changes
69
+ con.execute("DELETE FROM composerHeaders WHERE composerId IN (SELECT id FROM old_c)")
70
+ headers_deleted = con.total_changes - before_h
71
+ con.commit()
72
+
73
+ # Shrink file; must be outside a transaction and with no other writers.
74
+ con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
75
+ con.execute("VACUUM")
76
+ return headers_deleted, kv_deleted
77
+ finally:
78
+ con.close()
79
+
80
+
81
+ def clean_agent_versions(versions: list[AgentVersionInfo]) -> tuple[list[str], int]:
82
+ """Delete agent version folders marked keep=False."""
83
+ deleted: list[str] = []
84
+ freed = 0
85
+ for item in versions:
86
+ if item.keep:
87
+ continue
88
+ size = item.size_bytes
89
+ shutil.rmtree(item.path, ignore_errors=False)
90
+ deleted.append(item.name)
91
+ freed += size
92
+ return deleted, freed
93
+
94
+
95
+ def clean_optional_dirs(paths: dict[str, Path], *, include_cache: bool) -> int:
96
+ """Optionally remove CachedData / logs contents. Backup is never touched."""
97
+ if not include_cache:
98
+ return 0
99
+ freed = 0
100
+ for key in ("cached_data", "logs"):
101
+ path = paths[key]
102
+ if not path.exists():
103
+ continue
104
+ size = _dir_size(path)
105
+ if path.is_dir():
106
+ shutil.rmtree(path, ignore_errors=False)
107
+ path.mkdir(parents=True, exist_ok=True)
108
+ freed += size
109
+ return freed
110
+
111
+
112
+ def clean(
113
+ report: ScanReport,
114
+ *,
115
+ clean_chats: bool = True,
116
+ clean_agents: bool = True,
117
+ include_cache: bool = False,
118
+ ) -> CleanResult:
119
+ """Apply cleanup based on a prior scan report. Does not delete state.vscdb.backup."""
120
+ result = CleanResult(state_db_before=_file_size(report.paths["state_db"]))
121
+
122
+ if clean_chats and report.chat and report.chat.old_composer_ids:
123
+ try:
124
+ headers, kv = clean_old_chats(report.paths["state_db"], report.chat)
125
+ result.chat_deleted_composers = headers
126
+ result.chat_deleted_kv_rows = kv
127
+ except (sqlite3.Error, OSError) as exc:
128
+ result.errors.append(f"Chat cleanup failed: {exc}")
129
+
130
+ if clean_agents:
131
+ try:
132
+ deleted, freed = clean_agent_versions(report.agent_versions)
133
+ result.agent_deleted = deleted
134
+ result.agent_bytes_freed = freed
135
+ except OSError as exc:
136
+ result.errors.append(f"Agent version cleanup failed: {exc}")
137
+
138
+ if include_cache:
139
+ try:
140
+ result.cache_bytes_freed = clean_optional_dirs(
141
+ report.paths, include_cache=True
142
+ )
143
+ except OSError as exc:
144
+ result.errors.append(f"Cache/logs cleanup failed: {exc}")
145
+
146
+ result.state_db_after = _file_size(report.paths["state_db"])
147
+ return result
cursor_clean/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """Command-line interface for cursor-clean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from cursor_clean import __version__
11
+ from cursor_clean.cleaner import clean
12
+ from cursor_clean.format_utils import format_bytes
13
+ from cursor_clean.process import cursor_is_running
14
+ from cursor_clean.scanner import ScanReport, scan
15
+
16
+
17
+ def _build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="cursor-clean",
20
+ description=(
21
+ "Scan and clean Cursor Roaming data on Windows: "
22
+ "old Composer/Agent chats and unused cursor-agent versions. "
23
+ "Never deletes state.vscdb.backup."
24
+ ),
25
+ )
26
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
27
+
28
+ sub = parser.add_subparsers(dest="command", required=True)
29
+
30
+ def add_common(p: argparse.ArgumentParser) -> None:
31
+ p.add_argument(
32
+ "--keep-days",
33
+ type=int,
34
+ default=90,
35
+ help="Keep chats updated/created within this many days (default: 90).",
36
+ )
37
+ p.add_argument(
38
+ "--data-dir",
39
+ type=Path,
40
+ default=None,
41
+ help="Override Cursor Roaming root (default: %%APPDATA%%\\Cursor).",
42
+ )
43
+ p.add_argument(
44
+ "--deep",
45
+ action="store_true",
46
+ help="Estimate reclaimable chat blob size (slower on large DBs).",
47
+ )
48
+ p.add_argument(
49
+ "--include-cache",
50
+ action="store_true",
51
+ help="Also consider CachedData and logs (off by default).",
52
+ )
53
+
54
+ p_scan = sub.add_parser("scan", help="Scan and print reclaimable items (no changes).")
55
+ add_common(p_scan)
56
+
57
+ p_clean = sub.add_parser("clean", help="Scan, ask for confirmation, then clean.")
58
+ add_common(p_clean)
59
+ p_clean.add_argument(
60
+ "-y",
61
+ "--yes",
62
+ action="store_true",
63
+ help="Skip interactive confirmation.",
64
+ )
65
+ p_clean.add_argument(
66
+ "--skip-chats",
67
+ action="store_true",
68
+ help="Do not delete old chats.",
69
+ )
70
+ p_clean.add_argument(
71
+ "--skip-agents",
72
+ action="store_true",
73
+ help="Do not delete old agent versions.",
74
+ )
75
+ p_clean.add_argument(
76
+ "--force",
77
+ action="store_true",
78
+ help="Allow cleaning while Cursor appears to be running (not recommended).",
79
+ )
80
+ return parser
81
+
82
+
83
+ def _print_report(report: ScanReport) -> None:
84
+ root = report.paths["root"]
85
+ print(f"Cursor data: {root}")
86
+ print(f"state.vscdb: {format_bytes(report.state_db_bytes)}")
87
+ print(
88
+ f"state.vscdb.backup: {format_bytes(report.state_db_backup_bytes)} (kept, never deleted)"
89
+ )
90
+ print()
91
+
92
+ if report.chat is not None:
93
+ cutoff = datetime.fromtimestamp(report.chat.cutoff_ms / 1000, tz=timezone.utc)
94
+ print("Chats / Composer")
95
+ print(f" keep-days: {report.chat.keep_days}")
96
+ print(f" cutoff (UTC): {cutoff.strftime('%Y-%m-%d %H:%M')}")
97
+ print(f" total: {report.chat.total_composers}")
98
+ print(f" older: {report.chat.old_composers} (candidates)")
99
+ if report.chat.estimated_bytes is not None:
100
+ print(f" est. blobs: {format_bytes(report.chat.estimated_bytes)}")
101
+ else:
102
+ print(" est. blobs: (use --deep for estimate; free space shows after VACUUM)")
103
+ print()
104
+ else:
105
+ print("Chats / Composer: unavailable")
106
+ print()
107
+
108
+ print("cursor-agent versions")
109
+ if not report.agent_versions:
110
+ print(" (none found)")
111
+ else:
112
+ for v in report.agent_versions:
113
+ mark = "KEEP " if v.keep else "DELETE"
114
+ print(f" [{mark}] {v.name:40} {format_bytes(v.size_bytes)}")
115
+ print(f" reclaimable: {format_bytes(report.agent_reclaimable_bytes)}")
116
+ print()
117
+
118
+ if report.cached_data_bytes or report.logs_bytes:
119
+ print("Optional cache/logs")
120
+ print(f" CachedData: {format_bytes(report.cached_data_bytes)}")
121
+ print(f" logs: {format_bytes(report.logs_bytes)}")
122
+ print()
123
+
124
+ for w in report.warnings:
125
+ print(f"WARNING: {w}")
126
+
127
+
128
+ def cmd_scan(args: argparse.Namespace) -> int:
129
+ report = scan(
130
+ data_dir=args.data_dir,
131
+ keep_days=args.keep_days,
132
+ deep=args.deep,
133
+ include_cache=args.include_cache,
134
+ )
135
+ _print_report(report)
136
+ return 0
137
+
138
+
139
+ def cmd_clean(args: argparse.Namespace) -> int:
140
+ if cursor_is_running() and not args.force:
141
+ print(
142
+ "Cursor is running. Quit Cursor completely, then retry.\n"
143
+ "If you are sure no Cursor process holds the DB, pass --force.",
144
+ file=sys.stderr,
145
+ )
146
+ return 2
147
+
148
+ report = scan(
149
+ data_dir=args.data_dir,
150
+ keep_days=args.keep_days,
151
+ deep=args.deep,
152
+ include_cache=args.include_cache,
153
+ )
154
+ _print_report(report)
155
+
156
+ will_chats = (not args.skip_chats) and report.chat and report.chat.old_composers > 0
157
+ will_agents = (not args.skip_agents) and report.agent_reclaimable_bytes > 0
158
+ will_cache = bool(args.include_cache) and (
159
+ report.cached_data_bytes > 0 or report.logs_bytes > 0
160
+ )
161
+
162
+ if not (will_chats or will_agents or will_cache):
163
+ print("Nothing to clean.")
164
+ return 0
165
+
166
+ print("Planned actions:")
167
+ if will_chats:
168
+ print(f" - Delete {report.chat.old_composers} old chat(s), then VACUUM state.vscdb")
169
+ if will_agents:
170
+ print(
171
+ f" - Delete {sum(1 for v in report.agent_versions if not v.keep)} old agent version(s) "
172
+ f"({format_bytes(report.agent_reclaimable_bytes)})"
173
+ )
174
+ if will_cache:
175
+ print(
176
+ f" - Clear CachedData/logs "
177
+ f"({format_bytes(report.cached_data_bytes + report.logs_bytes)})"
178
+ )
179
+ print(" - state.vscdb.backup will NOT be deleted")
180
+
181
+ if not args.yes:
182
+ answer = input("Proceed? [y/N] ").strip().lower()
183
+ if answer not in {"y", "yes"}:
184
+ print("Aborted.")
185
+ return 1
186
+
187
+ result = clean(
188
+ report,
189
+ clean_chats=not args.skip_chats,
190
+ clean_agents=not args.skip_agents,
191
+ include_cache=args.include_cache,
192
+ )
193
+
194
+ print()
195
+ print("Done.")
196
+ if not args.skip_chats:
197
+ print(
198
+ f" chats removed: {result.chat_deleted_composers} "
199
+ f"(kv rows: {result.chat_deleted_kv_rows})"
200
+ )
201
+ print(
202
+ f" state.vscdb: {format_bytes(result.state_db_before)} -> "
203
+ f"{format_bytes(result.state_db_after)} "
204
+ f"(freed {format_bytes(result.state_db_bytes_freed)})"
205
+ )
206
+ if result.agent_deleted:
207
+ print(
208
+ f" agents removed: {len(result.agent_deleted)} "
209
+ f"({format_bytes(result.agent_bytes_freed)})"
210
+ )
211
+ if result.cache_bytes_freed:
212
+ print(f" cache/logs: {format_bytes(result.cache_bytes_freed)}")
213
+ for err in result.errors:
214
+ print(f"ERROR: {err}", file=sys.stderr)
215
+ return 1 if result.errors else 0
216
+
217
+
218
+ def main(argv: list[str] | None = None) -> int:
219
+ parser = _build_parser()
220
+ args = parser.parse_args(argv)
221
+ if args.keep_days < 1:
222
+ parser.error("--keep-days must be >= 1")
223
+ if args.command == "scan":
224
+ return cmd_scan(args)
225
+ if args.command == "clean":
226
+ return cmd_clean(args)
227
+ parser.error(f"unknown command: {args.command}")
228
+ return 2
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Formatting helpers for human-readable sizes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def format_bytes(num: int | float) -> str:
7
+ """Format a byte count as a short human string."""
8
+ value = float(num)
9
+ units = ["B", "KB", "MB", "GB", "TB"]
10
+ for unit in units:
11
+ if abs(value) < 1024.0 or unit == units[-1]:
12
+ if unit == "B":
13
+ return f"{int(value)} {unit}"
14
+ return f"{value:.2f} {unit}"
15
+ value /= 1024.0
16
+ return f"{value:.2f} TB"
cursor_clean/paths.py ADDED
@@ -0,0 +1,38 @@
1
+ """Resolve Cursor data directories (Windows-first)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def default_roaming_root() -> Path:
10
+ """Return the default Cursor Roaming root on this machine."""
11
+ appdata = os.environ.get("APPDATA")
12
+ if not appdata:
13
+ raise RuntimeError("APPDATA is not set; only Windows is supported for now.")
14
+ return Path(appdata) / "Cursor"
15
+
16
+
17
+ def resolve_paths(data_dir: Path | None = None) -> dict[str, Path]:
18
+ """Resolve commonly used Cursor paths under Roaming."""
19
+ root = Path(data_dir) if data_dir else default_roaming_root()
20
+ global_storage = root / "User" / "globalStorage"
21
+ agent_versions = (
22
+ global_storage
23
+ / "anysphere.cursor-agent-worker"
24
+ / "agent-cli"
25
+ / ".local"
26
+ / "share"
27
+ / "cursor-agent"
28
+ / "versions"
29
+ )
30
+ return {
31
+ "root": root,
32
+ "global_storage": global_storage,
33
+ "state_db": global_storage / "state.vscdb",
34
+ "state_db_backup": global_storage / "state.vscdb.backup",
35
+ "agent_versions": agent_versions,
36
+ "cached_data": root / "CachedData",
37
+ "logs": root / "logs",
38
+ }
@@ -0,0 +1,38 @@
1
+ """Detect whether Cursor is currently running."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+
8
+
9
+ CURSOR_PROCESS_NAMES = {"cursor.exe", "cursor"}
10
+
11
+
12
+ def cursor_is_running() -> bool:
13
+ """Return True if a Cursor process appears to be running."""
14
+ if sys.platform == "win32":
15
+ try:
16
+ completed = subprocess.run(
17
+ ["tasklist", "/FI", "IMAGENAME eq Cursor.exe", "/FO", "CSV", "/NH"],
18
+ capture_output=True,
19
+ text=True,
20
+ check=False,
21
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
22
+ )
23
+ except OSError:
24
+ return False
25
+ out = (completed.stdout or "").strip().lower()
26
+ return "cursor.exe" in out
27
+
28
+ try:
29
+ completed = subprocess.run(
30
+ ["ps", "-A", "-o", "comm="],
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+ except OSError:
36
+ return False
37
+ names = {(line.strip().split("/")[-1] or "").lower() for line in completed.stdout.splitlines()}
38
+ return bool(names & CURSOR_PROCESS_NAMES)
@@ -0,0 +1,218 @@
1
+ """Scan Cursor Roaming data for reclaimable space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from cursor_clean.paths import resolve_paths
11
+
12
+
13
+ @dataclass
14
+ class AgentVersionInfo:
15
+ name: str
16
+ path: Path
17
+ size_bytes: int
18
+ keep: bool
19
+
20
+
21
+ @dataclass
22
+ class ChatCleanupPlan:
23
+ keep_days: int
24
+ cutoff_ms: int
25
+ total_composers: int
26
+ old_composers: int
27
+ old_composer_ids: list[str] = field(default_factory=list)
28
+ estimated_bytes: int | None = None # filled when deep=True
29
+
30
+
31
+ @dataclass
32
+ class ScanReport:
33
+ paths: dict[str, Path]
34
+ state_db_bytes: int
35
+ state_db_backup_bytes: int
36
+ chat: ChatCleanupPlan | None
37
+ agent_versions: list[AgentVersionInfo]
38
+ cached_data_bytes: int
39
+ logs_bytes: int
40
+ warnings: list[str] = field(default_factory=list)
41
+
42
+ @property
43
+ def agent_reclaimable_bytes(self) -> int:
44
+ return sum(v.size_bytes for v in self.agent_versions if not v.keep)
45
+
46
+ @property
47
+ def chat_reclaimable_bytes(self) -> int | None:
48
+ if self.chat is None:
49
+ return None
50
+ return self.chat.estimated_bytes
51
+
52
+
53
+ def _dir_size(path: Path) -> int:
54
+ if not path.exists():
55
+ return 0
56
+ if path.is_file():
57
+ return path.stat().st_size
58
+ total = 0
59
+ for p in path.rglob("*"):
60
+ try:
61
+ if p.is_file():
62
+ total += p.stat().st_size
63
+ except OSError:
64
+ continue
65
+ return total
66
+
67
+
68
+ def _file_size(path: Path) -> int:
69
+ try:
70
+ return path.stat().st_size if path.is_file() else 0
71
+ except OSError:
72
+ return 0
73
+
74
+
75
+ def _open_ro(db_path: Path) -> sqlite3.Connection:
76
+ uri = db_path.resolve().as_uri() + "?mode=ro"
77
+ con = sqlite3.connect(uri, uri=True, timeout=30)
78
+ con.execute("PRAGMA query_only=ON")
79
+ return con
80
+
81
+
82
+ def _composer_cutoff_ms(keep_days: int) -> int:
83
+ return int((time.time() - keep_days * 86400) * 1000)
84
+
85
+
86
+ def scan_old_composers(
87
+ db_path: Path,
88
+ keep_days: int = 90,
89
+ *,
90
+ deep: bool = False,
91
+ ) -> ChatCleanupPlan:
92
+ """Find composers older than keep_days.
93
+
94
+ Age uses COALESCE(NULLIF(lastUpdatedAt,0), createdAt).
95
+ deep=True estimates reclaimable blob bytes (slower on large DBs).
96
+ """
97
+ cutoff = _composer_cutoff_ms(keep_days)
98
+ con = _open_ro(db_path)
99
+ try:
100
+ total = con.execute("SELECT COUNT(*) FROM composerHeaders").fetchone()[0]
101
+ rows = con.execute(
102
+ """
103
+ SELECT composerId
104
+ FROM composerHeaders
105
+ WHERE COALESCE(NULLIF(lastUpdatedAt, 0), createdAt) < ?
106
+ ORDER BY COALESCE(NULLIF(lastUpdatedAt, 0), createdAt) ASC
107
+ """,
108
+ (cutoff,),
109
+ ).fetchall()
110
+ old_ids = [r[0] for r in rows if r[0]]
111
+ estimated: int | None = None
112
+ if deep and old_ids:
113
+ estimated = _estimate_composer_blob_bytes(con, old_ids)
114
+ return ChatCleanupPlan(
115
+ keep_days=keep_days,
116
+ cutoff_ms=cutoff,
117
+ total_composers=int(total),
118
+ old_composers=len(old_ids),
119
+ old_composer_ids=old_ids,
120
+ estimated_bytes=estimated,
121
+ )
122
+ finally:
123
+ con.close()
124
+
125
+
126
+ def _estimate_composer_blob_bytes(con: sqlite3.Connection, old_ids: list[str]) -> int:
127
+ """Sum LENGTH(value) for keys belonging to old composers.
128
+
129
+ Uses chunked IN (...) so it works on read-only connections (no TEMP table).
130
+ """
131
+ total = 0
132
+ # Keep SQL parameter lists manageable.
133
+ chunk_size = 400
134
+ prefixes = (
135
+ ("composerData:", 14),
136
+ ("bubbleId:", 10),
137
+ ("checkpointId:", 14),
138
+ ("messageRequestContext:", 23),
139
+ )
140
+ for i in range(0, len(old_ids), chunk_size):
141
+ chunk = old_ids[i : i + chunk_size]
142
+ placeholders = ",".join("?" * len(chunk))
143
+ parts = []
144
+ params: list[str] = []
145
+ for prefix, start in prefixes:
146
+ parts.append(
147
+ f"(key LIKE ? AND substr(key, {start}, 36) IN ({placeholders}))"
148
+ )
149
+ params.append(f"{prefix}%")
150
+ params.extend(chunk)
151
+ sql = f"SELECT COALESCE(SUM(LENGTH(value)), 0) FROM cursorDiskKV WHERE {' OR '.join(parts)}"
152
+ row = con.execute(sql, params).fetchone()
153
+ total += int(row[0] if row else 0)
154
+ return total
155
+
156
+
157
+ def scan_agent_versions(versions_dir: Path) -> list[AgentVersionInfo]:
158
+ """List agent CLI versions; keep the newest non-tmp folder."""
159
+ if not versions_dir.is_dir():
160
+ return []
161
+
162
+ entries: list[AgentVersionInfo] = []
163
+ for child in versions_dir.iterdir():
164
+ if not child.is_dir():
165
+ continue
166
+ entries.append(
167
+ AgentVersionInfo(
168
+ name=child.name,
169
+ path=child,
170
+ size_bytes=_dir_size(child),
171
+ keep=False,
172
+ )
173
+ )
174
+
175
+ real = [e for e in entries if not e.name.startswith(".tmp")]
176
+ if real:
177
+ # Version folder names are date-prefixed (YYYY.MM.DD-...), lexicographic max ~= newest.
178
+ newest = max(real, key=lambda e: e.name)
179
+ for e in entries:
180
+ e.keep = e.name == newest.name
181
+ return sorted(entries, key=lambda e: (e.keep, e.name), reverse=True)
182
+
183
+
184
+ def scan(
185
+ *,
186
+ data_dir: Path | None = None,
187
+ keep_days: int = 90,
188
+ deep: bool = False,
189
+ include_cache: bool = False,
190
+ ) -> ScanReport:
191
+ """Scan Cursor data and build a cleanup report."""
192
+ paths = resolve_paths(data_dir)
193
+ warnings: list[str] = []
194
+
195
+ state_db = paths["state_db"]
196
+ chat: ChatCleanupPlan | None = None
197
+ if state_db.is_file():
198
+ try:
199
+ chat = scan_old_composers(state_db, keep_days=keep_days, deep=deep)
200
+ except sqlite3.Error as exc:
201
+ warnings.append(f"Failed to read state.vscdb: {exc}")
202
+ else:
203
+ warnings.append(f"state.vscdb not found: {state_db}")
204
+
205
+ agents = scan_agent_versions(paths["agent_versions"])
206
+ cached = _dir_size(paths["cached_data"]) if include_cache else 0
207
+ logs = _dir_size(paths["logs"]) if include_cache else 0
208
+
209
+ return ScanReport(
210
+ paths=paths,
211
+ state_db_bytes=_file_size(state_db),
212
+ state_db_backup_bytes=_file_size(paths["state_db_backup"]),
213
+ chat=chat,
214
+ agent_versions=agents,
215
+ cached_data_bytes=cached,
216
+ logs_bytes=logs,
217
+ warnings=warnings,
218
+ )
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: cursor-clean
3
+ Version: 0.1.0
4
+ Summary: Scan and clean Cursor Roaming data: old chats and unused agent versions.
5
+ Author: cursor-clean contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/cursor-clean/
8
+ Keywords: cursor,cleanup,disk,sqlite,windows
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # cursor-clean
23
+
24
+ Scan and clean **Cursor** Roaming data on Windows:
25
+
26
+ - Old Composer / Agent chats (default: keep last **90** days)
27
+ - Unused `cursor-agent` CLI versions (keep newest only)
28
+ - Optional: `CachedData` / `logs`
29
+
30
+ **Never deletes** `state.vscdb.backup`.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install cursor-clean
36
+ ```
37
+
38
+ Requires Python 3.9+.
39
+
40
+ ## Usage
41
+
42
+ Quit Cursor before cleaning (the tool checks for running processes).
43
+
44
+ ```bash
45
+ # Preview only
46
+ cursor-clean scan
47
+
48
+ # Preview with blob size estimate (slower)
49
+ cursor-clean scan --deep
50
+
51
+ # Keep 60 days instead of 90
52
+ cursor-clean scan --keep-days 60
53
+
54
+ # Clean after confirmation
55
+ cursor-clean clean
56
+
57
+ # Non-interactive
58
+ cursor-clean clean -y --keep-days 90
59
+
60
+ # Also clear CachedData / logs
61
+ cursor-clean clean --include-cache
62
+ ```
63
+
64
+ ## What it touches
65
+
66
+ | Path | Action |
67
+ |------|--------|
68
+ | `%APPDATA%\Cursor\User\globalStorage\state.vscdb` | Delete old chat rows, then `VACUUM` |
69
+ | `...\anysphere.cursor-agent-worker\...\versions\*` | Keep newest version folder, delete the rest |
70
+ | `state.vscdb.backup` | **Left alone** |
71
+ | `CachedData`, `logs` | Only with `--include-cache` |
72
+
73
+ Chat age uses `COALESCE(lastUpdatedAt, createdAt)` from `composerHeaders`.
74
+
75
+ ## Safety
76
+
77
+ 1. Prefer `scan` first.
78
+ 2. Fully quit Cursor before `clean`.
79
+ 3. Backup is retained by design.
80
+ 4. No local “deletion log” is written.
81
+
82
+ ## Override data directory
83
+
84
+ ```bash
85
+ cursor-clean scan --data-dir "D:\CursorData"
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,14 @@
1
+ cursor_clean/__init__.py,sha256=u7tcSlBFJFOUY8avlYUCqMIcyOSOegHbVhU1JAL5odU,90
2
+ cursor_clean/__main__.py,sha256=-372QWzIkcMWhIHFqbH2y8HpD3snLiMv8ZgE1S2Fu-0,91
3
+ cursor_clean/cleaner.py,sha256=AsZwg8LOihYRg5Z4CLD_LrKdvs9hIBetXueTQUDxff8,4835
4
+ cursor_clean/cli.py,sha256=K3JzCdlgI4wpk4Xh3r21U9BJrFYXmQwQe1TSi70Shi0,7552
5
+ cursor_clean/format_utils.py,sha256=m1kzAjuhsS7aLQUZSeOnrh51_Rdj4-IMIc9SayrqrOg,496
6
+ cursor_clean/paths.py,sha256=fT6tMaca8r0OiDdiOuu_WWMcFwH5HXfppPU-uceF-mE,1178
7
+ cursor_clean/process.py,sha256=JFPZdrKzGslHiRnklT6gkrwsF6ECYAp8iU2XCmWs4wg,1121
8
+ cursor_clean/scanner.py,sha256=d2iMobENbhNkeJdDEXWTKgP7V3hM0PoqdyiNFuNTiMw,6347
9
+ cursor_clean-0.1.0.dist-info/licenses/LICENSE,sha256=LdBgiaZ8HIKY9ioCV2U1zbA1wc7iSi8Oi_ecdYsz8MQ,1082
10
+ cursor_clean-0.1.0.dist-info/METADATA,sha256=JYTRtGppVw0oh7c5B4CmDJ5P_MmAtmijeP4M57gE8po,2288
11
+ cursor_clean-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ cursor_clean-0.1.0.dist-info/entry_points.txt,sha256=TfM7sAkz-f2TYsrHzMzijdPV6VDT5KdLpVEPvuq96nM,55
13
+ cursor_clean-0.1.0.dist-info/top_level.txt,sha256=_EbOl6JtABz_u1TPJ953TBwvMvS2k1Dph_baphNmGoM,13
14
+ cursor_clean-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cursor-clean = cursor_clean.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cursor-clean contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cursor_clean