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 +2 -2
- package/changelogs/CHANGELOGS_0_6_4.md +31 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +22 -8
- package/src/cli.py +1 -1
- package/src/cli_commands.py +52 -1
- package/src/provider_runtime.py +23 -3
- package/src/session_service.py +2 -0
- package/src/status_source.py +13 -0
- package/src/status_view.py +33 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
package/src/claude_usage.py
CHANGED
|
@@ -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
|
-
|
|
28
|
+
cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
|
|
19
29
|
try:
|
|
20
|
-
with open(
|
|
30
|
+
with open(cred_path, "r", encoding="utf-8") as f:
|
|
21
31
|
data = json.load(f)
|
|
22
|
-
|
|
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
|
-
|
|
39
|
+
anthropic_cred_path = os.path.join(auth_home, "credentials", "default.json")
|
|
29
40
|
try:
|
|
30
|
-
with open(
|
|
41
|
+
with open(anthropic_cred_path, "r", encoding="utf-8") as f:
|
|
31
42
|
data = json.load(f)
|
|
32
|
-
|
|
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
|
-
|
|
47
|
+
pass
|
|
48
|
+
return None
|
|
35
49
|
|
|
36
50
|
|
|
37
51
|
def _home_env_overrides(auth_home):
|
package/src/cli.py
CHANGED
package/src/cli_commands.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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):
|
package/src/session_service.py
CHANGED
|
@@ -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,
|
package/src/status_source.py
CHANGED
|
@@ -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)
|
package/src/status_view.py
CHANGED
|
@@ -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(
|
|
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)}",
|