cdx-manager 0.9.15 → 0.10.0
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.
- package/README.md +75 -3
- package/changelogs/CHANGELOGS_0_10_0.md +43 -0
- package/changelogs/CHANGELOGS_0_9_16.md +19 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +168 -2
- package/src/cli_args.py +1 -0
- package/src/cli_commands.py +513 -4
- package/src/cli_helpers.py +7 -0
- package/src/codex_usage.py +81 -16
- package/src/provider_runtime.py +10 -1
- package/src/session_service.py +10 -0
- package/src/status_view.py +14 -6
package/src/cli_commands.py
CHANGED
|
@@ -4,8 +4,11 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
8
10
|
import time
|
|
11
|
+
import uuid
|
|
9
12
|
from datetime import datetime
|
|
10
13
|
|
|
11
14
|
from .backup_bundle import read_bundle_meta
|
|
@@ -13,6 +16,7 @@ from .claude_refresh import _refresh_claude_sessions
|
|
|
13
16
|
from .cli_args import (
|
|
14
17
|
CAN_RESUME_USAGE,
|
|
15
18
|
CONTEXT_USAGE,
|
|
19
|
+
DISK_USAGE,
|
|
16
20
|
DOCTOR_USAGE,
|
|
17
21
|
HANDOFF_USAGE,
|
|
18
22
|
LAST_USAGE,
|
|
@@ -66,9 +70,10 @@ from .cli_helpers import (
|
|
|
66
70
|
_write_json,
|
|
67
71
|
_write_update_notice,
|
|
68
72
|
)
|
|
69
|
-
from .cli_render import _dim, _info, _style, _success, _warn
|
|
73
|
+
from .cli_render import _dim, _info, _pad_table, _style, _success, _warn
|
|
70
74
|
from .cli_view import handle_view as handle_view # re-export for cli.py / tests
|
|
71
|
-
from .
|
|
75
|
+
from .codex_usage import consume_codex_rate_limit_reset_credit
|
|
76
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
72
77
|
from .context_store import (
|
|
73
78
|
clear_context,
|
|
74
79
|
edit_context,
|
|
@@ -140,6 +145,21 @@ def _confirm_removal(name):
|
|
|
140
145
|
return answer.strip().lower() in ("y", "yes")
|
|
141
146
|
|
|
142
147
|
|
|
148
|
+
def _confirm_reset(name):
|
|
149
|
+
answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
|
|
150
|
+
return answer.strip().lower() in ("y", "yes")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _confirm_profile_cleanup(action):
|
|
154
|
+
answer = input(f"Delete matching profile {action} candidates? [y/N] ")
|
|
155
|
+
return answer.strip().lower() in ("y", "yes")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _confirm_log_cleanup(target):
|
|
159
|
+
answer = input(f"Clear launch transcript logs for {target}? [y/N] ")
|
|
160
|
+
return answer.strip().lower() in ("y", "yes")
|
|
161
|
+
|
|
162
|
+
|
|
143
163
|
def _resolve_confirmation(confirm_fn, name):
|
|
144
164
|
confirmed = (
|
|
145
165
|
asyncio.get_event_loop().run_until_complete(confirm_fn(name))
|
|
@@ -151,6 +171,412 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
151
171
|
return confirmed
|
|
152
172
|
|
|
153
173
|
|
|
174
|
+
def _format_bytes(value):
|
|
175
|
+
size = float(value)
|
|
176
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
177
|
+
if size < 1024 or unit == "TB":
|
|
178
|
+
return f"{int(size)} {unit}" if unit == "B" or size.is_integer() else f"{size:.1f} {unit}"
|
|
179
|
+
size /= 1024
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _directory_size_bytes(path, runner=None):
|
|
183
|
+
if not os.path.exists(path):
|
|
184
|
+
raise CdxError(f"CDX home does not exist: {path}")
|
|
185
|
+
runner = runner or subprocess.check_output
|
|
186
|
+
try:
|
|
187
|
+
output = runner(["du", "-sk", path], text=True, stderr=subprocess.DEVNULL)
|
|
188
|
+
return int(str(output).split()[0]) * 1024
|
|
189
|
+
except (OSError, subprocess.CalledProcessError, ValueError, IndexError):
|
|
190
|
+
total = 0
|
|
191
|
+
for root, _, files in os.walk(path):
|
|
192
|
+
for name in files:
|
|
193
|
+
try:
|
|
194
|
+
total += os.path.getsize(os.path.join(root, name))
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
return total
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _directory_child_sizes(path, runner=None, progress=None):
|
|
201
|
+
rows = []
|
|
202
|
+
try:
|
|
203
|
+
names = os.listdir(path)
|
|
204
|
+
except OSError:
|
|
205
|
+
return rows
|
|
206
|
+
children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
|
|
207
|
+
for index, (name, child_path) in enumerate(children, start=1):
|
|
208
|
+
if progress:
|
|
209
|
+
progress("profile", name, index, len(children))
|
|
210
|
+
rows.append({
|
|
211
|
+
"name": name,
|
|
212
|
+
"path": child_path,
|
|
213
|
+
"bytes": _directory_size_bytes(child_path, runner=runner),
|
|
214
|
+
})
|
|
215
|
+
rows.sort(key=lambda row: row["bytes"], reverse=True)
|
|
216
|
+
for row in rows:
|
|
217
|
+
row["size"] = _format_bytes(row["bytes"])
|
|
218
|
+
return rows
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_days(value, usage):
|
|
222
|
+
match = re.fullmatch(r"(\d+)(?:d)?", str(value or "").strip())
|
|
223
|
+
if not match or int(match.group(1)) <= 0:
|
|
224
|
+
raise CdxError(usage)
|
|
225
|
+
return int(match.group(1))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _candidate(profile, kind, path, bytes_used, reason, risk="safe", evidence=None):
|
|
229
|
+
return {
|
|
230
|
+
"profile": profile,
|
|
231
|
+
"kind": kind,
|
|
232
|
+
"path": path,
|
|
233
|
+
"bytes": bytes_used,
|
|
234
|
+
"size": _format_bytes(bytes_used),
|
|
235
|
+
"reason": reason,
|
|
236
|
+
"risk": risk,
|
|
237
|
+
"evidence": evidence or {},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _iter_profile_dirs(base_dir):
|
|
242
|
+
profiles_dir = os.path.join(base_dir, "profiles")
|
|
243
|
+
try:
|
|
244
|
+
names = sorted(os.listdir(profiles_dir))
|
|
245
|
+
except OSError:
|
|
246
|
+
return
|
|
247
|
+
for name in names:
|
|
248
|
+
path = os.path.join(profiles_dir, name)
|
|
249
|
+
if os.path.isdir(path):
|
|
250
|
+
yield name, path
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _collect_old_logs(profile_name, profile_path, days, now=None):
|
|
254
|
+
cutoff = (time.time() if now is None else now) - (days * 86400)
|
|
255
|
+
paths = []
|
|
256
|
+
bytes_used = 0
|
|
257
|
+
latest_mtime = 0
|
|
258
|
+
for root, _, files in os.walk(profile_path):
|
|
259
|
+
if os.path.basename(root) != "log":
|
|
260
|
+
continue
|
|
261
|
+
for filename in files:
|
|
262
|
+
if not filename.endswith(".log"):
|
|
263
|
+
continue
|
|
264
|
+
path = os.path.join(root, filename)
|
|
265
|
+
try:
|
|
266
|
+
stat = os.stat(path)
|
|
267
|
+
except OSError:
|
|
268
|
+
continue
|
|
269
|
+
if stat.st_mtime >= cutoff:
|
|
270
|
+
continue
|
|
271
|
+
paths.append(path)
|
|
272
|
+
bytes_used += stat.st_size
|
|
273
|
+
latest_mtime = max(latest_mtime, stat.st_mtime)
|
|
274
|
+
if not paths:
|
|
275
|
+
return None
|
|
276
|
+
return _candidate(
|
|
277
|
+
profile_name,
|
|
278
|
+
f"old-logs-{days}d",
|
|
279
|
+
profile_path,
|
|
280
|
+
bytes_used,
|
|
281
|
+
f"{len(paths)} .log files older than {days}d",
|
|
282
|
+
risk="review",
|
|
283
|
+
evidence={
|
|
284
|
+
"days": days,
|
|
285
|
+
"file_count": len(paths),
|
|
286
|
+
"newest_old_log_mtime": datetime.fromtimestamp(latest_mtime).isoformat() if latest_mtime else None,
|
|
287
|
+
"sample_paths": paths[:5],
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
|
|
293
|
+
candidates = []
|
|
294
|
+
profiles = list(_iter_profile_dirs(base_dir))
|
|
295
|
+
for index, (profile_name, profile_path) in enumerate(profiles, start=1):
|
|
296
|
+
if progress:
|
|
297
|
+
progress("candidates", profile_name, index, len(profiles))
|
|
298
|
+
tmp_dir = os.path.join(profile_path, ".tmp")
|
|
299
|
+
tmp_targets = [
|
|
300
|
+
("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
|
|
301
|
+
]
|
|
302
|
+
try:
|
|
303
|
+
tmp_names = os.listdir(tmp_dir)
|
|
304
|
+
except OSError:
|
|
305
|
+
tmp_names = []
|
|
306
|
+
for name in tmp_names:
|
|
307
|
+
if name.startswith("plugins-clone-"):
|
|
308
|
+
tmp_targets.append(("tmp-plugin-clone", os.path.join(tmp_dir, name), "temporary plugin clone"))
|
|
309
|
+
elif name.startswith("plugins-backup-"):
|
|
310
|
+
tmp_targets.append(("tmp-plugin-backup", os.path.join(tmp_dir, name), "temporary plugin backup"))
|
|
311
|
+
for kind, path, reason in tmp_targets:
|
|
312
|
+
if not os.path.exists(path):
|
|
313
|
+
continue
|
|
314
|
+
bytes_used = _directory_size_bytes(path, runner=runner)
|
|
315
|
+
if bytes_used <= 0:
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
|
|
319
|
+
except OSError:
|
|
320
|
+
mtime = None
|
|
321
|
+
candidates.append(_candidate(
|
|
322
|
+
profile_name,
|
|
323
|
+
kind,
|
|
324
|
+
path,
|
|
325
|
+
bytes_used,
|
|
326
|
+
reason,
|
|
327
|
+
evidence={"modified_at": mtime},
|
|
328
|
+
))
|
|
329
|
+
old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
|
|
330
|
+
if old_logs and old_logs["bytes"] > 0:
|
|
331
|
+
candidates.append(old_logs)
|
|
332
|
+
candidates.sort(key=lambda item: item["bytes"], reverse=True)
|
|
333
|
+
return candidates
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _format_cleanup_candidates(candidates, use_color=False):
|
|
337
|
+
if not candidates:
|
|
338
|
+
return _dim("No cleanup candidates found.", use_color)
|
|
339
|
+
reclaimable = sum(item["bytes"] for item in candidates)
|
|
340
|
+
lines = [
|
|
341
|
+
_style("Cleanup candidates", "1", use_color),
|
|
342
|
+
f"{_style(_format_bytes(reclaimable), '33', use_color)} reclaimable across {len({item['profile'] for item in candidates})} profile(s)",
|
|
343
|
+
]
|
|
344
|
+
by_profile = {}
|
|
345
|
+
for item in candidates:
|
|
346
|
+
by_profile.setdefault(item["profile"], []).append(item)
|
|
347
|
+
for profile, items in sorted(by_profile.items(), key=lambda row: sum(i["bytes"] for i in row[1]), reverse=True):
|
|
348
|
+
total = sum(item["bytes"] for item in items)
|
|
349
|
+
lines.extend(["", f"{_style(profile, '1', use_color)} {_style(_format_bytes(total), '33', use_color)}"])
|
|
350
|
+
rows = [[
|
|
351
|
+
_style("SIZE", "1", use_color),
|
|
352
|
+
_style("TYPE", "1", use_color),
|
|
353
|
+
_style("RISK", "1", use_color),
|
|
354
|
+
_style("EVIDENCE", "1", use_color),
|
|
355
|
+
]]
|
|
356
|
+
for item in items:
|
|
357
|
+
risk_color = "32" if item["risk"] == "safe" else "33"
|
|
358
|
+
rows.append([
|
|
359
|
+
_style(item["size"], "36", use_color),
|
|
360
|
+
item["kind"],
|
|
361
|
+
_style(item["risk"], risk_color, use_color),
|
|
362
|
+
item["reason"],
|
|
363
|
+
])
|
|
364
|
+
lines.append(_pad_table(rows))
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_disk_report(payload, use_color=False):
|
|
369
|
+
title = "CDX profiles" if payload["target"] == "profiles" else "CDX home"
|
|
370
|
+
lines = [
|
|
371
|
+
_style(title, "1", use_color),
|
|
372
|
+
f"Path: {_dim(payload['path'], use_color)}",
|
|
373
|
+
f"Total: {_style(payload['size'], '36', use_color)}",
|
|
374
|
+
]
|
|
375
|
+
children = payload.get("children") or []
|
|
376
|
+
if children:
|
|
377
|
+
reclaimable_by_profile = {}
|
|
378
|
+
for item in payload.get("candidates") or []:
|
|
379
|
+
reclaimable_by_profile[item["profile"]] = reclaimable_by_profile.get(item["profile"], 0) + item["bytes"]
|
|
380
|
+
headers = ["PROFILE", "SIZE", "SHARE"]
|
|
381
|
+
if "candidates" in payload:
|
|
382
|
+
headers.append("RECLAIMABLE")
|
|
383
|
+
rows = [[_style(header, "1", use_color) for header in headers]]
|
|
384
|
+
for child in children:
|
|
385
|
+
share = (child["bytes"] / payload["bytes"] * 100) if payload["bytes"] else 0
|
|
386
|
+
row = [
|
|
387
|
+
_style(child["name"], "1", use_color),
|
|
388
|
+
_style(child["size"], "36", use_color),
|
|
389
|
+
f"{share:.1f}%",
|
|
390
|
+
]
|
|
391
|
+
if "candidates" in payload:
|
|
392
|
+
reclaimable = reclaimable_by_profile.get(child["name"], 0)
|
|
393
|
+
row.append(_style(_format_bytes(reclaimable), "33", use_color) if reclaimable else _dim("-", use_color))
|
|
394
|
+
rows.append(row)
|
|
395
|
+
lines.extend(["", _style(f"Profiles ({len(children)})", "1", use_color), _pad_table(rows)])
|
|
396
|
+
if "candidates" in payload:
|
|
397
|
+
lines.extend(["", _format_cleanup_candidates(payload["candidates"], use_color=use_color)])
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _remove_path(path):
|
|
402
|
+
try:
|
|
403
|
+
if os.path.isdir(path):
|
|
404
|
+
size = _directory_size_bytes(path)
|
|
405
|
+
shutil.rmtree(path)
|
|
406
|
+
else:
|
|
407
|
+
size = os.path.getsize(path)
|
|
408
|
+
os.remove(path)
|
|
409
|
+
return size
|
|
410
|
+
except OSError as error:
|
|
411
|
+
raise CdxError(f"Failed to remove cleanup candidate {path}: {error}") from error
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _clean_profile_tmp(profile_path):
|
|
415
|
+
tmp_dir = os.path.join(profile_path, ".tmp")
|
|
416
|
+
targets = [os.path.join(tmp_dir, "marketplaces")]
|
|
417
|
+
try:
|
|
418
|
+
names = os.listdir(tmp_dir)
|
|
419
|
+
except OSError:
|
|
420
|
+
names = []
|
|
421
|
+
targets.extend(os.path.join(tmp_dir, name) for name in names if name.startswith(("plugins-clone-", "plugins-backup-")))
|
|
422
|
+
freed = 0
|
|
423
|
+
removed = []
|
|
424
|
+
for path in targets:
|
|
425
|
+
if os.path.exists(path):
|
|
426
|
+
size = _remove_path(path)
|
|
427
|
+
if size:
|
|
428
|
+
freed += size
|
|
429
|
+
removed.append(path)
|
|
430
|
+
return freed, removed
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _clean_profile_old_logs(profile_path, days, now=None):
|
|
434
|
+
cutoff = (time.time() if now is None else now) - (days * 86400)
|
|
435
|
+
freed = 0
|
|
436
|
+
removed = []
|
|
437
|
+
for root, _, files in os.walk(profile_path):
|
|
438
|
+
if os.path.basename(root) != "log":
|
|
439
|
+
continue
|
|
440
|
+
for filename in files:
|
|
441
|
+
if not filename.endswith(".log"):
|
|
442
|
+
continue
|
|
443
|
+
path = os.path.join(root, filename)
|
|
444
|
+
try:
|
|
445
|
+
stat = os.stat(path)
|
|
446
|
+
except OSError:
|
|
447
|
+
continue
|
|
448
|
+
if stat.st_mtime >= cutoff:
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
os.remove(path)
|
|
452
|
+
except OSError as error:
|
|
453
|
+
raise CdxError(f"Failed to remove old log {path}: {error}") from error
|
|
454
|
+
freed += stat.st_size
|
|
455
|
+
removed.append(path)
|
|
456
|
+
return freed, removed
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _handle_clean_profiles(args, ctx, json_flag):
|
|
460
|
+
usage = "Usage: cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]"
|
|
461
|
+
yes = "--yes" in args
|
|
462
|
+
args = [arg for arg in args if arg != "--yes"]
|
|
463
|
+
clean_tmp = "--tmp" in args
|
|
464
|
+
old_logs = None
|
|
465
|
+
if "--old-logs" in args:
|
|
466
|
+
index = args.index("--old-logs")
|
|
467
|
+
if index + 1 >= len(args):
|
|
468
|
+
raise CdxError(usage)
|
|
469
|
+
old_logs = _parse_days(args[index + 1], usage)
|
|
470
|
+
args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
|
|
471
|
+
args = [arg for arg in args if arg != "--tmp"]
|
|
472
|
+
if args or clean_tmp == (old_logs is not None):
|
|
473
|
+
raise CdxError(usage)
|
|
474
|
+
|
|
475
|
+
action = "temporary cache" if clean_tmp else f"logs older than {old_logs}d"
|
|
476
|
+
if not yes:
|
|
477
|
+
confirm_fn = ctx["options"].get("confirmProfileCleanup")
|
|
478
|
+
if confirm_fn:
|
|
479
|
+
confirmed = _resolve_confirmation(confirm_fn, action)
|
|
480
|
+
elif not ctx["stdin_is_tty"]:
|
|
481
|
+
raise CdxError("Profile cleanup requires an interactive terminal or --yes in non-interactive mode.")
|
|
482
|
+
else:
|
|
483
|
+
confirmed = _confirm_profile_cleanup(action)
|
|
484
|
+
if not confirmed:
|
|
485
|
+
if json_flag:
|
|
486
|
+
_write_json(ctx, _json_success("clean.profiles", "Cancelled.", cancelled=True, freed_bytes=0, freed_size="0 B", profiles=[]))
|
|
487
|
+
return 0
|
|
488
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
results = []
|
|
492
|
+
now = ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None
|
|
493
|
+
for profile_name, profile_path in _iter_profile_dirs(ctx["service"]["base_dir"]):
|
|
494
|
+
if clean_tmp:
|
|
495
|
+
freed, removed = _clean_profile_tmp(profile_path)
|
|
496
|
+
result_action = "tmp"
|
|
497
|
+
else:
|
|
498
|
+
freed, removed = _clean_profile_old_logs(profile_path, old_logs, now=now)
|
|
499
|
+
result_action = f"old-logs-{old_logs}d"
|
|
500
|
+
if not freed and not removed:
|
|
501
|
+
continue
|
|
502
|
+
results.append({
|
|
503
|
+
"profile": profile_name,
|
|
504
|
+
"action": result_action,
|
|
505
|
+
"freed_bytes": freed,
|
|
506
|
+
"freed_size": _format_bytes(freed),
|
|
507
|
+
"removed_count": len(removed),
|
|
508
|
+
"removed_paths": removed,
|
|
509
|
+
})
|
|
510
|
+
total = sum(item["freed_bytes"] for item in results)
|
|
511
|
+
if json_flag:
|
|
512
|
+
_write_json(ctx, _json_success("clean.profiles", f"Cleaned profiles ({_format_bytes(total)} freed)", cancelled=False, freed_bytes=total, freed_size=_format_bytes(total), profiles=results))
|
|
513
|
+
return 0
|
|
514
|
+
if not results:
|
|
515
|
+
ctx["out"](f"{_dim('No matching profile cleanup candidates found.', ctx['use_color'])}\n")
|
|
516
|
+
return 0
|
|
517
|
+
for item in results:
|
|
518
|
+
message = f"Cleaned {item['profile']} {item['action']} ({item['freed_size']} freed)"
|
|
519
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
520
|
+
ctx["out"](f"{_success(f'Total freed: {_format_bytes(total)}', ctx['use_color'])}\n")
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def handle_disk(rest, ctx):
|
|
525
|
+
parsed = _parse_flag_args(rest, {
|
|
526
|
+
"--candidates": {"key": "candidates", "type": "bool", "default": False},
|
|
527
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
528
|
+
}, DISK_USAGE, positionals_key="targets", max_positionals=1)
|
|
529
|
+
target = parsed["targets"][0] if parsed["targets"] else "home"
|
|
530
|
+
if target == "home":
|
|
531
|
+
path = ctx["service"]["base_dir"]
|
|
532
|
+
elif target == "profiles":
|
|
533
|
+
path = os.path.join(ctx["service"]["base_dir"], "profiles")
|
|
534
|
+
else:
|
|
535
|
+
raise CdxError(DISK_USAGE)
|
|
536
|
+
stderr = ctx["options"].get("stderr", sys.stderr)
|
|
537
|
+
show_progress = not parsed["json"] and hasattr(stderr, "isatty") and stderr.isatty()
|
|
538
|
+
|
|
539
|
+
def progress(stage, name=None, index=None, total=None):
|
|
540
|
+
if not show_progress:
|
|
541
|
+
return
|
|
542
|
+
if stage == "total":
|
|
543
|
+
label = "CDX profiles" if target == "profiles" else "CDX home"
|
|
544
|
+
ctx["err"](f"Measuring {label} disk usage...\n")
|
|
545
|
+
elif stage == "profile":
|
|
546
|
+
ctx["err"](f"Measuring profile {name} ({index}/{total})...\n")
|
|
547
|
+
elif stage == "candidates":
|
|
548
|
+
ctx["err"](f"Scanning cleanup candidates in {name} ({index}/{total})...\n")
|
|
549
|
+
|
|
550
|
+
progress("total")
|
|
551
|
+
bytes_used = _directory_size_bytes(path, runner=ctx["options"].get("diskUsageRunner"))
|
|
552
|
+
payload = {
|
|
553
|
+
"target": target,
|
|
554
|
+
"path": path,
|
|
555
|
+
"bytes": bytes_used,
|
|
556
|
+
"size": _format_bytes(bytes_used),
|
|
557
|
+
}
|
|
558
|
+
if target == "profiles":
|
|
559
|
+
payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
|
|
560
|
+
if parsed["candidates"]:
|
|
561
|
+
if target != "profiles":
|
|
562
|
+
raise CdxError(DISK_USAGE)
|
|
563
|
+
candidates = _collect_profile_cleanup_candidates(
|
|
564
|
+
ctx["service"]["base_dir"],
|
|
565
|
+
runner=ctx["options"].get("diskUsageRunner"),
|
|
566
|
+
now=ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None,
|
|
567
|
+
progress=progress,
|
|
568
|
+
)
|
|
569
|
+
payload["candidates"] = candidates
|
|
570
|
+
payload["reclaimable_bytes"] = sum(item["bytes"] for item in candidates)
|
|
571
|
+
payload["reclaimable_size"] = _format_bytes(payload["reclaimable_bytes"])
|
|
572
|
+
if parsed["json"]:
|
|
573
|
+
label = "CDX profiles" if target == "profiles" else "CDX home"
|
|
574
|
+
_write_json(ctx, _json_success("disk", f"Measured {label} disk usage: {payload['size']}", disk=payload))
|
|
575
|
+
return 0
|
|
576
|
+
ctx["out"](f"{_format_disk_report(payload, use_color=ctx['use_color'])}\n")
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
|
|
154
580
|
def _extract_claude_oauth_token(text):
|
|
155
581
|
if not text:
|
|
156
582
|
return None
|
|
@@ -1309,6 +1735,10 @@ def handle_last(rest, ctx):
|
|
|
1309
1735
|
|
|
1310
1736
|
def handle_clean(rest, ctx):
|
|
1311
1737
|
json_flag, args = _parse_json_flag(rest)
|
|
1738
|
+
if args[:1] == ["profiles"] and any(arg in ("--tmp", "--old-logs") for arg in args[1:]):
|
|
1739
|
+
return _handle_clean_profiles(args[1:], ctx, json_flag)
|
|
1740
|
+
yes = "--yes" in args
|
|
1741
|
+
args = [arg for arg in args if arg != "--yes"]
|
|
1312
1742
|
service = ctx["service"]
|
|
1313
1743
|
if len(args) == 0:
|
|
1314
1744
|
targets = service["list_sessions"]()
|
|
@@ -1318,7 +1748,23 @@ def handle_clean(rest, ctx):
|
|
|
1318
1748
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
1319
1749
|
targets = [session]
|
|
1320
1750
|
else:
|
|
1321
|
-
raise CdxError("Usage: cdx clean [name] [--json]")
|
|
1751
|
+
raise CdxError("Usage: cdx clean [name] [--yes] [--json]")
|
|
1752
|
+
|
|
1753
|
+
if not yes:
|
|
1754
|
+
target = targets[0]["name"] if len(targets) == 1 else f"all {len(targets)} sessions"
|
|
1755
|
+
confirm_fn = ctx["options"].get("confirmClean")
|
|
1756
|
+
if confirm_fn:
|
|
1757
|
+
confirmed = _resolve_confirmation(confirm_fn, target)
|
|
1758
|
+
elif not ctx["stdin_is_tty"]:
|
|
1759
|
+
raise CdxError("Log cleanup requires an interactive terminal or --yes in non-interactive mode.")
|
|
1760
|
+
else:
|
|
1761
|
+
confirmed = _confirm_log_cleanup(target)
|
|
1762
|
+
if not confirmed:
|
|
1763
|
+
if json_flag:
|
|
1764
|
+
_write_json(ctx, _json_success("clean", "Cancelled.", cancelled=True, sessions=[]))
|
|
1765
|
+
return 0
|
|
1766
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
1767
|
+
return 0
|
|
1322
1768
|
|
|
1323
1769
|
cleaned_sessions = []
|
|
1324
1770
|
for session in targets:
|
|
@@ -1373,7 +1819,7 @@ def handle_clean(rest, ctx):
|
|
|
1373
1819
|
continue
|
|
1374
1820
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
1375
1821
|
if json_flag:
|
|
1376
|
-
_write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
|
|
1822
|
+
_write_json(ctx, _json_success("clean", "Cleaned session logs", cancelled=False, sessions=cleaned_sessions))
|
|
1377
1823
|
return 0
|
|
1378
1824
|
|
|
1379
1825
|
|
|
@@ -1667,6 +2113,69 @@ def handle_status(rest, ctx):
|
|
|
1667
2113
|
return 0
|
|
1668
2114
|
|
|
1669
2115
|
|
|
2116
|
+
def handle_reset(rest, ctx):
|
|
2117
|
+
parsed = _parse_flag_args(rest, {
|
|
2118
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
2119
|
+
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
2120
|
+
}, "Usage: cdx reset <name> [--yes] [--json]", positionals_key="args", max_positionals=1)
|
|
2121
|
+
if len(parsed["args"]) != 1:
|
|
2122
|
+
raise CdxError("Usage: cdx reset <name> [--yes] [--json]")
|
|
2123
|
+
name = parsed["args"][0]
|
|
2124
|
+
session = ctx["service"]["get_session"](name)
|
|
2125
|
+
if not session:
|
|
2126
|
+
raise CdxError(f"Unknown session: {name}")
|
|
2127
|
+
if session["provider"] != PROVIDER_CODEX:
|
|
2128
|
+
raise CdxError("Banked rate-limit resets are only available for Codex sessions.")
|
|
2129
|
+
|
|
2130
|
+
status = ctx["service"]["get_status_row"](name, force_refresh=True)
|
|
2131
|
+
if status.get("reset_credits_available") == 0:
|
|
2132
|
+
raise CdxError(f"No banked Codex reset is available for {name}.")
|
|
2133
|
+
if not parsed["yes"]:
|
|
2134
|
+
confirm_fn = ctx["options"].get("confirmReset")
|
|
2135
|
+
if confirm_fn:
|
|
2136
|
+
confirmed = _resolve_confirmation(confirm_fn, name)
|
|
2137
|
+
elif not ctx["stdin_is_tty"]:
|
|
2138
|
+
raise CdxError("Reset activation requires an interactive terminal or --yes in non-interactive mode.")
|
|
2139
|
+
else:
|
|
2140
|
+
confirmed = _confirm_reset(name)
|
|
2141
|
+
if not confirmed:
|
|
2142
|
+
if parsed["json"]:
|
|
2143
|
+
_write_json(ctx, _json_success("reset", "Cancelled.", cancelled=True, session=name))
|
|
2144
|
+
return 0
|
|
2145
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
2146
|
+
return 0
|
|
2147
|
+
|
|
2148
|
+
credit_rows = status.get("reset_credits") or []
|
|
2149
|
+
credit_id = credit_rows[0].get("id") if credit_rows else None
|
|
2150
|
+
consumer = ctx["options"].get("consumeCodexReset") or consume_codex_rate_limit_reset_credit
|
|
2151
|
+
result = consumer(
|
|
2152
|
+
session,
|
|
2153
|
+
ctx["options"].get("resetIdempotencyKey") or str(uuid.uuid4()),
|
|
2154
|
+
credit_id=credit_id,
|
|
2155
|
+
)
|
|
2156
|
+
if not result.get("ok"):
|
|
2157
|
+
if result.get("reason") == "reset_consume_failed":
|
|
2158
|
+
raise CdxError(
|
|
2159
|
+
f"Unable to activate Codex reset for {name}. "
|
|
2160
|
+
"The installed Codex version or account may not support reset activation."
|
|
2161
|
+
)
|
|
2162
|
+
raise CdxError(f"Unable to activate Codex reset for {name}: {result.get('reason') or 'unknown error'}")
|
|
2163
|
+
outcome = result.get("outcome")
|
|
2164
|
+
if outcome == "noCredit":
|
|
2165
|
+
raise CdxError(f"No banked Codex reset is available for {name}.")
|
|
2166
|
+
if outcome == "nothingToReset":
|
|
2167
|
+
raise CdxError(f"No current Codex rate-limit window is eligible for reset on {name}.")
|
|
2168
|
+
if outcome not in ("reset", "alreadyRedeemed"):
|
|
2169
|
+
raise CdxError(f"Unexpected Codex reset outcome for {name}: {outcome or 'missing'}")
|
|
2170
|
+
refreshed = ctx["service"]["get_status_row"](name, force_refresh=True)
|
|
2171
|
+
message = f"Activated banked Codex reset for {name}"
|
|
2172
|
+
if parsed["json"]:
|
|
2173
|
+
_write_json(ctx, _json_success("reset", message, cancelled=False, outcome=outcome, session=refreshed))
|
|
2174
|
+
return 0
|
|
2175
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
2176
|
+
return 0
|
|
2177
|
+
|
|
2178
|
+
|
|
1670
2179
|
def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
|
|
1671
2180
|
target_names = set(target_names or [])
|
|
1672
2181
|
errors = []
|
package/src/cli_helpers.py
CHANGED
|
@@ -68,6 +68,13 @@ def _update_notice_warning(ctx):
|
|
|
68
68
|
def _update_notice_warnings(ctx):
|
|
69
69
|
warnings = []
|
|
70
70
|
for notice in ctx.get("update_notices") or []:
|
|
71
|
+
if notice.get("code") == "disk_cleanup_available":
|
|
72
|
+
warnings.append({
|
|
73
|
+
"code": "disk_cleanup_available",
|
|
74
|
+
"message": notice["message"],
|
|
75
|
+
**{key: value for key, value in notice.items() if key not in ("code", "message")},
|
|
76
|
+
})
|
|
77
|
+
continue
|
|
71
78
|
tool = notice.get("tool") or "cdx-manager"
|
|
72
79
|
current = notice.get("current_version") or ctx.get("version")
|
|
73
80
|
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|