cdx-manager 0.6.2 → 0.6.3

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.3-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.3 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'`
@@ -12,6 +12,10 @@
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"
15
19
  }
16
20
  }
17
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,6 +15,16 @@ CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS = 15
15
15
 
16
16
 
17
17
  def _read_claude_credentials(auth_home):
18
+ anthropic_cred_path = os.path.join(auth_home, "credentials", "default.json")
19
+ try:
20
+ with open(anthropic_cred_path, "r", encoding="utf-8") as f:
21
+ data = json.load(f)
22
+ token = data.get("access_token") if isinstance(data, dict) else None
23
+ if token:
24
+ return {"accessToken": token}
25
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
26
+ pass
27
+
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:
@@ -27,7 +37,7 @@ def _read_claude_credentials(auth_home):
27
37
  def _home_env_overrides(auth_home):
28
38
  overrides = {
29
39
  "HOME": auth_home,
30
- "CLAUDE_CONFIG_DIR": auth_home,
40
+ "ANTHROPIC_CONFIG_DIR": auth_home,
31
41
  "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
32
42
  }
33
43
  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.3"
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,
@@ -837,6 +837,72 @@ def _resolve_confirmation(confirm_fn, name):
837
837
  return confirmed
838
838
 
839
839
 
840
+ def _extract_claude_oauth_token(text):
841
+ if not text:
842
+ return None
843
+ patterns = [
844
+ r"CLAUDE_CODE_OAUTH_TOKEN=([^\s\"']+)",
845
+ r"(sk-ant-oat[0-9A-Za-z._-]+)",
846
+ ]
847
+ for pattern in patterns:
848
+ match = re.search(pattern, text)
849
+ if match:
850
+ return match.group(1).strip()
851
+ return None
852
+
853
+
854
+ def _write_claude_oauth_token(auth_home, token):
855
+ cred_dir = os.path.join(auth_home, "credentials")
856
+ os.makedirs(cred_dir, exist_ok=True)
857
+ cred_path = os.path.join(cred_dir, "default.json")
858
+ payload = {
859
+ "version": "1.0",
860
+ "type": "oauth_token",
861
+ "access_token": token,
862
+ }
863
+ with open(cred_path, "w", encoding="utf-8") as handle:
864
+ json.dump(payload, handle, indent=2)
865
+ handle.write("\n")
866
+ try:
867
+ os.chmod(cred_path, 0o600)
868
+ except OSError:
869
+ pass
870
+ return cred_path
871
+
872
+
873
+ def _bootstrap_claude_setup_token(session, ctx):
874
+ ctx["out"](
875
+ "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
876
+ )
877
+ run_info = _run_interactive_provider_command(
878
+ session,
879
+ "setup-token",
880
+ spawn=ctx.get("spawn"),
881
+ env_override=ctx.get("env"),
882
+ signal_emitter=ctx.get("signal_emitter"),
883
+ )
884
+ transcript_path = run_info.get("transcript_path")
885
+ if not transcript_path or not os.path.isfile(transcript_path):
886
+ raise CdxError(
887
+ "Claude setup-token completed, but cdx could not capture the token. "
888
+ "Run claude setup-token and save the token under credentials/default.json."
889
+ )
890
+ try:
891
+ with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
892
+ transcript = handle.read()
893
+ finally:
894
+ try:
895
+ os.remove(transcript_path)
896
+ except OSError:
897
+ pass
898
+ token = _extract_claude_oauth_token(transcript)
899
+ if not token:
900
+ raise CdxError(
901
+ "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output."
902
+ )
903
+ return _write_claude_oauth_token(session.get("authHome") or "", token)
904
+
905
+
840
906
  def handle_add(rest, ctx):
841
907
  json_flag, args = _parse_json_flag(rest)
842
908
  parsed = _parse_add_args(args)
@@ -1666,7 +1732,7 @@ def handle_login(rest, ctx):
1666
1732
  session = ctx["service"]["get_session"](args[0])
1667
1733
  if not session:
1668
1734
  raise CdxError(f"Unknown session: {args[0]}")
1669
- if session["provider"] not in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
1735
+ if session["provider"] == PROVIDER_CODEX:
1670
1736
  _run_interactive_provider_command(
1671
1737
  session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1672
1738
  signal_emitter=ctx.get("signal_emitter")
@@ -1675,6 +1741,26 @@ def handle_login(rest, ctx):
1675
1741
  session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1676
1742
  signal_emitter=ctx.get("signal_emitter")
1677
1743
  )
1744
+ auth_probe = _ensure_session_authentication(
1745
+ session,
1746
+ ctx["service"],
1747
+ spawn_sync=ctx.get("spawn_sync"),
1748
+ env_override=ctx.get("env"),
1749
+ behavior="probe-only",
1750
+ )
1751
+ if not auth_probe.get("authenticated") and session["provider"] == PROVIDER_CLAUDE:
1752
+ _bootstrap_claude_setup_token(session, ctx)
1753
+ auth_probe = _ensure_session_authentication(
1754
+ session,
1755
+ ctx["service"],
1756
+ spawn_sync=ctx.get("spawn_sync"),
1757
+ env_override=ctx.get("env"),
1758
+ behavior="probe-only",
1759
+ )
1760
+ if not auth_probe.get("authenticated"):
1761
+ raise CdxError(
1762
+ f"Login command completed, but session {session['name']} is still not authenticated."
1763
+ )
1678
1764
  now = _local_now_iso()
1679
1765
  ctx["service"]["update_auth_state"](args[0], lambda auth: {
1680
1766
  **auth, "status": "authenticated",
@@ -31,13 +31,16 @@ LAUNCH_PERMISSION_ARGS = {
31
31
  def _home_env_overrides(auth_home):
32
32
  """Return env vars that point the claude CLI to the given home directory.
33
33
 
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.
34
+ On Unix, only HOME is needed. Claude Code resolves its auth files relative
35
+ to HOME; forcing CLAUDE_CONFIG_DIR to this directory makes current Claude
36
+ Code builds ignore otherwise valid isolated credentials. On Windows, Node.js
37
+ resolves the home directory via USERPROFILE (and falls back to
38
+ HOMEDRIVE+HOMEPATH), so we set all three to ensure profile isolation works
39
+ regardless of the platform.
37
40
  """
38
41
  overrides = {
39
42
  "HOME": auth_home,
40
- "CLAUDE_CONFIG_DIR": auth_home,
43
+ "ANTHROPIC_CONFIG_DIR": auth_home,
41
44
  "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
42
45
  }
43
46
  if sys.platform == "win32":
@@ -47,6 +50,48 @@ def _home_env_overrides(auth_home):
47
50
  return overrides
48
51
 
49
52
 
53
+ def _anthropic_profile_name():
54
+ return "default"
55
+
56
+
57
+ def _anthropic_credentials_path(auth_home):
58
+ return os.path.join(auth_home, "credentials", f"{_anthropic_profile_name()}.json")
59
+
60
+
61
+ def _claude_env(base_env, auth_home):
62
+ env = {**base_env, **_home_env_overrides(auth_home)}
63
+ env.pop("CLAUDE_CONFIG_DIR", None)
64
+ env.pop("CODEX_HOME", None)
65
+ env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
66
+ return env
67
+
68
+
69
+ def _read_anthropic_oauth_token(auth_home):
70
+ try:
71
+ with open(_anthropic_credentials_path(auth_home), "r", encoding="utf-8") as handle:
72
+ credentials = json.load(handle)
73
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
74
+ return None
75
+ token = credentials.get("access_token") if isinstance(credentials, dict) else None
76
+ if not token:
77
+ return None
78
+ return str(token)
79
+
80
+
81
+ def _read_claude_account_email(auth_home):
82
+ config_path = os.path.join(auth_home, ".claude.json")
83
+ try:
84
+ with open(config_path, "r", encoding="utf-8") as handle:
85
+ config = json.load(handle)
86
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
87
+ return None
88
+ account = config.get("oauthAccount") if isinstance(config, dict) else None
89
+ email = account.get("emailAddress") if isinstance(account, dict) else None
90
+ if not email:
91
+ return None
92
+ return str(email).strip()
93
+
94
+
50
95
  def _antigravity_env_overrides(auth_home):
51
96
  overrides = {"HOME": auth_home}
52
97
  if sys.platform == "win32":
@@ -187,12 +232,17 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
187
232
  args = ["--name", session["name"]] + _launch_config_args(session)
188
233
  if initial_prompt:
189
234
  args.append(initial_prompt)
235
+ auth_home = _get_auth_home(session)
236
+ claude_env = _claude_env(env, auth_home)
237
+ oauth_token = _read_anthropic_oauth_token(auth_home)
238
+ if oauth_token:
239
+ claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
190
240
  return _wrap_launch_with_transcript(session, {
191
241
  "command": "claude",
192
242
  "args": args,
193
243
  "options": {
194
244
  "cwd": cwd,
195
- "env": {**env, **_home_env_overrides(_get_auth_home(session))},
245
+ "env": claude_env,
196
246
  },
197
247
  "label": "claude",
198
248
  }, env=env)
@@ -241,7 +291,11 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
241
291
  def _build_login_status_spec(session, env_override=None):
242
292
  env = {**os.environ, **(env_override or {})}
243
293
  if session["provider"] == PROVIDER_CLAUDE:
244
- env.update(_home_env_overrides(_get_auth_home(session)))
294
+ auth_home = _get_auth_home(session)
295
+ env = _claude_env(env, auth_home)
296
+ oauth_token = _read_anthropic_oauth_token(auth_home)
297
+ if oauth_token:
298
+ env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
245
299
 
246
300
  def parser(output):
247
301
  try:
@@ -273,9 +327,20 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
273
327
  cwd = cwd or os.getcwd()
274
328
  env = {**os.environ, **(env_override or {})}
275
329
  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}"}
330
+ auth_home = _get_auth_home(session)
331
+ env = _claude_env(env, auth_home)
332
+ args = ["auth", action]
333
+ label = f"claude auth {action}"
334
+ if action == "setup-token":
335
+ spec = {"command": "claude", "args": ["setup-token"],
336
+ "options": {"cwd": cwd, "env": env}, "label": "claude setup-token"}
337
+ return _wrap_launch_with_transcript(session, spec, env=env)
338
+ if action == "login":
339
+ email = _read_claude_account_email(auth_home)
340
+ if email:
341
+ args += ["--email", email]
342
+ return {"command": "claude", "args": args,
343
+ "options": {"cwd": cwd, "env": env}, "label": label}
279
344
  if session["provider"] == PROVIDER_ANTIGRAVITY:
280
345
  if action == "logout":
281
346
  raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
@@ -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",