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 +2 -2
- package/changelogs/CHANGELOGS_0_6_3.md +28 -0
- package/changelogs/CHANGELOGS_0_6_4.md +31 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +27 -3
- package/src/cli.py +1 -1
- package/src/cli_commands.py +139 -2
- package/src/provider_runtime.py +94 -9
- package/src/session_service.py +4 -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,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
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,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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
package/src/cli_commands.py
CHANGED
|
@@ -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"]
|
|
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",
|
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
|
|
@@ -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.
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
"
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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):
|
package/src/session_service.py
CHANGED
|
@@ -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,
|
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)}",
|