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.
- cursor_clean/__init__.py +3 -0
- cursor_clean/__main__.py +4 -0
- cursor_clean/cleaner.py +147 -0
- cursor_clean/cli.py +232 -0
- cursor_clean/format_utils.py +16 -0
- cursor_clean/paths.py +38 -0
- cursor_clean/process.py +38 -0
- cursor_clean/scanner.py +218 -0
- cursor_clean-0.1.0.dist-info/METADATA +90 -0
- cursor_clean-0.1.0.dist-info/RECORD +14 -0
- cursor_clean-0.1.0.dist-info/WHEEL +5 -0
- cursor_clean-0.1.0.dist-info/entry_points.txt +2 -0
- cursor_clean-0.1.0.dist-info/licenses/LICENSE +21 -0
- cursor_clean-0.1.0.dist-info/top_level.txt +1 -0
cursor_clean/__init__.py
ADDED
cursor_clean/__main__.py
ADDED
cursor_clean/cleaner.py
ADDED
|
@@ -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
|
+
}
|
cursor_clean/process.py
ADDED
|
@@ -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)
|
cursor_clean/scanner.py
ADDED
|
@@ -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,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
|