codex-manager 1.0.1__tar.gz → 3.0.0__tar.gz

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.
Files changed (84) hide show
  1. codex_manager-3.0.0/CODEX_MANAGER_SPEC.md +49 -0
  2. codex_manager-3.0.0/PKG-INFO +83 -0
  3. {codex_manager-1.0.1 → codex_manager-3.0.0}/pyproject.toml +2 -1
  4. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/__init__.py +1 -1
  5. codex_manager-3.0.0/src/codex_manager/account_status.py +289 -0
  6. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/args.py +295 -87
  7. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/backup.py +99 -25
  8. codex_manager-3.0.0/src/codex_manager/cli.py +434 -0
  9. codex_manager-3.0.0/src/codex_manager/cloud.py +67 -0
  10. codex_manager-3.0.0/src/codex_manager/config.py +34 -0
  11. codex_manager-3.0.0/src/codex_manager/cooldown.py +217 -0
  12. codex_manager-3.0.0/src/codex_manager/credentials.py +90 -0
  13. codex_manager-3.0.0/src/codex_manager/doctor.py +112 -0
  14. codex_manager-3.0.0/src/codex_manager/list_backups.py +264 -0
  15. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/profile.py +2 -1
  16. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/prune.py +0 -2
  17. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/prune_backups.py +9 -10
  18. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/recommend.py +4 -4
  19. codex_manager-3.0.0/src/codex_manager/registry.py +113 -0
  20. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager/restore.py +65 -27
  21. codex_manager-3.0.0/src/codex_manager/status.py +262 -0
  22. codex_manager-3.0.0/src/codex_manager/sync.py +99 -0
  23. codex_manager-3.0.0/src/codex_manager/ui.py +134 -0
  24. codex_manager-3.0.0/src/codex_manager/use_account.py +79 -0
  25. codex_manager-3.0.0/src/codex_manager/utils.py +13 -0
  26. codex_manager-3.0.0/src/codex_manager.egg-info/PKG-INFO +83 -0
  27. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager.egg-info/SOURCES.txt +28 -6
  28. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager.egg-info/requires.txt +1 -0
  29. codex_manager-3.0.0/tests/test_args_cli.py +139 -0
  30. codex_manager-3.0.0/tests/test_args_cli_3.py +15 -0
  31. codex_manager-3.0.0/tests/test_args_cli_extra.py +38 -0
  32. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_backup.py +29 -1
  33. codex_manager-3.0.0/tests/test_backup2.py +76 -0
  34. codex_manager-3.0.0/tests/test_cli2.py +83 -0
  35. codex_manager-3.0.0/tests/test_cloud.py +75 -0
  36. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_config.py +1 -1
  37. codex_manager-3.0.0/tests/test_cooldown.py +87 -0
  38. codex_manager-3.0.0/tests/test_cooldown2.py +57 -0
  39. codex_manager-3.0.0/tests/test_credentials2.py +75 -0
  40. codex_manager-3.0.0/tests/test_doctor.py +81 -0
  41. codex_manager-3.0.0/tests/test_doctor2.py +53 -0
  42. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_list_backups.py +1 -1
  43. codex_manager-3.0.0/tests/test_list_backups2.py +61 -0
  44. codex_manager-3.0.0/tests/test_list_backups3.py +103 -0
  45. codex_manager-3.0.0/tests/test_next_gen_upgrade.py +103 -0
  46. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_prune_backups.py +1 -1
  47. codex_manager-3.0.0/tests/test_prune_backups2.py +52 -0
  48. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_recommend.py +29 -25
  49. codex_manager-3.0.0/tests/test_recommend2.py +23 -0
  50. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_restore.py +1 -2
  51. codex_manager-3.0.0/tests/test_restore2.py +76 -0
  52. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_status.py +1 -1
  53. codex_manager-3.0.0/tests/test_status2.py +105 -0
  54. codex_manager-3.0.0/tests/test_status3.py +19 -0
  55. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_sync.py +1 -4
  56. codex_manager-3.0.0/tests/test_sync2.py +77 -0
  57. codex_manager-3.0.0/tests/test_ui.py +63 -0
  58. codex_manager-3.0.0/tests/test_ui_2.py +35 -0
  59. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_use.py +2 -2
  60. codex_manager-3.0.0/tests/test_use2.py +48 -0
  61. codex_manager-1.0.1/CODEX_MANAGER_SPEC.md +0 -427
  62. codex_manager-1.0.1/PKG-INFO +0 -460
  63. codex_manager-1.0.1/README.md +0 -154
  64. codex_manager-1.0.1/src/codex_manager/cli.py +0 -243
  65. codex_manager-1.0.1/src/codex_manager/config.py +0 -50
  66. codex_manager-1.0.1/src/codex_manager/cooldown.py +0 -113
  67. codex_manager-1.0.1/src/codex_manager/doctor.py +0 -69
  68. codex_manager-1.0.1/src/codex_manager/inventory.py +0 -32
  69. codex_manager-1.0.1/src/codex_manager/list_backups.py +0 -129
  70. codex_manager-1.0.1/src/codex_manager/normalize.py +0 -119
  71. codex_manager-1.0.1/src/codex_manager/status.py +0 -180
  72. codex_manager-1.0.1/src/codex_manager/sync.py +0 -97
  73. codex_manager-1.0.1/src/codex_manager/use_account.py +0 -112
  74. codex_manager-1.0.1/src/codex_manager.egg-info/PKG-INFO +0 -460
  75. codex_manager-1.0.1/tests/test_cooldown.py +0 -85
  76. codex_manager-1.0.1/tests/test_doctor.py +0 -67
  77. codex_manager-1.0.1/tests/test_inventory.py +0 -30
  78. codex_manager-1.0.1/tests/test_normalize.py +0 -92
  79. {codex_manager-1.0.1 → codex_manager-3.0.0}/setup.cfg +0 -0
  80. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
  81. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
  82. {codex_manager-1.0.1 → codex_manager-3.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
  83. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_profile.py +0 -0
  84. {codex_manager-1.0.1 → codex_manager-3.0.0}/tests/test_prune.py +0 -0
@@ -0,0 +1,49 @@
1
+ # Codex Manager
2
+
3
+ `codex-manager` is a CLI for backing up Codex account state and tracking account cooldown data from live `/status` output.
4
+
5
+ ## Core model
6
+
7
+ - Backups are stored as `*.tar.gz` archives plus adjacent `*.metadata.json`.
8
+ - Metadata is the source of truth for `cooldown`, `recommend`, and account rotation decisions.
9
+ - Live Codex `/status` output is parsed to capture:
10
+ - account email
11
+ - quota text
12
+ - quota percent left when available
13
+ - weekly reset timestamp
14
+
15
+ ## Main commands
16
+
17
+ - `cm backup`: capture live status, build archive metadata, and create a backup.
18
+ - `cm status`: parse live status and patch the latest metadata for the current account.
19
+ - `cm cooldown`: show account availability from stored metadata, optionally merged with live status.
20
+ - `cm recommend`: choose the best account to use next.
21
+ - `cm use`: switch to another account, defaulting to auth-only restore unless `--clean` is used.
22
+ - `cm restore`: restore a full backup into the Codex home.
23
+
24
+ ## Status tracking policy
25
+
26
+ - `use` and `restore` sync the current account status before switching away from it.
27
+ - If live status capture fails, the command retries once.
28
+ - If status capture fails twice, the command exits and instructs the user to rerun with `--without-status-check`.
29
+
30
+ ## Emergency fallback
31
+
32
+ `--without-status-check` exists for cases where Codex layout changes or live status is temporarily unavailable.
33
+
34
+ In that mode:
35
+
36
+ - cooldown is estimated as `now + 7 days`
37
+ - metadata is still written so cooldown state is not lost
38
+ - fallback archive and metadata names are based on the estimated reset time, not the current time
39
+
40
+ ## Storage defaults
41
+
42
+ - manager home: `~/.codex-manager`
43
+ - backup directory: `~/.codex-manager/backups`
44
+ - Codex home: `~/.codex`
45
+
46
+ ## Cloud support
47
+
48
+ - Backblaze B2 is supported for remote backup metadata and archive storage.
49
+ - Local and cloud entries can be merged for recommendation and cooldown reporting.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-manager
3
+ Version: 3.0.0
4
+ Summary: Codex account snapshot manager
5
+ Author-email: Dhruv <dhruv13x@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dhruv13x/codex-manager
8
+ Project-URL: Repository, https://github.com/dhruv13x/codex-manager
9
+ Project-URL: Issues, https://github.com/dhruv13x/codex-manager/issues
10
+ Keywords: codex,backup,cli,automation
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Environment :: Console
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: rich>=13.0.0
19
+ Requires-Dist: boto3>=1.28.0
20
+ Requires-Dist: b2sdk>=2.0.0
21
+ Requires-Dist: relm>=7.1.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
25
+ Requires-Dist: pytest-timeout>=2.2.0; extra == "dev"
26
+ Requires-Dist: pytest-json-report>=1.5.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
28
+ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
29
+ Requires-Dist: pyfakefs>=5.0.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
31
+ Requires-Dist: black>=24.3.0; extra == "dev"
32
+ Requires-Dist: mypy>=1.11.0; extra == "dev"
33
+ Requires-Dist: types-PyYAML>=6.0; extra == "dev"
34
+
35
+ # Codex Manager
36
+
37
+ `codex-manager` is a CLI for backing up Codex account state and tracking account cooldown data from live `/status` output.
38
+
39
+ ## Core model
40
+
41
+ - Backups are stored as `*.tar.gz` archives plus adjacent `*.metadata.json`.
42
+ - Metadata is the source of truth for `cooldown`, `recommend`, and account rotation decisions.
43
+ - Live Codex `/status` output is parsed to capture:
44
+ - account email
45
+ - quota text
46
+ - quota percent left when available
47
+ - weekly reset timestamp
48
+
49
+ ## Main commands
50
+
51
+ - `cm backup`: capture live status, build archive metadata, and create a backup.
52
+ - `cm status`: parse live status and patch the latest metadata for the current account.
53
+ - `cm cooldown`: show account availability from stored metadata, optionally merged with live status.
54
+ - `cm recommend`: choose the best account to use next.
55
+ - `cm use`: switch to another account, defaulting to auth-only restore unless `--clean` is used.
56
+ - `cm restore`: restore a full backup into the Codex home.
57
+
58
+ ## Status tracking policy
59
+
60
+ - `use` and `restore` sync the current account status before switching away from it.
61
+ - If live status capture fails, the command retries once.
62
+ - If status capture fails twice, the command exits and instructs the user to rerun with `--without-status-check`.
63
+
64
+ ## Emergency fallback
65
+
66
+ `--without-status-check` exists for cases where Codex layout changes or live status is temporarily unavailable.
67
+
68
+ In that mode:
69
+
70
+ - cooldown is estimated as `now + 7 days`
71
+ - metadata is still written so cooldown state is not lost
72
+ - fallback archive and metadata names are based on the estimated reset time, not the current time
73
+
74
+ ## Storage defaults
75
+
76
+ - manager home: `~/.codex-manager`
77
+ - backup directory: `~/.codex-manager/backups`
78
+ - Codex home: `~/.codex`
79
+
80
+ ## Cloud support
81
+
82
+ - Backblaze B2 is supported for remote backup metadata and archive storage.
83
+ - Local and cloud entries can be merged for recommendation and cooldown reporting.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-manager"
3
- version = "1.0.1"
3
+ version = "3.0.0"
4
4
  description = "Codex account snapshot manager"
5
5
  readme = "CODEX_MANAGER_SPEC.md"
6
6
  requires-python = ">=3.10"
@@ -12,6 +12,7 @@ authors = [
12
12
  dependencies = [
13
13
  "rich>=13.0.0",
14
14
  "boto3>=1.28.0",
15
+ "b2sdk>=2.0.0",
15
16
  "relm>=7.1.1",
16
17
  ]
17
18
 
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "3.0.0"
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import tempfile
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .backup import read_status_text_from_args
11
+ from .cloud import get_cloud_provider
12
+ from .list_backups import list_backups, list_cloud_backups
13
+ from .registry import sync_registry_with_cloud, update_registry_entry
14
+ from .status import parse_live_status_text
15
+ from .ui import console
16
+ from .utils import build_archive_name
17
+
18
+
19
+ def patch_metadata(
20
+ email: str,
21
+ reset_at: Any | None = None,
22
+ quota_text: str | None = None,
23
+ quota_percent_left: int | None = None,
24
+ args: Any = None,
25
+ session_start_at: Any | None = None,
26
+ is_expired: bool = False,
27
+ ) -> None:
28
+ backup_dir = Path(args.backup_dir).expanduser() if args and hasattr(args, "backup_dir") else Path("~/.codex-manager/backups").expanduser()
29
+
30
+ # We will compute the final reset_at and session_start_at to save to registry
31
+ final_reset_at = reset_at
32
+ final_session_start_at = session_start_at
33
+
34
+ if backup_dir.exists():
35
+ # Find any metadata file containing this email
36
+ metadata_paths = []
37
+ for p in backup_dir.glob("*.metadata.json"):
38
+ if email in p.name:
39
+ metadata_paths.append(p)
40
+
41
+ if metadata_paths:
42
+ # Sort by name descending to get the latest
43
+ metadata_paths.sort(key=lambda p: p.name, reverse=True)
44
+ metadata_path = metadata_paths[0]
45
+ try:
46
+ data = json.loads(metadata_path.read_text(encoding="utf-8"))
47
+
48
+ if reset_at is not None:
49
+ data["reset_at"] = (
50
+ reset_at.isoformat() if hasattr(reset_at, "isoformat") else str(reset_at)
51
+ )
52
+ data["next_available_at"] = data["reset_at"]
53
+
54
+ if session_start_at is not None:
55
+ data["session_start_at"] = (
56
+ session_start_at.isoformat()
57
+ if hasattr(session_start_at, "isoformat")
58
+ else str(session_start_at)
59
+ )
60
+
61
+ # capture the final values from existing metadata if we didn't overwrite
62
+ if final_reset_at is None and "reset_at" in data:
63
+ from .cooldown import parse_iso_datetime
64
+ try:
65
+ final_reset_at = parse_iso_datetime(data["reset_at"])
66
+ except Exception:
67
+ pass
68
+ if final_session_start_at is None and "session_start_at" in data:
69
+ from .cooldown import parse_iso_datetime
70
+ try:
71
+ final_session_start_at = parse_iso_datetime(data["session_start_at"])
72
+ except Exception:
73
+ pass
74
+
75
+ if quota_text is not None:
76
+ data["quota_text"] = quota_text
77
+ if quota_percent_left is not None:
78
+ data["quota_percent_left"] = quota_percent_left
79
+ data["is_expired"] = is_expired
80
+ data["updated_at"] = datetime.now().astimezone().isoformat()
81
+ metadata_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
82
+ console.print(
83
+ f"Updated local metadata for [cyan]{email}[/]: [dim]{metadata_path.name}[/]"
84
+ )
85
+ except Exception as exc:
86
+ console.print(f"[yellow]Warning:[/] Failed to patch local metadata: {exc}")
87
+ else:
88
+ now = datetime.now().astimezone()
89
+ final_reset_at = reset_at or now
90
+ final_session_start_at = session_start_at or (now - timedelta(days=7))
91
+ archive_name = build_archive_name(final_reset_at, email)
92
+ metadata_path = backup_dir / archive_name.replace(".tar.gz", ".metadata.json")
93
+ data = {
94
+ "product": "codex",
95
+ "email": email,
96
+ "session_start_at": (
97
+ final_session_start_at.isoformat()
98
+ if hasattr(final_session_start_at, "isoformat")
99
+ else str(final_session_start_at)
100
+ ),
101
+ "next_available_at": (
102
+ final_reset_at.isoformat() if hasattr(final_reset_at, "isoformat") else str(final_reset_at)
103
+ ),
104
+ "reset_at": (
105
+ final_reset_at.isoformat() if hasattr(final_reset_at, "isoformat") else str(final_reset_at)
106
+ ),
107
+ "quota_text": quota_text or "unknown",
108
+ "quota_percent_left": quota_percent_left,
109
+ "is_expired": is_expired,
110
+ "archive_name": archive_name,
111
+ "created_at": now.isoformat(),
112
+ "status_source": "pre_switch_sync",
113
+ "metadata_only": True,
114
+ }
115
+ try:
116
+ metadata_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
117
+ console.print(
118
+ f"Created cooldown-only metadata for [cyan]{email}[/]: [dim]{metadata_path.name}[/]"
119
+ )
120
+ except Exception as exc:
121
+ console.print(f"[yellow]Warning:[/] Failed to create local metadata: {exc}")
122
+
123
+ update_registry_entry(
124
+ email=email,
125
+ reset_at=final_reset_at,
126
+ is_expired=is_expired,
127
+ quota_text=quota_text,
128
+ quota_percent_left=quota_percent_left,
129
+ session_start_at=final_session_start_at,
130
+ )
131
+
132
+ if args and getattr(args, "cloud", False):
133
+ cp = get_cloud_provider(args)
134
+ if cp:
135
+ sync_registry_with_cloud(cp)
136
+ entries = list_cloud_backups(cp, email=email, latest_per_email=True)
137
+ if entries:
138
+ selected = entries[0]
139
+ archive_name = selected.archive_path.name
140
+ metadata_name = archive_name.replace(".tar.gz", ".metadata.json")
141
+
142
+ with tempfile.TemporaryDirectory() as tmp:
143
+ local_metadata = Path(tmp) / metadata_name
144
+ try:
145
+ cp.download_file(metadata_name, local_metadata)
146
+ data = json.loads(local_metadata.read_text(encoding="utf-8"))
147
+ data["reset_at"] = (
148
+ reset_at.isoformat() if hasattr(reset_at, "isoformat") else str(reset_at)
149
+ )
150
+ data["next_available_at"] = data["reset_at"]
151
+ if session_start_at:
152
+ data["session_start_at"] = (
153
+ session_start_at.isoformat()
154
+ if hasattr(session_start_at, "isoformat")
155
+ else str(session_start_at)
156
+ )
157
+ data["quota_text"] = quota_text
158
+ if quota_percent_left is not None:
159
+ data["quota_percent_left"] = quota_percent_left
160
+ data["is_expired"] = is_expired
161
+ data["updated_at"] = datetime.now().astimezone().isoformat()
162
+ local_metadata.write_text(json.dumps(data, indent=2), encoding="utf-8")
163
+
164
+ console.print(
165
+ f"Uploading updated metadata to Cloud: [dim]{metadata_name}[/] ..."
166
+ )
167
+ cp.upload_file(local_metadata, metadata_name)
168
+ console.print("Cloud metadata update complete.")
169
+ except Exception as exc:
170
+ console.print(f"[yellow]Warning:[/] Failed to patch cloud metadata: {exc}")
171
+ else:
172
+ console.print("[yellow]Warning:[/] Cloud update requested but credentials not resolved.")
173
+
174
+
175
+ def sync_current_account_status(args: Any) -> None:
176
+ codex_home = Path(
177
+ getattr(
178
+ args,
179
+ "dest_dir",
180
+ args.source_dir if hasattr(args, "source_dir") else "~/.codex",
181
+ )
182
+ ).expanduser()
183
+ auth_path = codex_home / "auth.json"
184
+
185
+ current_email = None
186
+ if auth_path.exists():
187
+ try:
188
+ auth_data = json.loads(auth_path.read_text(encoding="utf-8"))
189
+ current_email = auth_data.get("email")
190
+ except Exception:
191
+ pass
192
+
193
+ if not current_email:
194
+ console.print(
195
+ "[yellow]Warning:[/] Could not identify current account from auth.json. "
196
+ "Skipping pre-switch status sync."
197
+ )
198
+ return
199
+
200
+ if getattr(args, "without_status_check", False):
201
+ now = datetime.now().astimezone()
202
+ session_start_at = now
203
+ reset_at = now + timedelta(days=7)
204
+
205
+ console.print(f"[yellow]Note:[/] Bypassing status check for [cyan]{current_email}[/].")
206
+ console.print(
207
+ "[yellow]Assuming exhaustion:[/] Next reset estimated for "
208
+ f"[bright_magenta]{reset_at.strftime('%Y-%m-%d %H:%M:%S')}[/]"
209
+ )
210
+
211
+ patch_metadata(
212
+ email=current_email,
213
+ reset_at=reset_at,
214
+ quota_text="Status capture bypassed via --without-status-check. Estimated +7 days cooldown.",
215
+ quota_percent_left=None,
216
+ args=args,
217
+ session_start_at=session_start_at,
218
+ )
219
+ return
220
+
221
+ console.print(f"Syncing status for current account: [cyan]{current_email}[/]")
222
+
223
+ text = None
224
+ from .status import TokenExpiredError
225
+ for attempt in range(1, 3):
226
+ try:
227
+ text = read_status_text_from_args(args)
228
+ if text:
229
+ break
230
+ except TokenExpiredError as exc:
231
+ console.print(f"[bold red]Error:[/] {exc}")
232
+ # Try to at least get the email from the error output
233
+ try:
234
+ from .status import parse_live_status_text
235
+ status = parse_live_status_text(exc.output)
236
+ patch_metadata(
237
+ email=status.email,
238
+ reset_at=None,
239
+ quota_text="TOKEN EXPIRED: Re-login required.",
240
+ quota_percent_left=None,
241
+ args=args,
242
+ session_start_at=None,
243
+ is_expired=True,
244
+ )
245
+ except Exception:
246
+ # If we can't even get the email, we use the one from auth.json
247
+ patch_metadata(
248
+ email=current_email,
249
+ reset_at=None,
250
+ quota_text="TOKEN EXPIRED: Re-login required.",
251
+ quota_percent_left=None,
252
+ args=args,
253
+ is_expired=True,
254
+ )
255
+ sys.exit(1)
256
+ except Exception as exc:
257
+ if attempt == 1:
258
+ console.print(f"[yellow]Status capture failed (attempt 1): {exc}. Try one more time...[/]")
259
+ else:
260
+ console.print(
261
+ f"[bold red]Error:[/] Status capture failed twice for [cyan]{current_email}[/]: {exc}"
262
+ )
263
+ console.print("\n[bold yellow]Next-Gen Safety Protocol:[/]")
264
+ console.print("If Codex has changed its layout or status is unavailable, you MUST use:")
265
+ console.print(f" [bright_cyan]cm {args.command} --without-status-check ...[/]")
266
+ console.print("[dim]This will safely assume a 7-day cooldown for the current account.[/]")
267
+ sys.exit(1)
268
+
269
+ if text:
270
+ try:
271
+ status = parse_live_status_text(
272
+ text,
273
+ reference_year=getattr(args, "reference_year", None),
274
+ )
275
+ patch_metadata(
276
+ email=status.email,
277
+ reset_at=status.reset_at,
278
+ quota_text=status.quota_text,
279
+ quota_percent_left=status.quota_percent_left,
280
+ args=args,
281
+ session_start_at=status.session_start_at,
282
+ is_expired=status.is_expired,
283
+ )
284
+ except Exception as exc:
285
+ console.print(
286
+ f"[bold red]Error:[/] Failed to parse status for [cyan]{current_email}[/]: {exc}"
287
+ )
288
+ console.print("[dim]Use --without-status-check if Codex layout has changed.[/]")
289
+ sys.exit(1)