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 +3 -3
- package/changelogs/CHANGELOGS_0_6_4.md +31 -0
- package/changelogs/CHANGELOGS_0_6_5.md +35 -0
- package/checksums/release-archives.json +8 -0
- package/install.sh +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/backup_bundle.py +40 -11
- package/src/claude_refresh.py +1 -0
- package/src/claude_usage.py +22 -8
- package/src/cli.py +1 -1
- package/src/cli_commands.py +62 -12
- package/src/provider_runtime.py +42 -7
- 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
|
|
|
@@ -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
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
51
|
+
' "$1"
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if [ -z "$VERSION" ]; then
|
package/package.json
CHANGED
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.
|
|
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"
|
package/src/backup_bundle.py
CHANGED
|
@@ -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(
|
|
94
|
-
|
|
95
|
-
ciphertext =
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
package/src/claude_refresh.py
CHANGED
|
@@ -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
|
]
|
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
|
|
|
@@ -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
|
-
|
|
891
|
-
|
|
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:
|
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,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
|
-
|
|
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")))
|
|
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") ==
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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"])
|
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)}",
|