cdx-manager 0.6.2 → 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.2-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.2 sh install.sh
137
+ CDX_VERSION=v0.6.4 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -0,0 +1,28 @@
1
+ # Changelog (`0.6.2 -> 0.6.3`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Claude Authentication
6
+
7
+ - Fixed Claude Code 2.1.145 isolated auth handling by using `ANTHROPIC_CONFIG_DIR` instead of the older `CLAUDE_CONFIG_DIR` override.
8
+ - Removed leaked `CODEX_HOME` from Claude auth, launch, and status environments so Claude sessions do not write nested `.cdx` state inside isolated Claude homes.
9
+ - Stopped running a destructive Claude logout before `cdx login <name>`, preserving existing credentials when reauthenticating a session.
10
+ - Added profile email hints to Claude login so account-specific sessions open the expected Claude account in the browser.
11
+
12
+ ## Claude Token Fallback
13
+
14
+ - Added automatic `claude setup-token` fallback when browser login completes but does not create isolated credentials.
15
+ - Captured the one-time setup token through a temporary transcript, wrote it to `claude-home/credentials/default.json`, and removed the temporary transcript after extraction.
16
+ - Added support for the new Anthropic `credentials/default.json` OAuth format during auth probes, launches, status refreshes, and auth bundle exports.
17
+
18
+ ## Release Metadata and Documentation
19
+
20
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.3`.
21
+
22
+ ## Validation and Regression Coverage
23
+
24
+ - Added regression coverage for Claude login without pre-logout, email-hinted login, setup-token fallback, modern Anthropic credentials, and cleaned Claude environments.
25
+
26
+ ## Validation and Regression Evidence
27
+
28
+ - `python3 -m unittest discover -s test -p 'test_*_py.py'`
@@ -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`
@@ -12,6 +12,14 @@
12
12
  "v0.6.1": {
13
13
  "github_tarball_sha256": "5ae4032894e1806ffb3a713e555c12b2faa3bfa6fd7c9fa468664a8577abe827",
14
14
  "github_zip_sha256": "7949e99dcfe21dc4e2b82f720b515aea8f0ac9f2059142032d488bcfbc4dfaf1"
15
+ },
16
+ "v0.6.2": {
17
+ "github_tarball_sha256": "0a00fba132453d265a72fa551bff750a9a400011f895d9da33149b335324e02e",
18
+ "github_zip_sha256": "424b1c9652da5054bdc4cf26f31764ea34ddc4609d6eb8075efbf8068112cd50"
19
+ },
20
+ "v0.6.3": {
21
+ "github_tarball_sha256": "539bc299fe2211cddcbe58db71a859ebbd1cb76aec254f6feef501fc038d7c62",
22
+ "github_zip_sha256": "138c7c67ea1b512d71ed745559f8eed395accb9a06b3ed9d82c3ccc454aba12b"
15
23
  }
16
24
  }
17
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.2",
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.2"
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,20 +15,43 @@ 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
28
  cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
19
29
  try:
20
30
  with open(cred_path, "r", encoding="utf-8") as f:
21
31
  data = json.load(f)
22
- return data.get("claudeAiOauth")
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
34
+ if token:
35
+ return {**creds, "accessToken": token}
23
36
  except (FileNotFoundError, json.JSONDecodeError, OSError):
24
- return None
37
+ pass
38
+
39
+ anthropic_cred_path = os.path.join(auth_home, "credentials", "default.json")
40
+ try:
41
+ with open(anthropic_cred_path, "r", encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ token = _clean_oauth_token(data.get("access_token") if isinstance(data, dict) else None)
44
+ if token:
45
+ return {"accessToken": token}
46
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
47
+ pass
48
+ return None
25
49
 
26
50
 
27
51
  def _home_env_overrides(auth_home):
28
52
  overrides = {
29
53
  "HOME": auth_home,
30
- "CLAUDE_CONFIG_DIR": auth_home,
54
+ "ANTHROPIC_CONFIG_DIR": auth_home,
31
55
  "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
32
56
  }
33
57
  if sys.platform == "win32":
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.2"
58
+ VERSION = "0.6.4"
59
59
 
60
60
 
61
61
  # ---------------------------------------------------------------------------
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta
9
9
 
10
10
  from .claude_refresh import _refresh_claude_sessions
11
11
  from .cli_render import _dim, _info, _success, _warn
12
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CODEX, PROVIDER_OLLAMA, PROVIDERS
12
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA, PROVIDERS
13
13
  from .context_store import (
14
14
  clear_context,
15
15
  edit_context,
@@ -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
@@ -837,6 +838,76 @@ def _resolve_confirmation(confirm_fn, name):
837
838
  return confirmed
838
839
 
839
840
 
841
+ def _extract_claude_oauth_token(text):
842
+ if not text:
843
+ return None
844
+ text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(text))
845
+ patterns = [
846
+ r"CLAUDE_CODE_OAUTH_TOKEN=([^\s\"']+)",
847
+ r"(sk-ant-oat[0-9A-Za-z._-]+)",
848
+ ]
849
+ for pattern in patterns:
850
+ match = re.search(pattern, text)
851
+ if match:
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
856
+ return None
857
+
858
+
859
+ def _write_claude_oauth_token(auth_home, token):
860
+ cred_dir = os.path.join(auth_home, "credentials")
861
+ os.makedirs(cred_dir, exist_ok=True)
862
+ cred_path = os.path.join(cred_dir, "default.json")
863
+ payload = {
864
+ "version": "1.0",
865
+ "type": "oauth_token",
866
+ "access_token": token,
867
+ }
868
+ with open(cred_path, "w", encoding="utf-8") as handle:
869
+ json.dump(payload, handle, indent=2)
870
+ handle.write("\n")
871
+ try:
872
+ os.chmod(cred_path, 0o600)
873
+ except OSError:
874
+ pass
875
+ return cred_path
876
+
877
+
878
+ def _bootstrap_claude_setup_token(session, ctx):
879
+ ctx["out"](
880
+ "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
881
+ )
882
+ run_info = _run_interactive_provider_command(
883
+ session,
884
+ "setup-token",
885
+ spawn=ctx.get("spawn"),
886
+ env_override=ctx.get("env"),
887
+ signal_emitter=ctx.get("signal_emitter"),
888
+ )
889
+ transcript_path = run_info.get("transcript_path")
890
+ if not transcript_path or not os.path.isfile(transcript_path):
891
+ raise CdxError(
892
+ "Claude setup-token completed, but cdx could not capture the token. "
893
+ "Run claude setup-token and save the token under credentials/default.json."
894
+ )
895
+ try:
896
+ with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
897
+ transcript = handle.read()
898
+ finally:
899
+ try:
900
+ os.remove(transcript_path)
901
+ except OSError:
902
+ pass
903
+ token = _extract_claude_oauth_token(transcript)
904
+ if not token:
905
+ raise CdxError(
906
+ "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output."
907
+ )
908
+ return _write_claude_oauth_token(session.get("authHome") or "", token)
909
+
910
+
840
911
  def handle_add(rest, ctx):
841
912
  json_flag, args = _parse_json_flag(rest)
842
913
  parsed = _parse_add_args(args)
@@ -1549,6 +1620,20 @@ def handle_status(rest, ctx):
1549
1620
  }
1550
1621
  for item in refresh_errors
1551
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
+ ])
1552
1637
  update_warning = _update_notice_warning(ctx)
1553
1638
  if update_warning:
1554
1639
  warnings.append(update_warning)
@@ -1579,6 +1664,38 @@ def handle_status(rest, ctx):
1579
1664
  return 0
1580
1665
 
1581
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
+
1582
1699
  def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
1583
1700
  write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
1584
1701
  for item in refresh_errors:
@@ -1666,7 +1783,7 @@ def handle_login(rest, ctx):
1666
1783
  session = ctx["service"]["get_session"](args[0])
1667
1784
  if not session:
1668
1785
  raise CdxError(f"Unknown session: {args[0]}")
1669
- if session["provider"] not in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
1786
+ if session["provider"] == PROVIDER_CODEX:
1670
1787
  _run_interactive_provider_command(
1671
1788
  session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1672
1789
  signal_emitter=ctx.get("signal_emitter")
@@ -1675,6 +1792,26 @@ def handle_login(rest, ctx):
1675
1792
  session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1676
1793
  signal_emitter=ctx.get("signal_emitter")
1677
1794
  )
1795
+ auth_probe = _ensure_session_authentication(
1796
+ session,
1797
+ ctx["service"],
1798
+ spawn_sync=ctx.get("spawn_sync"),
1799
+ env_override=ctx.get("env"),
1800
+ behavior="probe-only",
1801
+ )
1802
+ if not auth_probe.get("authenticated") and session["provider"] == PROVIDER_CLAUDE:
1803
+ _bootstrap_claude_setup_token(session, ctx)
1804
+ auth_probe = _ensure_session_authentication(
1805
+ session,
1806
+ ctx["service"],
1807
+ spawn_sync=ctx.get("spawn_sync"),
1808
+ env_override=ctx.get("env"),
1809
+ behavior="probe-only",
1810
+ )
1811
+ if not auth_probe.get("authenticated"):
1812
+ raise CdxError(
1813
+ f"Login command completed, but session {session['name']} is still not authenticated."
1814
+ )
1678
1815
  now = _local_now_iso()
1679
1816
  ctx["service"]["update_auth_state"](args[0], lambda auth: {
1680
1817
  **auth, "status": "authenticated",
@@ -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
@@ -31,13 +32,16 @@ LAUNCH_PERMISSION_ARGS = {
31
32
  def _home_env_overrides(auth_home):
32
33
  """Return env vars that point the claude CLI to the given home directory.
33
34
 
34
- On Unix, only HOME is needed. On Windows, Node.js resolves the home
35
- directory via USERPROFILE (and falls back to HOMEDRIVE+HOMEPATH), so we
36
- set all three to ensure profile isolation works regardless of the platform.
35
+ On Unix, only HOME is needed. Claude Code resolves its auth files relative
36
+ to HOME; forcing CLAUDE_CONFIG_DIR to this directory makes current Claude
37
+ Code builds ignore otherwise valid isolated credentials. On Windows, Node.js
38
+ resolves the home directory via USERPROFILE (and falls back to
39
+ HOMEDRIVE+HOMEPATH), so we set all three to ensure profile isolation works
40
+ regardless of the platform.
37
41
  """
38
42
  overrides = {
39
43
  "HOME": auth_home,
40
- "CLAUDE_CONFIG_DIR": auth_home,
44
+ "ANTHROPIC_CONFIG_DIR": auth_home,
41
45
  "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
42
46
  }
43
47
  if sys.platform == "win32":
@@ -47,6 +51,65 @@ def _home_env_overrides(auth_home):
47
51
  return overrides
48
52
 
49
53
 
54
+ def _anthropic_profile_name():
55
+ return "default"
56
+
57
+
58
+ def _anthropic_credentials_path(auth_home):
59
+ return os.path.join(auth_home, "credentials", f"{_anthropic_profile_name()}.json")
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
+
71
+ def _claude_env(base_env, auth_home):
72
+ env = {**base_env, **_home_env_overrides(auth_home)}
73
+ env.pop("CLAUDE_CONFIG_DIR", None)
74
+ env.pop("CODEX_HOME", None)
75
+ env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
76
+ return env
77
+
78
+
79
+ def _read_anthropic_oauth_token(auth_home):
80
+ try:
81
+ with open(_anthropic_credentials_path(auth_home), "r", encoding="utf-8") as handle:
82
+ credentials = json.load(handle)
83
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
84
+ return None
85
+ token = credentials.get("access_token") if isinstance(credentials, dict) else None
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 _read_claude_account_email(auth_home):
100
+ config_path = os.path.join(auth_home, ".claude.json")
101
+ try:
102
+ with open(config_path, "r", encoding="utf-8") as handle:
103
+ config = json.load(handle)
104
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
105
+ return None
106
+ account = config.get("oauthAccount") if isinstance(config, dict) else None
107
+ email = account.get("emailAddress") if isinstance(account, dict) else None
108
+ if not email:
109
+ return None
110
+ return str(email).strip()
111
+
112
+
50
113
  def _antigravity_env_overrides(auth_home):
51
114
  overrides = {"HOME": auth_home}
52
115
  if sys.platform == "win32":
@@ -187,12 +250,17 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
187
250
  args = ["--name", session["name"]] + _launch_config_args(session)
188
251
  if initial_prompt:
189
252
  args.append(initial_prompt)
253
+ auth_home = _get_auth_home(session)
254
+ claude_env = _claude_env(env, auth_home)
255
+ oauth_token = _read_anthropic_oauth_token(auth_home)
256
+ if oauth_token:
257
+ claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
190
258
  return _wrap_launch_with_transcript(session, {
191
259
  "command": "claude",
192
260
  "args": args,
193
261
  "options": {
194
262
  "cwd": cwd,
195
- "env": {**env, **_home_env_overrides(_get_auth_home(session))},
263
+ "env": claude_env,
196
264
  },
197
265
  "label": "claude",
198
266
  }, env=env)
@@ -241,7 +309,11 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
241
309
  def _build_login_status_spec(session, env_override=None):
242
310
  env = {**os.environ, **(env_override or {})}
243
311
  if session["provider"] == PROVIDER_CLAUDE:
244
- env.update(_home_env_overrides(_get_auth_home(session)))
312
+ auth_home = _get_auth_home(session)
313
+ env = _claude_env(env, auth_home)
314
+ oauth_token = _read_anthropic_oauth_token(auth_home)
315
+ if oauth_token:
316
+ env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
245
317
 
246
318
  def parser(output):
247
319
  try:
@@ -273,9 +345,20 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
273
345
  cwd = cwd or os.getcwd()
274
346
  env = {**os.environ, **(env_override or {})}
275
347
  if session["provider"] == PROVIDER_CLAUDE:
276
- env.update(_home_env_overrides(_get_auth_home(session)))
277
- return {"command": "claude", "args": ["auth", action],
278
- "options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
348
+ auth_home = _get_auth_home(session)
349
+ env = _claude_env(env, auth_home)
350
+ args = ["auth", action]
351
+ label = f"claude auth {action}"
352
+ if action == "setup-token":
353
+ spec = {"command": "claude", "args": ["setup-token"],
354
+ "options": {"cwd": cwd, "env": env}, "label": "claude setup-token"}
355
+ return _wrap_launch_with_transcript(session, spec, env=env)
356
+ if action == "login":
357
+ email = _read_claude_account_email(auth_home)
358
+ if email:
359
+ args += ["--email", email]
360
+ return {"command": "claude", "args": args,
361
+ "options": {"cwd": cwd, "env": env}, "label": label}
279
362
  if session["provider"] == PROVIDER_ANTIGRAVITY:
280
363
  if action == "logout":
281
364
  raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
@@ -312,6 +395,8 @@ def _resolve_command(command, env=None):
312
395
  def _probe_provider_auth(session, spawn_sync=None, env_override=None):
313
396
  spawn_sync = spawn_sync or subprocess.run
314
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
315
400
  if session.get("provider") == PROVIDER_CODEX:
316
401
  auth_path = os.path.join(_get_auth_home(session), "auth.json")
317
402
  if os.path.isfile(auth_path):
@@ -433,6 +433,8 @@ def create_session_service(options=None):
433
433
  def _auth_bundle_paths(provider):
434
434
  if provider == PROVIDER_CLAUDE:
435
435
  return [
436
+ "claude-home/configs/default.json",
437
+ "claude-home/credentials/default.json",
436
438
  "claude-home/.claude/.credentials.json",
437
439
  "claude-home/.claude.json",
438
440
  "claude-home/auth.json",
@@ -959,6 +961,8 @@ def create_session_service(options=None):
959
961
  "enabled": enabled,
960
962
  "active": bool(_session_runtime(s["name"])) if enabled else False,
961
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")),
962
966
  "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
963
967
  "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
964
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)}",