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