cdx-manager 0.6.3 → 0.6.5

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.5-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
 
@@ -51,7 +51,7 @@ One command to launch any session. Zero auth juggling.
51
51
 
52
52
  ## Technical Overview
53
53
 
54
- - Python 3.9+, zero runtime dependencies.
54
+ - Python 3.9+.
55
55
  - Environment isolation per session:
56
56
  - Codex sessions override `CODEX_HOME` to a dedicated profile directory.
57
57
  - Claude sessions override `HOME` to a dedicated profile directory and disable Claude Code commit co-author attribution by default.
@@ -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.5 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`
@@ -0,0 +1,35 @@
1
+ # Changelog (`0.6.4 -> 0.6.5`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Installer Integrity
6
+
7
+ - Fixed standalone installer checksum resolution so the Unix installer can validate published GitHub archive checksums correctly.
8
+ - Added regression coverage for the release checksum lookup path used by the standalone installer.
9
+
10
+ ## Claude Authentication
11
+
12
+ - Preserved the captured Claude setup-token transcript when token extraction fails, making failed login setup recoverable and easier to inspect.
13
+ - Reordered Claude status handling so authentication is checked before refreshing usage, avoiding noisy usage refresh failures when Claude credentials are absent or invalid.
14
+
15
+ ## Codex Authentication
16
+
17
+ - Hardened Codex auth detection so an empty or unusable `auth.json` is no longer treated as an authenticated session.
18
+ - Added coverage for usable-token validation instead of relying on file presence alone.
19
+
20
+ ## Auth Bundle Security
21
+
22
+ - Replaced the previous homegrown auth bundle encryption with AEAD encryption via `cryptography`.
23
+ - Added wrong-passphrase and auth bundle regression tests for the new encrypted bundle format.
24
+ - Updated CI and publish workflows to install Python package dependencies before running Python-backed lint, tests, and version validation.
25
+
26
+ ## Release Metadata and Documentation
27
+
28
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.6.5`.
29
+
30
+ ## Validation and Regression Evidence
31
+
32
+ - `npm run lint`
33
+ - `npm test`
34
+ - `python -m build --sdist --wheel`
35
+ - `npm pack --dry-run`
@@ -16,6 +16,14 @@
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"
23
+ },
24
+ "v0.6.4": {
25
+ "github_tarball_sha256": "f7c83dd1ae7506cf1ae6420e718a285f4dc96a10fb3a19e24cc17e79d75a46b4",
26
+ "github_zip_sha256": "d602800cf6b54f1e0adea751cc6b686ab2fc4a224715ade94f761df2ff7da2af"
19
27
  }
20
28
  }
21
29
  }
package/install.sh CHANGED
@@ -34,7 +34,7 @@ sha256_file() {
34
34
 
35
35
  resolve_expected_sha256() {
36
36
  curl -fsSL "$CHECKSUMS_URL" |
37
- python3 - "$1" <<'PY'
37
+ python3 -c '
38
38
  import json
39
39
  import sys
40
40
 
@@ -48,7 +48,7 @@ release = (payload.get("releases") or {}).get(tag) or {}
48
48
  value = release.get("github_tarball_sha256")
49
49
  if value:
50
50
  print(value)
51
- PY
51
+ ' "$1"
52
52
  }
53
53
 
54
54
  if [ -z "$VERSION" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Software Development",
27
27
  "Topic :: Terminals",
28
28
  ]
29
- dependencies = []
29
+ dependencies = ["cryptography>=42"]
30
30
 
31
31
  [project.urls]
32
32
  Homepage = "https://github.com/AlexAgo83/cdx-manager"
@@ -11,10 +11,13 @@ from .errors import CdxError
11
11
  BUNDLE_SCHEMA_VERSION = 1
12
12
  _SALT_BYTES = 16
13
13
  _NONCE_BYTES = 16
14
+ _AEAD_NONCE_BYTES = 12
14
15
  _SCRYPT_N = 2 ** 14
15
16
  _SCRYPT_R = 8
16
17
  _SCRYPT_P = 1
17
18
  _PBKDF2_ITERATIONS = 200000
19
+ _AEAD_ALGORITHM = "aes-256-gcm"
20
+ _LEGACY_ALGORITHM = "sha256-xor-hmac"
18
21
 
19
22
 
20
23
  def _now_iso():
@@ -68,6 +71,22 @@ def _derive_keys(passphrase, salt):
68
71
  return key_material[:32], key_material[32:]
69
72
 
70
73
 
74
+ def _derive_aead_key(passphrase, salt):
75
+ enc_key, _mac_key = _derive_keys(passphrase, salt)
76
+ return enc_key
77
+
78
+
79
+ def _load_aesgcm():
80
+ try:
81
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
82
+ except ImportError as error:
83
+ raise CdxError(
84
+ "Auth bundle encryption requires the Python package 'cryptography'. "
85
+ "Install cdx-manager through pipx/uv/pip, or install cryptography for the Python used by cdx."
86
+ ) from error
87
+ return AESGCM
88
+
89
+
71
90
  def _xor_keystream(data, key, nonce):
72
91
  output = bytearray()
73
92
  counter = 0
@@ -90,14 +109,14 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
90
109
  }
91
110
  if include_auth:
92
111
  salt = os.urandom(_SALT_BYTES)
93
- nonce = os.urandom(_NONCE_BYTES)
94
- enc_key, mac_key = _derive_keys(passphrase, salt)
95
- ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
96
- mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
112
+ nonce = os.urandom(_AEAD_NONCE_BYTES)
113
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
114
+ ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
97
115
  wrapper.update({
116
+ "encryption": _AEAD_ALGORITHM,
117
+ "kdf": "scrypt" if hasattr(hashlib, "scrypt") else "pbkdf2-hmac-sha256",
98
118
  "salt": _b64_encode(salt),
99
119
  "nonce": _b64_encode(nonce),
100
- "hmac_sha256": _b64_encode(mac),
101
120
  "payload": _b64_encode(ciphertext),
102
121
  })
103
122
  else:
@@ -116,13 +135,23 @@ def decode_bundle(data, passphrase=None):
116
135
  if encrypted:
117
136
  salt = _b64_decode(wrapper.get("salt", ""))
118
137
  nonce = _b64_decode(wrapper.get("nonce", ""))
119
- expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
120
138
  ciphertext = _b64_decode(payload_b64)
121
- enc_key, mac_key = _derive_keys(passphrase, salt)
122
- actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
123
- if not hmac.compare_digest(actual_mac, expected_mac):
124
- raise CdxError("Invalid bundle passphrase or corrupted bundle.")
125
- payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
139
+ algorithm = wrapper.get("encryption") or _LEGACY_ALGORITHM
140
+ if algorithm == _AEAD_ALGORITHM:
141
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
142
+ try:
143
+ payload_bytes = aesgcm.decrypt(nonce, ciphertext, None)
144
+ except Exception as error:
145
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.") from error
146
+ elif algorithm == _LEGACY_ALGORITHM:
147
+ expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
148
+ enc_key, mac_key = _derive_keys(passphrase, salt)
149
+ actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
150
+ if not hmac.compare_digest(actual_mac, expected_mac):
151
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.")
152
+ payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
153
+ else:
154
+ raise CdxError("Unsupported bundle encryption algorithm.")
126
155
  else:
127
156
  payload_bytes = _b64_decode(payload_b64)
128
157
 
@@ -38,6 +38,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
38
38
  s for s in sessions
39
39
  if s["provider"] == PROVIDER_CLAUDE
40
40
  and s.get("enabled", True) is not False
41
+ and (s.get("auth") or {}).get("status") != "logged_out"
41
42
  and (not target_names or s["name"] in target_names)
42
43
  and (force or _is_stale(s, ttl_seconds=ttl_seconds))
43
44
  ]
@@ -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.5"
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
 
@@ -887,19 +892,18 @@ def _bootstrap_claude_setup_token(session, ctx):
887
892
  "Claude setup-token completed, but cdx could not capture the token. "
888
893
  "Run claude setup-token and save the token under credentials/default.json."
889
894
  )
890
- try:
891
- with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
892
- transcript = handle.read()
893
- finally:
894
- try:
895
- os.remove(transcript_path)
896
- except OSError:
897
- pass
895
+ with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
896
+ transcript = handle.read()
898
897
  token = _extract_claude_oauth_token(transcript)
899
898
  if not token:
900
899
  raise CdxError(
901
- "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output."
900
+ "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
901
+ f"Transcript kept at: {transcript_path}"
902
902
  )
903
+ try:
904
+ os.remove(transcript_path)
905
+ except OSError:
906
+ pass
903
907
  return _write_claude_oauth_token(session.get("authHome") or "", token)
904
908
 
905
909
 
@@ -1594,6 +1598,20 @@ def handle_status(rest, ctx):
1594
1598
  if len(args) == 1 and parsed["small"]:
1595
1599
  raise CdxError(STATUS_USAGE)
1596
1600
 
1601
+ auth_refresh = _refresh_claude_auth_states(
1602
+ ctx["service"],
1603
+ target_names=args if len(args) == 1 else None,
1604
+ spawn_sync=ctx.get("spawn_sync"),
1605
+ env_override=ctx.get("env"),
1606
+ )
1607
+ warnings = [
1608
+ {
1609
+ "code": "claude_auth_probe_failed",
1610
+ "session": item.get("session") or "unknown",
1611
+ "message": item.get("error") or "unknown error",
1612
+ }
1613
+ for item in auth_refresh.get("errors", [])
1614
+ ]
1597
1615
  refresh_result = _refresh_claude_sessions(
1598
1616
  ctx["service"],
1599
1617
  ctx.get("refresh_fn"),
@@ -1607,14 +1625,14 @@ def handle_status(rest, ctx):
1607
1625
  }
1608
1626
  for item in refresh_result.get("errors", [])
1609
1627
  ]
1610
- warnings = [
1628
+ warnings.extend([
1611
1629
  {
1612
1630
  "code": "claude_refresh_failed",
1613
1631
  "session": item.get("session") or "unknown",
1614
1632
  "message": item.get("error") or "unknown error",
1615
1633
  }
1616
1634
  for item in refresh_errors
1617
- ]
1635
+ ])
1618
1636
  update_warning = _update_notice_warning(ctx)
1619
1637
  if update_warning:
1620
1638
  warnings.append(update_warning)
@@ -1645,6 +1663,38 @@ def handle_status(rest, ctx):
1645
1663
  return 0
1646
1664
 
1647
1665
 
1666
+ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
1667
+ target_names = set(target_names or [])
1668
+ errors = []
1669
+ updated = []
1670
+ for session in service["list_sessions"]():
1671
+ if session["provider"] != PROVIDER_CLAUDE:
1672
+ continue
1673
+ if session.get("enabled", True) is False:
1674
+ continue
1675
+ if target_names and session["name"] not in target_names:
1676
+ continue
1677
+ if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
1678
+ continue
1679
+ try:
1680
+ authenticated = _probe_provider_auth(
1681
+ session,
1682
+ spawn_sync=spawn_sync,
1683
+ env_override=env_override,
1684
+ )
1685
+ now = _local_now_iso()
1686
+ service["update_auth_state"](session["name"], lambda auth: {
1687
+ **auth,
1688
+ "status": "authenticated" if authenticated else "logged_out",
1689
+ "lastCheckedAt": now,
1690
+ **({"lastAuthenticatedAt": now} if authenticated else {}),
1691
+ })
1692
+ updated.append(session["name"])
1693
+ except Exception as error:
1694
+ errors.append({"session": session["name"], "error": str(error)})
1695
+ return {"updated": updated, "errors": errors}
1696
+
1697
+
1648
1698
  def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
1649
1699
  write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
1650
1700
  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,34 @@ 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")))
97
+
98
+
99
+ def _has_local_codex_auth(auth_home):
100
+ try:
101
+ with open(os.path.join(auth_home, "auth.json"), "r", encoding="utf-8") as handle:
102
+ auth = json.load(handle)
103
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
104
+ return False
105
+ if not isinstance(auth, dict):
106
+ return False
107
+ tokens = auth.get("tokens")
108
+ if not isinstance(tokens, dict):
109
+ return False
110
+ return any(
111
+ _clean_oauth_token(tokens.get(name))
112
+ for name in ("id_token", "access_token", "refresh_token")
113
+ )
79
114
 
80
115
 
81
116
  def _read_claude_account_email(auth_home):
@@ -377,10 +412,10 @@ def _resolve_command(command, env=None):
377
412
  def _probe_provider_auth(session, spawn_sync=None, env_override=None):
378
413
  spawn_sync = spawn_sync or subprocess.run
379
414
  spec = _build_login_status_spec(session, env_override)
380
- if session.get("provider") == PROVIDER_CODEX:
381
- auth_path = os.path.join(_get_auth_home(session), "auth.json")
382
- if os.path.isfile(auth_path):
383
- return True
415
+ if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
416
+ return True
417
+ if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
418
+ return True
384
419
  try:
385
420
  if spawn_sync is subprocess.run:
386
421
  command = _resolve_command(spec["command"], spec["env"])
@@ -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)}",