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.
@@ -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 .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_OLLAMA
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
- _probe_provider_auth,
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
- authenticated = _probe_provider_auth(
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, authenticated=authenticated, now=now: {
2211
+ service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
1691
2212
  **auth,
1692
- "status": "authenticated" if authenticated else "logged_out",
2213
+ "status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
1693
2214
  "lastCheckedAt": now,
1694
- **({"lastAuthenticatedAt": now} if authenticated else {}),
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 ""