codex-manager 7.0.0__tar.gz → 9.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 (87) hide show
  1. {codex_manager-7.0.0 → codex_manager-9.0.0}/PKG-INFO +1 -1
  2. {codex_manager-7.0.0 → codex_manager-9.0.0}/pyproject.toml +1 -1
  3. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/__init__.py +1 -1
  4. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/account_status.py +13 -8
  5. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cli.py +6 -4
  6. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cooldown.py +38 -11
  7. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/status.py +12 -5
  8. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/PKG-INFO +1 -1
  9. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_account_status_coverage.py +22 -0
  10. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_more.py +26 -0
  11. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cooldown.py +19 -0
  12. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status2.py +20 -0
  13. {codex_manager-7.0.0 → codex_manager-9.0.0}/README.md +0 -0
  14. {codex_manager-7.0.0 → codex_manager-9.0.0}/setup.cfg +0 -0
  15. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/args.py +0 -0
  16. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/backup.py +0 -0
  17. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cloud.py +0 -0
  18. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/config.py +0 -0
  19. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/credentials.py +0 -0
  20. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/doctor.py +0 -0
  21. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/list_backups.py +0 -0
  22. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/profile.py +0 -0
  23. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/prune.py +0 -0
  24. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/prune_backups.py +0 -0
  25. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/purge.py +0 -0
  26. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/recommend.py +0 -0
  27. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/registry.py +0 -0
  28. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/remove.py +0 -0
  29. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/restore.py +0 -0
  30. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/sync.py +0 -0
  31. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/ui.py +0 -0
  32. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/use_account.py +0 -0
  33. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/utils.py +0 -0
  34. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/SOURCES.txt +0 -0
  35. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
  36. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
  37. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/requires.txt +0 -0
  38. {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
  39. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_account_status_more2.py +0 -0
  40. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli.py +0 -0
  41. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli_3.py +0 -0
  42. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli_extra.py +0 -0
  43. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup.py +0 -0
  44. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup2.py +0 -0
  45. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup_ux.py +0 -0
  46. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli2.py +0 -0
  47. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage.py +0 -0
  48. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_2.py +0 -0
  49. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_3.py +0 -0
  50. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_4.py +0 -0
  51. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_5.py +0 -0
  52. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_6.py +0 -0
  53. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_7.py +0 -0
  54. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_8.py +0 -0
  55. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_extra.py +0 -0
  56. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cloud.py +0 -0
  57. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_config.py +0 -0
  58. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cooldown2.py +0 -0
  59. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_credentials2.py +0 -0
  60. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_doctor.py +0 -0
  61. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_doctor2.py +0 -0
  62. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_dry_run_coverage.py +0 -0
  63. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups.py +0 -0
  64. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups2.py +0 -0
  65. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups3.py +0 -0
  66. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups_coverage.py +0 -0
  67. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_next_gen_upgrade.py +0 -0
  68. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_profile.py +0 -0
  69. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune.py +0 -0
  70. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune_backups.py +0 -0
  71. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune_backups2.py +0 -0
  72. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_purge.py +0 -0
  73. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_recommend.py +0 -0
  74. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_recommend2.py +0 -0
  75. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_registry_coverage_more.py +0 -0
  76. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_registry_dry_run_coverage.py +0 -0
  77. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_remove.py +0 -0
  78. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_restore.py +0 -0
  79. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_restore2.py +0 -0
  80. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status.py +0 -0
  81. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status3.py +0 -0
  82. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_sync.py +0 -0
  83. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_sync2.py +0 -0
  84. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_ui.py +0 -0
  85. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_ui_2.py +0 -0
  86. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_use.py +0 -0
  87. {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_use2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 7.0.0
3
+ Version: 9.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-manager"
3
- version = "7.0.0"
3
+ version = "9.0.0"
4
4
  description = "Codex account snapshot manager"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "7.0.0"
3
+ __version__ = "9.0.0"
@@ -30,6 +30,10 @@ def patch_metadata(
30
30
  # We will compute the final reset_at and session_start_at to save to registry
31
31
  final_reset_at = reset_at
32
32
  final_session_start_at = session_start_at
33
+ if is_expired and final_reset_at is None:
34
+ final_reset_at = datetime.now().astimezone()
35
+ if is_expired and final_session_start_at is None and final_reset_at is not None:
36
+ final_session_start_at = final_reset_at - timedelta(days=7)
33
37
 
34
38
  if backup_dir.exists():
35
39
  # Find any metadata file containing this email
@@ -151,15 +155,16 @@ def patch_metadata(
151
155
  try:
152
156
  cp.download_file(metadata_name, local_metadata)
153
157
  data = json.loads(local_metadata.read_text(encoding="utf-8"))
154
- data["reset_at"] = (
155
- reset_at.isoformat() if hasattr(reset_at, "isoformat") else str(reset_at)
156
- )
157
- data["next_available_at"] = data["reset_at"]
158
- if session_start_at:
158
+ if final_reset_at is not None:
159
+ data["reset_at"] = (
160
+ final_reset_at.isoformat() if hasattr(final_reset_at, "isoformat") else str(final_reset_at)
161
+ )
162
+ data["next_available_at"] = data["reset_at"]
163
+ if final_session_start_at:
159
164
  data["session_start_at"] = (
160
- session_start_at.isoformat()
161
- if hasattr(session_start_at, "isoformat")
162
- else str(session_start_at)
165
+ final_session_start_at.isoformat()
166
+ if hasattr(final_session_start_at, "isoformat")
167
+ else str(final_session_start_at)
163
168
  )
164
169
  data["quota_text"] = quota_text
165
170
  if quota_percent_left is not None:
@@ -47,12 +47,11 @@ def list_entries_from_args(args: Any) -> list[BackupEntry]:
47
47
  )
48
48
  )
49
49
 
50
- if getattr(args, "cloud", False) or force_latest:
50
+ if getattr(args, "cloud", False):
51
51
  cp = get_cloud_provider(args)
52
52
  if cp:
53
53
  # Sync registry (cooldown.json) when cloud is enabled
54
- if getattr(args, "cloud", False):
55
- sync_registry_with_cloud(cp)
54
+ sync_registry_with_cloud(cp)
56
55
 
57
56
  all_entries.extend(
58
57
  list_cloud_backups(
@@ -72,7 +71,8 @@ def list_entries_from_args(args: Any) -> list[BackupEntry]:
72
71
  sys.exit(1)
73
72
 
74
73
  if latest_per_email:
75
- all_entries.sort(key=lambda entry: entry.created_at, reverse=True)
74
+ # Sort by reset_at descending so the newest state is picked
75
+ all_entries.sort(key=lambda entry: entry.reset_at, reverse=True)
76
76
  seen_emails: dict[str, BackupEntry] = {}
77
77
  for entry in all_entries:
78
78
  if entry.email not in seen_emails:
@@ -166,6 +166,8 @@ def build_live_status(args: Any) -> CooldownStatus | None:
166
166
  validation_status="live",
167
167
  proposed_archive_name=live_status.proposed_archive_name,
168
168
  remaining_seconds=max(0, remaining_seconds),
169
+ quota_text=live_status.quota_text,
170
+ quota_percent_left=live_status.quota_percent_left,
169
171
  is_expired=live_status.is_expired,
170
172
  )
171
173
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from datetime import datetime
4
+ from datetime import datetime, timedelta
5
5
 
6
6
  from .list_backups import BackupEntry
7
7
 
@@ -16,6 +16,8 @@ class CooldownStatus:
16
16
  validation_status: str
17
17
  proposed_archive_name: str
18
18
  remaining_seconds: int
19
+ quota_text: str | None = None
20
+ quota_percent_left: int | None = None
19
21
  is_expired: bool = False
20
22
 
21
23
 
@@ -34,9 +36,6 @@ def evaluate_entry(entry: BackupEntry, now: datetime | None = None) -> CooldownS
34
36
  remaining_seconds = int((next_available_at - current).total_seconds())
35
37
  status = "ready" if remaining_seconds <= 0 else "cooldown"
36
38
 
37
- # metadata for entry might have is_expired
38
- # we need to load it if not already there, but BackupEntry doesn't have it.
39
- # However, list_backups.py build_backup_entry could be updated to include it.
40
39
  is_expired = getattr(entry, "is_expired", False)
41
40
 
42
41
  return CooldownStatus(
@@ -48,6 +47,8 @@ def evaluate_entry(entry: BackupEntry, now: datetime | None = None) -> CooldownS
48
47
  validation_status="backup",
49
48
  proposed_archive_name=entry.archive_path.name,
50
49
  remaining_seconds=max(0, remaining_seconds),
50
+ quota_text=getattr(entry, "quota_text", None),
51
+ quota_percent_left=getattr(entry, "quota_percent_left", None),
51
52
  is_expired=is_expired,
52
53
  )
53
54
 
@@ -66,10 +67,12 @@ def evaluate_records(
66
67
  current = now.astimezone() if now is not None else datetime.now().astimezone()
67
68
 
68
69
  for email, reg_entry in registry_data.items():
69
- if "reset_at" not in reg_entry or "updated_at" not in reg_entry:
70
+ if "updated_at" not in reg_entry:
70
71
  continue
71
72
 
72
73
  reg_updated_at = parse_iso_datetime(reg_entry["updated_at"])
74
+ reg_reset_at = reg_entry.get("reset_at")
75
+ reg_is_expired = reg_entry.get("is_expired", False)
73
76
 
74
77
  # Check if we already have a status for this email from backups
75
78
  existing_idx = next((i for i, s in enumerate(statuses) if s.email == email), None)
@@ -78,8 +81,14 @@ def evaluate_records(
78
81
  existing_status = statuses[existing_idx]
79
82
  # If registry is newer, update the status
80
83
  if reg_updated_at > existing_status.quota_end_detected_at:
81
- next_available_at = parse_iso_datetime(reg_entry["reset_at"])
82
- session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_entry["reset_at"]))
84
+ if reg_reset_at is not None:
85
+ next_available_at = parse_iso_datetime(reg_reset_at)
86
+ session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
87
+ elif reg_is_expired:
88
+ next_available_at = existing_status.next_available_at
89
+ session_start_at = existing_status.session_start_at
90
+ else:
91
+ continue
83
92
  remaining_seconds = int((next_available_at - current).total_seconds())
84
93
  statuses[existing_idx] = CooldownStatus(
85
94
  email=email,
@@ -90,12 +99,20 @@ def evaluate_records(
90
99
  validation_status="registry",
91
100
  proposed_archive_name=existing_status.proposed_archive_name,
92
101
  remaining_seconds=max(0, remaining_seconds),
93
- is_expired=reg_entry.get("is_expired", False)
102
+ quota_text=reg_entry.get("quota_text"),
103
+ quota_percent_left=reg_entry.get("quota_percent_left"),
104
+ is_expired=reg_is_expired
94
105
  )
95
106
  else:
96
107
  # Create a new status from registry
97
- next_available_at = parse_iso_datetime(reg_entry["reset_at"])
98
- session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_entry["reset_at"]))
108
+ if reg_reset_at is not None:
109
+ next_available_at = parse_iso_datetime(reg_reset_at)
110
+ session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
111
+ elif reg_is_expired:
112
+ next_available_at = reg_updated_at
113
+ session_start_at = reg_updated_at - timedelta(days=7)
114
+ else:
115
+ continue
99
116
  remaining_seconds = int((next_available_at - current).total_seconds())
100
117
  statuses.append(
101
118
  CooldownStatus(
@@ -107,7 +124,9 @@ def evaluate_records(
107
124
  validation_status="registry",
108
125
  proposed_archive_name="none",
109
126
  remaining_seconds=max(0, remaining_seconds),
110
- is_expired=reg_entry.get("is_expired", False)
127
+ quota_text=reg_entry.get("quota_text"),
128
+ quota_percent_left=reg_entry.get("quota_percent_left"),
129
+ is_expired=reg_is_expired
111
130
  )
112
131
  )
113
132
 
@@ -145,6 +164,7 @@ def print_statuses_table(statuses: list[CooldownStatus], live_email: str | None
145
164
  table = Table(show_header=True, header_style="bold bright_magenta")
146
165
  table.add_column("Account", style="bright_cyan")
147
166
  table.add_column("Status", justify="center", no_wrap=True)
167
+ table.add_column("Quota", justify="right", style="bright_yellow")
148
168
  table.add_column("Available", justify="right", style="bright_yellow")
149
169
  table.add_column("Session Start", justify="right", style="dim")
150
170
  table.add_column("Reset At", justify="right", style="dim")
@@ -161,9 +181,16 @@ def print_statuses_table(statuses: list[CooldownStatus], live_email: str | None
161
181
  else:
162
182
  status_display = f"[bold bright_green]{status.status.upper()}[/]" if status.status == "ready" else f"[bold bright_yellow]{status.status.upper()}[/]"
163
183
 
184
+ quota_display = (
185
+ f"{status.quota_percent_left}%"
186
+ if status.quota_percent_left is not None
187
+ else "unknown"
188
+ )
189
+
164
190
  table.add_row(
165
191
  account_display,
166
192
  status_display,
193
+ quota_display,
167
194
  format_remaining(status.remaining_seconds),
168
195
  status.session_start_at.strftime("%Y-%m-%d %H:%M:%S"),
169
196
  status.next_available_at.strftime("%Y-%m-%d %H:%M:%S"),
@@ -109,11 +109,14 @@ def capture_tmux_status_text(
109
109
  if has_session:
110
110
  run_command(["tmux", "kill-session", "-t", session_name], check=False)
111
111
 
112
- run_command(
112
+ session = run_command(
113
113
  [
114
114
  "tmux",
115
115
  "new-session",
116
116
  "-d",
117
+ "-P",
118
+ "-F",
119
+ "#{pane_id}",
117
120
  "-s",
118
121
  session_name,
119
122
  "-x",
@@ -123,12 +126,16 @@ def capture_tmux_status_text(
123
126
  codex_command,
124
127
  ]
125
128
  )
129
+ pane_id = session.stdout.strip()
130
+ if not pane_id:
131
+ raise RuntimeError("tmux did not return a pane id for the temporary capture session.")
132
+ run_command(["tmux", "set-option", "-t", session_name, "remain-on-exit", "on"])
126
133
 
127
134
  try:
128
135
  start = time.time()
129
136
  with console.status("[cyan]Waiting for Codex prompt...[/cyan]", spinner="dots"):
130
137
  while True:
131
- output = run_command(["tmux", "capture-pane", "-t", session_name, "-p"]).stdout
138
+ output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
132
139
  if "›" in output:
133
140
  break
134
141
 
@@ -136,13 +143,13 @@ def capture_tmux_status_text(
136
143
  raise RuntimeError("Timed out waiting for Codex prompt.")
137
144
  time.sleep(0.5)
138
145
 
139
- run_command(["tmux", "send-keys", "-t", session_name, "/status", "Enter"])
146
+ run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
140
147
 
141
148
  start = time.time()
142
149
  last_retry = start
143
150
  with console.status("[cyan]Checking Codex status...[/cyan]", spinner="dots"):
144
151
  while True:
145
- output = run_command(["tmux", "capture-pane", "-t", session_name, "-p"]).stdout
152
+ output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
146
153
 
147
154
  # If we have the full panel, return it
148
155
  if "Account:" in output and "Weekly limit:" in output:
@@ -163,7 +170,7 @@ def capture_tmux_status_text(
163
170
 
164
171
  # Periodic retry of /status if it seems stuck or refreshing
165
172
  if time.time() - last_retry > 5.0:
166
- run_command(["tmux", "send-keys", "-t", session_name, "/status", "Enter"])
173
+ run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
167
174
  last_retry = time.time()
168
175
 
169
176
  time.sleep(0.5)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 7.0.0
3
+ Version: 9.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -55,6 +55,28 @@ def test_patch_metadata_fallback_datetime_parsing(mocker, tmp_path) -> None:
55
55
  mock_update.assert_called_once()
56
56
 
57
57
 
58
+ def test_patch_metadata_expired_without_existing_metadata_sets_registry_times(mocker, tmp_path) -> None:
59
+ class Args:
60
+ pass
61
+
62
+ args = Args()
63
+ args.backup_dir = str(tmp_path / "missing-backups")
64
+
65
+ mock_update = mocker.patch("codex_manager.account_status.update_registry_entry")
66
+
67
+ patch_metadata(
68
+ "expired@example.com",
69
+ quota_text="TOKEN EXPIRED: Re-login required.",
70
+ args=args,
71
+ is_expired=True,
72
+ )
73
+
74
+ call = mock_update.call_args.kwargs
75
+ assert call["is_expired"] is True
76
+ assert call["reset_at"] is not None
77
+ assert call["session_start_at"] is not None
78
+
79
+
58
80
 
59
81
  def test_sync_current_account_status_no_email(tmp_path, capsys):
60
82
  class Args:
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
+ from unittest.mock import MagicMock
4
5
 
5
6
  from codex_manager.cli import (
6
7
  _ensure_cloud_archive,
7
8
  _read_status_command_input,
8
9
  handle_status,
10
+ handle_recommend,
9
11
  list_entries_from_args,
10
12
  )
11
13
  from codex_manager.status import TokenExpiredError
@@ -67,3 +69,27 @@ def test_handle_status_token_expired(mocker, capsys):
67
69
 
68
70
  with pytest.raises(SystemExit):
69
71
  handle_status(args)
72
+
73
+
74
+ def test_handle_recommend_without_cloud_does_not_fetch_cloud(mocker, tmp_path):
75
+ class Args:
76
+ command = "recommend"
77
+ backup_dir = str(tmp_path)
78
+ cloud = False
79
+ live = False
80
+
81
+ args = Args()
82
+ mocker.patch("codex_manager.cli.list_backups", return_value=[])
83
+ mock_cloud = mocker.patch("codex_manager.cli.list_cloud_backups")
84
+ recommendation = MagicMock()
85
+ recommendation.selected.email = "test@example.com"
86
+ recommendation.selected.status = "ready"
87
+ recommendation.selected.remaining_seconds = 0
88
+ recommendation.selected.next_available_at.strftime.return_value = "2026-04-29 00:00:00 +0000"
89
+ recommendation.selected.validation_status = "backup"
90
+ recommendation.reason = "ready now"
91
+ mocker.patch("codex_manager.cli.choose_best_account", return_value=recommendation)
92
+
93
+ handle_recommend(args)
94
+
95
+ mock_cloud.assert_not_called()
@@ -83,6 +83,25 @@ def test_evaluate_records_merges_live_status(mock_reg) -> None:
83
83
  assert statuses[0].next_available_at.day == 20
84
84
 
85
85
 
86
+ @patch(
87
+ "codex_manager.registry.load_registry",
88
+ return_value={
89
+ "expired@example.com": {
90
+ "updated_at": "2026-04-21T16:00:00+00:00",
91
+ "is_expired": True,
92
+ "quota_text": "TOKEN EXPIRED: Re-login required.",
93
+ }
94
+ },
95
+ )
96
+ def test_evaluate_records_includes_expired_registry_entries_without_reset_at(mock_reg) -> None:
97
+ statuses = evaluate_records([], now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
98
+ assert len(statuses) == 1
99
+ assert statuses[0].email == "expired@example.com"
100
+ assert statuses[0].validation_status == "registry"
101
+ assert statuses[0].is_expired is True
102
+ assert statuses[0].status == "ready"
103
+
104
+
86
105
  def test_format_remaining() -> None:
87
106
  assert format_remaining(0) == "now"
88
107
  assert format_remaining(5400) == "1h 30m"
@@ -56,6 +56,8 @@ def test_capture_tmux_status_text_timeout(mock_sleep, mock_run):
56
56
  @patch("codex_manager.status.time.sleep")
57
57
  def test_capture_tmux_status_text_success(mock_sleep, mock_run):
58
58
  def mock_run_side_effect(args, **kwargs):
59
+ if args[:3] == ["tmux", "new-session", "-d"]:
60
+ return MagicMock(stdout="%42")
59
61
  if "capture-pane" in args:
60
62
  # We need to simulate the prompt '›' appearing, then the status appearing.
61
63
  # Let's count how many times capture-pane is called
@@ -72,11 +74,16 @@ def test_capture_tmux_status_text_success(mock_sleep, mock_run):
72
74
  mock_run.side_effect = mock_run_side_effect
73
75
  res = capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
74
76
  assert "Account: a@b.com" in res
77
+ capture_targets = [call.args[0] for call in mock_run.call_args_list if "capture-pane" in call.args[0]]
78
+ assert capture_targets
79
+ assert all("%42" in args for args in capture_targets)
75
80
 
76
81
  @patch("codex_manager.status.run_command")
77
82
  @patch("codex_manager.status.time.sleep")
78
83
  def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
79
84
  def mock_run_side_effect(args, **kwargs):
85
+ if args[:3] == ["tmux", "new-session", "-d"]:
86
+ return MagicMock(stdout="%42")
80
87
  if "capture-pane" in args:
81
88
  # First phase: '›'
82
89
  # Second phase: status timeout
@@ -98,6 +105,19 @@ def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
98
105
  with pytest.raises(RuntimeError):
99
106
  capture_tmux_status_text(startup_timeout_seconds=10.0, status_timeout_seconds=5.0)
100
107
 
108
+
109
+ @patch("codex_manager.status.run_command")
110
+ @patch("codex_manager.status.time.sleep")
111
+ def test_capture_tmux_status_text_requires_pane_id(mock_sleep, mock_run):
112
+ def mock_run_side_effect(args, **kwargs):
113
+ if args[:3] == ["tmux", "new-session", "-d"]:
114
+ return MagicMock(stdout="")
115
+ return MagicMock()
116
+
117
+ mock_run.side_effect = mock_run_side_effect
118
+ with pytest.raises(RuntimeError, match="pane id"):
119
+ capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
120
+
101
121
  @patch("codex_manager.status.subprocess.run")
102
122
  def test_run_command_fail(mock_run):
103
123
  mock_run.return_value = MagicMock(returncode=1)
File without changes
File without changes