cdx-manager 0.6.3 → 0.6.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -134,7 +134,7 @@ For a specific version:
134
134
 
135
135
  ```bash
136
136
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
137
- CDX_VERSION=v0.6.3 sh install.sh
137
+ CDX_VERSION=v0.6.4 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -0,0 +1,31 @@
1
+ # Changelog (`0.6.3 -> 0.6.4`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Claude Multi-Account Authentication
6
+
7
+ - Fixed Claude auth status reconciliation so `cdx status` no longer reports stale `authenticated` state after Claude CLI credentials are missing or logged out.
8
+ - Stopped trusting `credentials/default.json` alone as proof of Claude login; cdx now validates real Claude CLI credentials or probes `claude auth status`.
9
+ - Preferred Claude CLI credentials over setup-token fallback files for usage refreshes, avoiding HTTP 400 failures from stale or malformed fallback tokens.
10
+ - Hardened setup-token extraction to ignore placeholder hints such as `CLAUDE_CODE_OAUTH_TOKEN=<token>` and continue scanning for the real `sk-ant-oat...` token.
11
+ - Stripped terminal ANSI sequences from captured setup tokens before saving them.
12
+
13
+ ## Status Accuracy
14
+
15
+ - Added an explicit `AUTH` column to `cdx status` and `auth_status` fields to JSON rows.
16
+ - Marked Ollama and Antigravity authentication as `n/a` in the status table because cdx does not manage provider login for those sessions.
17
+ - Excluded explicitly logged-out sessions from priority recommendations while still showing their cached quota rows.
18
+ - Parsed Claude "No stats available yet" screens as a usable zero-usage status instead of leaving the row with unknown quota.
19
+
20
+ ## Release Metadata and Documentation
21
+
22
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.4`.
23
+
24
+ ## Validation and Regression Coverage
25
+
26
+ - Added regression coverage for Claude setup-token placeholder extraction, malformed fallback tokens, CLI credential precedence, status auth rendering, and logged-out priority filtering.
27
+
28
+ ## Validation and Regression Evidence
29
+
30
+ - `npm run lint`
31
+ - `npm test`
@@ -16,6 +16,10 @@
16
16
  "v0.6.2": {
17
17
  "github_tarball_sha256": "0a00fba132453d265a72fa551bff750a9a400011f895d9da33149b335324e02e",
18
18
  "github_zip_sha256": "424b1c9652da5054bdc4cf26f31764ea34ddc4609d6eb8075efbf8068112cd50"
19
+ },
20
+ "v0.6.3": {
21
+ "github_tarball_sha256": "539bc299fe2211cddcbe58db71a859ebbd1cb76aec254f6feef501fc038d7c62",
22
+ "github_zip_sha256": "138c7c67ea1b512d71ed745559f8eed395accb9a06b3ed9d82c3ccc454aba12b"
19
23
  }
20
24
  }
21
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.6.3"
7
+ version = "0.6.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import re
3
4
  import subprocess
4
5
  import sys
5
6
  import urllib.request
@@ -14,24 +15,37 @@ CLAUDE_STATUS_PROBE_MODEL = os.environ.get("CDX_CLAUDE_STATUS_MODEL", "claude-ha
14
15
  CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS = 15
15
16
 
16
17
 
18
+ def _clean_oauth_token(token):
19
+ if not token:
20
+ return None
21
+ text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(token)).strip()
22
+ if not text or text.startswith("<") or any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
23
+ return None
24
+ return text
25
+
26
+
17
27
  def _read_claude_credentials(auth_home):
18
- anthropic_cred_path = os.path.join(auth_home, "credentials", "default.json")
28
+ cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
19
29
  try:
20
- with open(anthropic_cred_path, "r", encoding="utf-8") as f:
30
+ with open(cred_path, "r", encoding="utf-8") as f:
21
31
  data = json.load(f)
22
- token = data.get("access_token") if isinstance(data, dict) else None
32
+ creds = data.get("claudeAiOauth") if isinstance(data, dict) else None
33
+ token = _clean_oauth_token(creds.get("accessToken")) if isinstance(creds, dict) else None
23
34
  if token:
24
- return {"accessToken": token}
35
+ return {**creds, "accessToken": token}
25
36
  except (FileNotFoundError, json.JSONDecodeError, OSError):
26
37
  pass
27
38
 
28
- cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
39
+ anthropic_cred_path = os.path.join(auth_home, "credentials", "default.json")
29
40
  try:
30
- with open(cred_path, "r", encoding="utf-8") as f:
41
+ with open(anthropic_cred_path, "r", encoding="utf-8") as f:
31
42
  data = json.load(f)
32
- return data.get("claudeAiOauth")
43
+ token = _clean_oauth_token(data.get("access_token") if isinstance(data, dict) else None)
44
+ if token:
45
+ return {"accessToken": token}
33
46
  except (FileNotFoundError, json.JSONDecodeError, OSError):
34
- return None
47
+ pass
48
+ return None
35
49
 
36
50
 
37
51
  def _home_env_overrides(auth_home):
package/src/cli.py CHANGED
@@ -55,7 +55,7 @@ from .status_view import (
55
55
  )
56
56
  from .update_check import check_for_update
57
57
 
58
- VERSION = "0.6.3"
58
+ VERSION = "0.6.4"
59
59
 
60
60
 
61
61
  # ---------------------------------------------------------------------------
@@ -33,6 +33,7 @@ from .notify import (
33
33
  from .provider_runtime import (
34
34
  _ensure_session_authentication,
35
35
  _list_launch_transcript_paths,
36
+ _probe_provider_auth,
36
37
  _run_interactive_provider_command,
37
38
  )
38
39
  from .repair import format_repair_report, repair_health
@@ -840,6 +841,7 @@ def _resolve_confirmation(confirm_fn, name):
840
841
  def _extract_claude_oauth_token(text):
841
842
  if not text:
842
843
  return None
844
+ text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(text))
843
845
  patterns = [
844
846
  r"CLAUDE_CODE_OAUTH_TOKEN=([^\s\"']+)",
845
847
  r"(sk-ant-oat[0-9A-Za-z._-]+)",
@@ -847,7 +849,10 @@ def _extract_claude_oauth_token(text):
847
849
  for pattern in patterns:
848
850
  match = re.search(pattern, text)
849
851
  if match:
850
- return match.group(1).strip()
852
+ token = match.group(1).strip()
853
+ if token.startswith("<") or any(ord(ch) < 32 or ord(ch) == 127 for ch in token):
854
+ continue
855
+ return token
851
856
  return None
852
857
 
853
858
 
@@ -1615,6 +1620,20 @@ def handle_status(rest, ctx):
1615
1620
  }
1616
1621
  for item in refresh_errors
1617
1622
  ]
1623
+ auth_refresh = _refresh_claude_auth_states(
1624
+ ctx["service"],
1625
+ target_names=args if len(args) == 1 else None,
1626
+ spawn_sync=ctx.get("spawn_sync"),
1627
+ env_override=ctx.get("env"),
1628
+ )
1629
+ warnings.extend([
1630
+ {
1631
+ "code": "claude_auth_probe_failed",
1632
+ "session": item.get("session") or "unknown",
1633
+ "message": item.get("error") or "unknown error",
1634
+ }
1635
+ for item in auth_refresh.get("errors", [])
1636
+ ])
1618
1637
  update_warning = _update_notice_warning(ctx)
1619
1638
  if update_warning:
1620
1639
  warnings.append(update_warning)
@@ -1645,6 +1664,38 @@ def handle_status(rest, ctx):
1645
1664
  return 0
1646
1665
 
1647
1666
 
1667
+ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
1668
+ target_names = set(target_names or [])
1669
+ errors = []
1670
+ updated = []
1671
+ for session in service["list_sessions"]():
1672
+ if session["provider"] != PROVIDER_CLAUDE:
1673
+ continue
1674
+ if session.get("enabled", True) is False:
1675
+ continue
1676
+ if target_names and session["name"] not in target_names:
1677
+ continue
1678
+ if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
1679
+ continue
1680
+ try:
1681
+ authenticated = _probe_provider_auth(
1682
+ session,
1683
+ spawn_sync=spawn_sync,
1684
+ env_override=env_override,
1685
+ )
1686
+ now = _local_now_iso()
1687
+ service["update_auth_state"](session["name"], lambda auth: {
1688
+ **auth,
1689
+ "status": "authenticated" if authenticated else "logged_out",
1690
+ "lastCheckedAt": now,
1691
+ **({"lastAuthenticatedAt": now} if authenticated else {}),
1692
+ })
1693
+ updated.append(session["name"])
1694
+ except Exception as error:
1695
+ errors.append({"session": session["name"], "error": str(error)})
1696
+ return {"updated": updated, "errors": errors}
1697
+
1698
+
1648
1699
  def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
1649
1700
  write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
1650
1701
  for item in refresh_errors:
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import re
3
4
  import signal
4
5
  import shlex
5
6
  import shutil
@@ -58,6 +59,15 @@ def _anthropic_credentials_path(auth_home):
58
59
  return os.path.join(auth_home, "credentials", f"{_anthropic_profile_name()}.json")
59
60
 
60
61
 
62
+ def _clean_oauth_token(token):
63
+ if not token:
64
+ return None
65
+ text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(token)).strip()
66
+ if not text or text.startswith("<") or any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
67
+ return None
68
+ return text
69
+
70
+
61
71
  def _claude_env(base_env, auth_home):
62
72
  env = {**base_env, **_home_env_overrides(auth_home)}
63
73
  env.pop("CLAUDE_CONFIG_DIR", None)
@@ -73,9 +83,17 @@ def _read_anthropic_oauth_token(auth_home):
73
83
  except (FileNotFoundError, OSError, json.JSONDecodeError):
74
84
  return None
75
85
  token = credentials.get("access_token") if isinstance(credentials, dict) else None
76
- if not token:
77
- return None
78
- return str(token)
86
+ return _clean_oauth_token(token)
87
+
88
+
89
+ def _has_local_claude_auth(auth_home):
90
+ try:
91
+ with open(os.path.join(auth_home, ".claude", ".credentials.json"), "r", encoding="utf-8") as handle:
92
+ credentials = json.load(handle)
93
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
94
+ return False
95
+ oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
96
+ return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
79
97
 
80
98
 
81
99
  def _read_claude_account_email(auth_home):
@@ -377,6 +395,8 @@ def _resolve_command(command, env=None):
377
395
  def _probe_provider_auth(session, spawn_sync=None, env_override=None):
378
396
  spawn_sync = spawn_sync or subprocess.run
379
397
  spec = _build_login_status_spec(session, env_override)
398
+ if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
399
+ return True
380
400
  if session.get("provider") == PROVIDER_CODEX:
381
401
  auth_path = os.path.join(_get_auth_home(session), "auth.json")
382
402
  if os.path.isfile(auth_path):
@@ -961,6 +961,8 @@ def create_session_service(options=None):
961
961
  "enabled": enabled,
962
962
  "active": bool(_session_runtime(s["name"])) if enabled else False,
963
963
  "status": "enabled" if enabled else "disabled",
964
+ "auth_status": (s.get("auth") or {}).get("status") or "unknown",
965
+ "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
964
966
  "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
965
967
  "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
966
968
  "credits": row_status.get("credits") if row_status else None,
@@ -212,6 +212,14 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
212
212
  ]],
213
213
  ):
214
214
  items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
215
+ for block in collect_blocks(
216
+ re.compile(r"No stats available yet\.?\s+Start using Claude Code", re.I),
217
+ [re.compile(p, re.I) for p in [
218
+ r"^Esc to cancel\b", r"^To continue this session\b", r"^╰",
219
+ ]],
220
+ max_span=40,
221
+ ):
222
+ items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
215
223
 
216
224
  if provider != PROVIDER_CLAUDE:
217
225
  for block in collect_blocks(
@@ -457,6 +465,11 @@ def extract_named_statuses_from_text(text):
457
465
  if "remaining_week_pct" not in result and week_used is not None:
458
466
  result["remaining_week_pct"] = max(0, 100 - week_used)
459
467
 
468
+ if re.search(r"No stats available yet\.?\s+Start using Claude Code", normalized, re.I):
469
+ result.setdefault("usage_pct", 0)
470
+ result.setdefault("remaining_5h_pct", 100)
471
+ result.setdefault("remaining_week_pct", 100)
472
+
460
473
  # Table header row
461
474
  header_idx = next(
462
475
  (i for i, l in enumerate(lines)
@@ -66,27 +66,40 @@ def _format_status_rows(rows, use_color=False, small=False):
66
66
  if small:
67
67
  headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
68
68
  elif has_provider:
69
- headers = ["SESSION", "PROV.", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
69
+ headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
70
70
  else:
71
- headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
71
+ headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
72
72
  if not rows:
73
73
  if small:
74
74
  return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
75
75
  return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
76
76
  headers = [_style(header, "1", use_color) for header in headers]
77
77
  active_rows = [r for r in rows if r.get("enabled", True) is not False]
78
+ priority_candidates = [
79
+ r for r in active_rows
80
+ if _format_auth_status(r) != "logged out"
81
+ ]
78
82
  disabled_rows = sorted(
79
83
  [r for r in rows if r.get("enabled", True) is False],
80
84
  key=lambda r: r.get("session_name") or "",
81
85
  )
82
- priority = _recommend_priority_sessions(active_rows)
86
+ priority = _recommend_priority_sessions(priority_candidates)
87
+ priority_names = {r.get("session_name") for r in priority}
88
+ non_priority_active = [
89
+ r for r in active_rows
90
+ if r.get("session_name") not in priority_names
91
+ ]
83
92
  table_rows = []
84
- for r in priority + disabled_rows:
93
+ for r in priority + non_priority_active + disabled_rows:
85
94
  base = [_format_session_name(r)]
86
95
  if has_provider:
87
96
  base.append(r.get("provider") or "n/a")
88
97
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
89
98
  base.append(_style(status, "2" if status == "disabled" else "32", use_color))
99
+ if not small:
100
+ auth = _format_auth_status(r)
101
+ auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
102
+ base.append(_style(auth, auth_color, use_color))
90
103
  if r.get("enabled", True) is False:
91
104
  usage_columns = [_style("-", "2", use_color)] * 5
92
105
  else:
@@ -142,6 +155,19 @@ def _format_session_name(row):
142
155
  return f"{row['session_name']}*" if row.get("active") else row["session_name"]
143
156
 
144
157
 
158
+ def _format_auth_status(row):
159
+ if row.get("enabled", True) is False:
160
+ return "-"
161
+ if row.get("provider") in ("antigravity", "ollama"):
162
+ return "n/a"
163
+ status = str(row.get("auth_status") or "").strip().lower()
164
+ if status == "authenticated":
165
+ return "logged"
166
+ if status == "logged_out":
167
+ return "logged out"
168
+ return "unknown"
169
+
170
+
145
171
  def _recommend_priority_sessions(rows):
146
172
  if not rows:
147
173
  return []
@@ -295,10 +321,13 @@ def _now_timestamp():
295
321
 
296
322
 
297
323
  def _format_status_detail(row, use_color=False):
324
+ auth = _format_auth_status(row)
325
+ auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
298
326
  lines = [
299
327
  f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
300
328
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
301
329
  f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
330
+ f"{_style('Auth:', '1', use_color)} {_style(auth, auth_color, use_color)}",
302
331
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
303
332
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
304
333
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",