cdx-manager 0.21.4 → 0.21.6

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.6-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.21.4
1
+ 0.21.6
@@ -0,0 +1,26 @@
1
+ # CDX Manager 0.21.5
2
+
3
+ ## Claude logins
4
+
5
+ - `cdx status` shows each Claude profile's auth type in a TYPE column: `setup-token` or `full-login` (a `/login` run inside the profile). It also appears in the single-session view and as `auth_type` in `--json`. Within 30 days of a setup-token's one-year expiry, the column shows `setup-token 12d`, or `setup-token expired` once it has lapsed, and `cdx doctor` warns with the `cdx login` command to run. Based on work by Nicolas Rigaudy (#15, #16, #21).
6
+ - The operations guide explains why a setup-token never loads claude.ai connectors such as Microsoft 365, and how to switch with `/login`.
7
+ - On macOS, `cdx login` on a Claude profile now also removes the profile's keychain `/login`, so the new setup-token is the one used at launch. Before, the keychain login kept winning. The misleading "falling back to claude setup-token" message is reworded (#18).
8
+
9
+ ## Fixes
10
+
11
+ - `cdx ready` never showed its notification: the scheduled job ran `cdx notify`, which now only serves agent hooks. On macOS the job was also written under the profile home inside a cdx session, where launchd never reads it (#18, #21).
12
+ - Windows:
13
+ - the session store lock now waits up to 120 seconds instead of failing after 10;
14
+ - the process-alive check no longer sends Ctrl+C to the process it probes (#18).
15
+ - Agent alert hooks work when the cdx path contains a space. Claude's `settings.json` is written atomically, through symlinks, and keeps its permissions (#18).
16
+ - Token prices reviewed on 2026-10-22:
17
+ - `claude-sonnet-5-5` cache reads are $0.10/MTok;
18
+ - `claude-haiku-5-5` is added;
19
+ - context variants such as `claude-opus-5-5[1m]` are priced at the base model's rates (#20).
20
+
21
+ ## Housekeeping
22
+
23
+ - `runs.json` keeps every running run plus the newest 500 finished ones.
24
+ - Handoff entries older than 7 days are deleted when a new one is prepared.
25
+ - Dead log-rotation code and duplicated helpers are removed.
26
+ - New tests cover the state-changing `cdx tray` commands (#17, #19).
@@ -0,0 +1,33 @@
1
+ # CDX Manager 0.21.6
2
+
3
+ ## Security
4
+
5
+ - **Windows toasts.** PowerShell also ends a single-quoted string on the typographic quotes ‘ ’ ‚ ‛, so those are now escaped too, in cdx and in the tray. A crafted folder name or response preview could run code. French text such as "l’agent" no longer drops the toast (#23).
6
+ - **Tray over WSL.** The tray no longer passes its commands through the WSL shell, which expanded `$(...)` and split arguments containing spaces. It also finds the user's own `cdx` instead of a stale system-wide one (#23).
7
+ - **`cdx set <name> --scrub-env on`** launches Claude with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`. The Bash commands, hooks and MCP servers the agent starts then no longer receive credentials, including the setup-token. It is off by default; `docs/operations.md` explains the trade-off (#26).
8
+ - **Backup bundles that include auth** use a stronger key derivation (scrypt N=2^17, or PBKDF2 at 600k iterations) and record its cost. Older bundles still import, and a crafted cost is refused (#25).
9
+ - **Claude profile creation** no longer replaces an unparseable or symlinked `settings.json`, and keeps its permissions (#23).
10
+ - **PyPI** releases are published with Trusted Publishing, without a stored API token (#27).
11
+
12
+ ## Fixes
13
+
14
+ - **Windows:**
15
+ - `cdx.cmd` now returns the real exit code; it always returned 0;
16
+ - `cdx ready` schedules its task on any Windows language, where `schtasks` refused the date on non-US systems;
17
+ - provider output is read as UTF-8;
18
+ - `cdx -v` and `--help` work without a home directory;
19
+ - the check after `cdx update` no longer prints "Could not determine home directory" (#22, #23).
20
+ - **`cdx run`** stops the provider when it receives SIGTERM or SIGHUP (a `kill`, a `systemctl stop` or a closed SSH session), not only on Ctrl+C (#24).
21
+ - **No more lost writes when two commands overlap:**
22
+ - tray events published while another is being acknowledged are kept;
23
+ - `cdx context set` and `clear` no longer race with an append (#24).
24
+ - **`cdx set`:**
25
+ - the usage line, help and docs list every flag;
26
+ - a bad on/off value names the flag, for example "Unsupported notify value: maybe (on|off)" (#25).
27
+ - **`cdx schema --json`** publishes every run error code and warning code. `cdx run` sets its error codes at the source instead of reading the message text (#25).
28
+
29
+ ## Housekeeping
30
+
31
+ - CI also tests Python 3.9, the minimum version declared in pyproject.
32
+ - The tray builds with `cargo --locked`.
33
+ - The test suite no longer touches the developer's real macOS keychain (#24, #25).
@@ -518,6 +518,28 @@
518
518
  "x86_64-pc-windows-msvc": "9729f245b46420ae4b11c1a50d3ba9cba682854866ef37a3f334dccbdd70ac89",
519
519
  "x86_64-unknown-linux-musl": "182fb651dab29a6a21b3b8ef56c196e954fd75230891f8532c21b09ff5fbacee"
520
520
  }
521
+ },
522
+ "v0.21.4": {
523
+ "github_tarball_sha256": "265936ceb35ffd43679e916f7ac2572b73e3a499803b86dfa8e87029a4188394",
524
+ "github_zip_sha256": "f9a648c4cba0d8c3204cac151f695d6fc4f86316d6dbdb6a1e8daf471dcd042b",
525
+ "tray_assets": {
526
+ "aarch64-apple-darwin": "b17aa8631acdcfa3e0de94ef8282b5b35791fd74d1e0be321bad42b20e7450ea",
527
+ "aarch64-unknown-linux-musl": "45eefeeb0d6c71269a24bece05780db98eaaa0e33630366f31cdb006f1dea946",
528
+ "x86_64-apple-darwin": "c10294583e88373570c7c5ad9094b711d57499d73358e9a1fa397bf5b76a12e3",
529
+ "x86_64-pc-windows-msvc": "0d4e6c736a789d8fddaf688cd61d899a305171fa6d7e856735b2cd7d2daef21c",
530
+ "x86_64-unknown-linux-musl": "8320025507801fd549b8c8dd4196a9378ddb25f137e48945e61e429bf2a864aa"
531
+ }
532
+ },
533
+ "v0.21.5": {
534
+ "github_tarball_sha256": "5a45b3ba4df72a4bba5bbd8c8c28728f7e804c67a4a7201d927f350d0276f432",
535
+ "github_zip_sha256": "b53d281da3540b85745a9b1e8abbdec797a0e97c1e1ec098d8c98753ffc334c2",
536
+ "tray_assets": {
537
+ "aarch64-apple-darwin": "e97c99054653a209b84190c2f08d3b1739527dc8e1d4795dd4b51271096fcdc7",
538
+ "aarch64-unknown-linux-musl": "04cfad61af12263eaa82f3b1fec12c8fad87762dc7a95b42e170f868156829e1",
539
+ "x86_64-apple-darwin": "b7a3beb55f34a9fae182ec164152d3295bf078a354c2788296c63c7c268c686e",
540
+ "x86_64-pc-windows-msvc": "d107e75e7700c2fcc7138d6bc14cdf6929fb3ad3e3d7a5b8648f44828f4ab4bb",
541
+ "x86_64-unknown-linux-musl": "00d93b4f16b58c203fbdc8cf80b15f1402ebf3fef8568162505142a3adb20272"
542
+ }
521
543
  }
522
544
  }
523
545
  }
package/docs/cli.md CHANGED
@@ -23,8 +23,8 @@
23
23
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
24
24
  | `cdx configs [--json]` | Show persistent launch settings for all sessions in one table |
25
25
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
26
- | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
27
- | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--rtk\|--logics\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
26
+ | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
27
+ | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--notify-preview\|--scrub-env\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
28
28
  | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
29
29
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
30
30
  | `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
@@ -56,7 +56,7 @@
56
56
  | `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
57
57
  | `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
58
58
  | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike |
59
- | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
59
+ | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status; Claude rows also report the auth type (`setup-token` or `full-login`) in a TYPE column and as `auth_type` in JSON. |
60
60
  | `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
61
61
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
62
62
  | `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
@@ -151,6 +151,8 @@ Hosts keep independent rotating credentials, so copying an auth file is not ongo
151
151
  credential sync.
152
152
 
153
153
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first. `cdx doctor` uses the same Codex app-server probe as `cdx auth refresh`, so a local auth file alone is never reported as live authentication; a rejected refresh token is reported as `login_required` with the matching login command.
154
+ - **A Claude session has no claude.ai connectors (Microsoft 365, ...) in `/mcp`** — `cdx login <name>` stores a setup-token for Claude profiles, and Claude Code can only make model requests with it: it never loads claude.ai connectors. `cdx status` shows each Claude profile's auth type (`setup-token` or `full-login`; `auth_type` in `--json`). To switch, start the session and run `/login` inside it. Once the profile holds its own claude.ai login (its `.claude/.credentials.json`, or its profile-specific `Claude Code-credentials-<hash>` keychain entry on macOS), cdx stops passing the setup-token on later launches. If that login later expires, run `/login` again, or go back to a setup-token with `cdx login <name>`, which removes the profile's `/login`.
155
+ - **Keep credentials away from what a Claude agent runs** — by default Claude Code passes its credentials, including the setup-token cdx injects, to the Bash commands, hooks and stdio MCP servers it starts. `cdx set <name> --scrub-env on` launches that session with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, which strips credential-looking variables from those subprocesses (GitHub tokens and proxy settings stay). It is off by default: it also removes other secrets an agent may need (`NPM_TOKEN`, a `DATABASE_URL` with a password), and on Linux it isolates Bash from host processes, so `ps` and `kill` no longer see them. Worth turning on for unattended sessions that read untrusted content.
154
156
  - **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
155
157
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
156
158
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
package/install.ps1 CHANGED
@@ -90,15 +90,18 @@ try {
90
90
  Copy-Item -Path (Join-Path $sourceDir.FullName "*") -Destination $targetDir -Recurse -Force
91
91
 
92
92
  $launcherPath = Join-Path $binDir "cdx.cmd"
93
+ # No parenthesized block: cmd expands %ERRORLEVEL% when it parses a block,
94
+ # before py runs, so `exit /b %ERRORLEVEL%` inside one always returned 0.
93
95
  $launcher = @"
94
96
  @echo off
95
97
  set SCRIPT=%~dp0..\versions\$versionDir\bin\cdx
96
98
  where py >nul 2>nul
97
- if %ERRORLEVEL%==0 (
98
- py -3 "%SCRIPT%" %*
99
- exit /b %ERRORLEVEL%
100
- )
99
+ if errorlevel 1 goto nopy
100
+ py -3 "%SCRIPT%" %*
101
+ exit /b %ERRORLEVEL%
102
+ :nopy
101
103
  python "%SCRIPT%" %*
104
+ exit /b %ERRORLEVEL%
102
105
  "@
103
106
  Set-Content -Path $launcherPath -Value $launcher -Encoding ascii
104
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.21.4",
3
+ "version": "0.21.6",
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.21.4"
7
+ version = "0.21.6"
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,7 @@ import shutil
15
15
  import subprocess
16
16
 
17
17
  from .config import get_cdx_home
18
+ from .fs_utils import atomic_write
18
19
  from .notify import notification_channel, send_desktop_notification
19
20
  from .tray_alerts import alerts_enabled
20
21
  from .tray_events import publish
@@ -71,7 +72,11 @@ def resolve_hook_command(env=None):
71
72
  the provider inherits.
72
73
  """
73
74
  env = env or os.environ
74
- return env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
75
+ path = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
76
+ # Hooks run through a shell, so a space in the path ("C:\\Users\\Jean Dupont",
77
+ # a macOS home with a space) would split it. Double quotes read the same in
78
+ # sh, Git Bash and cmd.exe; a path without whitespace stays as it was.
79
+ return f'"{path}"' if any(ch.isspace() for ch in path) else path
75
80
 
76
81
 
77
82
  def read_hook_payload(args, stdin_text=None):
@@ -481,7 +486,7 @@ def _subprocess_spawn_sync(command, args, options):
481
486
  [command, *args],
482
487
  env=options.get("env"),
483
488
  capture_output=True,
484
- text=True,
489
+ text=True, encoding="utf-8", errors="replace",
485
490
  timeout=60,
486
491
  )
487
492
 
@@ -547,11 +552,16 @@ def _read_json(path):
547
552
 
548
553
 
549
554
  def _write_json(path, document):
555
+ # Atomic, because Claude Code may rewrite the same settings file at the same
556
+ # moment. Through realpath so a symlinked settings.json stays a symlink, and
557
+ # with the file's own mode so a user-tightened file is not loosened.
550
558
  try:
551
- os.makedirs(os.path.dirname(path), exist_ok=True)
552
- with open(path, "w", encoding="utf-8") as handle:
553
- json.dump(document, handle, indent=2)
554
- handle.write("\n")
559
+ target = os.path.realpath(path)
560
+ try:
561
+ mode = os.stat(target).st_mode & 0o777
562
+ except FileNotFoundError:
563
+ mode = 0o644
564
+ atomic_write(target, json.dumps(document, indent=2) + "\n", mode=mode)
555
565
  except OSError:
556
566
  # An unwritable home is a skipped provisioning step, not a failed launch.
557
567
  return False
@@ -11,10 +11,16 @@ BUNDLE_SCHEMA_VERSION = 1
11
11
  _SALT_BYTES = 16
12
12
  _NONCE_BYTES = 16
13
13
  _AEAD_NONCE_BYTES = 12
14
- _SCRYPT_N = 2 ** 14
15
- _SCRYPT_R = 8
16
- _SCRYPT_P = 1
17
- _PBKDF2_ITERATIONS = 200000
14
+ # New bundles record their KDF cost in `kdf_params`, at OWASP-level costs
15
+ # (scrypt N=2^17, PBKDF2-SHA256 600k). Bundles without the field were written
16
+ # at the earlier costs, which stay readable. Recorded costs are capped so a
17
+ # crafted bundle cannot make an import burn minutes of CPU or gigabytes of RAM.
18
+ _SCRYPT_PARAMS = {"n": 2 ** 17, "r": 8, "p": 1}
19
+ _PBKDF2_PARAMS = {"iterations": 600000}
20
+ _LEGACY_SCRYPT_PARAMS = {"n": 2 ** 14, "r": 8, "p": 1}
21
+ _LEGACY_PBKDF2_PARAMS = {"iterations": 200000}
22
+ _MAX_SCRYPT_N = 2 ** 20
23
+ _MAX_PBKDF2_ITERATIONS = 10_000_000
18
24
  _AEAD_ALGORITHM = "aes-256-gcm"
19
25
  _LEGACY_ALGORITHM = "sha256-xor-hmac"
20
26
  _KDF_SCRYPT = "scrypt"
@@ -78,19 +84,49 @@ def _bundle_kdf_candidates(wrapper):
78
84
  return [recorded]
79
85
 
80
86
 
81
- def _derive_key_material(passphrase, salt, kdf):
87
+ def _default_params(kdf):
88
+ return dict(_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _PBKDF2_PARAMS)
89
+
90
+
91
+ def _bundle_kdf_params(wrapper, kdf):
92
+ """The recorded cost, or the pre-`kdf_params` cost for older bundles."""
93
+ recorded = wrapper.get("kdf_params")
94
+ if recorded is None:
95
+ return dict(_LEGACY_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _LEGACY_PBKDF2_PARAMS)
96
+ if not isinstance(recorded, dict) or ("n" in recorded) != (kdf == _KDF_SCRYPT):
97
+ return None # recorded for the other KDF: not this candidate
98
+ try:
99
+ if kdf == _KDF_SCRYPT:
100
+ params = {key: int(recorded[key]) for key in ("n", "r", "p")}
101
+ valid = (2 <= params["n"] <= _MAX_SCRYPT_N and params["n"] & (params["n"] - 1) == 0
102
+ and 1 <= params["r"] <= 32 and 1 <= params["p"] <= 16)
103
+ else:
104
+ params = {"iterations": int(recorded["iterations"])}
105
+ valid = 1 <= params["iterations"] <= _MAX_PBKDF2_ITERATIONS
106
+ except (TypeError, ValueError, KeyError):
107
+ valid = False
108
+ if not valid:
109
+ raise CdxError("Unsupported bundle key derivation parameters.")
110
+ return params
111
+
112
+
113
+ def _derive_key_material(passphrase, salt, kdf, params=None):
114
+ params = params or _default_params(kdf)
82
115
  if kdf == _KDF_SCRYPT:
83
116
  if not _available_kdf(_KDF_SCRYPT):
84
117
  raise CdxError(
85
118
  "This bundle records the scrypt key derivation function, which this Python runtime "
86
119
  "does not provide. Decode it with a Python built against OpenSSL scrypt support."
87
120
  )
121
+ n, r, p = params["n"], params["r"], params["p"]
88
122
  return hashlib.scrypt(
89
123
  passphrase,
90
124
  salt=salt,
91
- n=_SCRYPT_N,
92
- r=_SCRYPT_R,
93
- p=_SCRYPT_P,
125
+ n=n,
126
+ r=r,
127
+ p=p,
128
+ # scrypt needs 128*r*n bytes; OpenSSL's 32 MiB default refuses N=2^17.
129
+ maxmem=2 * 128 * r * n * p + 1024 * 1024,
94
130
  dklen=64,
95
131
  )
96
132
  if kdf == _KDF_PBKDF2:
@@ -98,23 +134,23 @@ def _derive_key_material(passphrase, salt, kdf):
98
134
  "sha256",
99
135
  passphrase,
100
136
  salt,
101
- _PBKDF2_ITERATIONS,
137
+ params["iterations"],
102
138
  dklen=64,
103
139
  )
104
140
  raise CdxError("Unsupported bundle key derivation function.")
105
141
 
106
142
 
107
- def _derive_keys(passphrase, salt, kdf=None):
143
+ def _derive_keys(passphrase, salt, kdf=None, params=None):
108
144
  if not passphrase:
109
145
  raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
110
146
  if isinstance(passphrase, str):
111
147
  passphrase = passphrase.encode("utf-8")
112
- key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf())
148
+ key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf(), params)
113
149
  return key_material[:32], key_material[32:]
114
150
 
115
151
 
116
- def _derive_aead_key(passphrase, salt, kdf=None):
117
- enc_key, _mac_key = _derive_keys(passphrase, salt, kdf)
152
+ def _derive_aead_key(passphrase, salt, kdf=None, params=None):
153
+ enc_key, _mac_key = _derive_keys(passphrase, salt, kdf, params)
118
154
  return enc_key
119
155
 
120
156
 
@@ -152,11 +188,13 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
152
188
  salt = os.urandom(_SALT_BYTES)
153
189
  nonce = os.urandom(_AEAD_NONCE_BYTES)
154
190
  kdf = _default_kdf()
155
- aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf))
191
+ params = _default_params(kdf)
192
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf, params))
156
193
  ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
157
194
  wrapper.update({
158
195
  "encryption": _AEAD_ALGORITHM,
159
196
  "kdf": kdf,
197
+ "kdf_params": params,
160
198
  "salt": _b64_encode(salt),
161
199
  "nonce": _b64_encode(nonce),
162
200
  "payload": _b64_encode(ciphertext),
@@ -186,9 +224,12 @@ def decode_bundle(data, passphrase=None):
186
224
  payload_bytes = None
187
225
  failure = None
188
226
  for kdf in kdf_candidates:
227
+ params = _bundle_kdf_params(wrapper, kdf)
228
+ if params is None:
229
+ continue
189
230
  # Derivation errors are about the bundle, not the passphrase, so
190
231
  # they must escape rather than be retried as a bad secret.
191
- cipher = aead(_derive_aead_key(passphrase, salt, kdf))
232
+ cipher = aead(_derive_aead_key(passphrase, salt, kdf, params))
192
233
  try:
193
234
  payload_bytes = cipher.decrypt(nonce, ciphertext, None)
194
235
  break
@@ -200,7 +241,10 @@ def decode_bundle(data, passphrase=None):
200
241
  expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
201
242
  payload_bytes = None
202
243
  for kdf in kdf_candidates:
203
- enc_key, mac_key = _derive_keys(passphrase, salt, kdf)
244
+ params = _bundle_kdf_params(wrapper, kdf)
245
+ if params is None:
246
+ continue
247
+ enc_key, mac_key = _derive_keys(passphrase, salt, kdf, params)
204
248
  actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
205
249
  if hmac.compare_digest(actual_mac, expected_mac):
206
250
  payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
@@ -81,6 +81,91 @@ def peek_keychain_credentials(auth_home):
81
81
  return None
82
82
 
83
83
 
84
+ INFERENCE_SCOPE = "user:inference"
85
+
86
+
87
+ def is_full_claude_login(credentials):
88
+ """True for a claude.ai login, False for an inference-only credential.
89
+
90
+ A setup-token can only make model requests, so Claude Code never loads
91
+ claude.ai connectors with it. A real `/login` stores a refresh token and
92
+ scopes beyond inference. Only the shape is inspected, never the values.
93
+ """
94
+ oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
95
+ if not isinstance(oauth, dict) or not oauth.get("accessToken"):
96
+ return False
97
+ scopes = oauth.get("scopes")
98
+ extra_scopes = isinstance(scopes, list) and any(
99
+ isinstance(scope, str) and scope != INFERENCE_SCOPE for scope in scopes
100
+ )
101
+ return bool(oauth.get("refreshToken")) or extra_scopes
102
+
103
+
104
+ def read_claude_login_file(auth_home):
105
+ try:
106
+ with open(os.path.join(auth_home, ".claude", ".credentials.json"), encoding="utf-8") as handle:
107
+ data = json.load(handle)
108
+ except (OSError, ValueError):
109
+ return None
110
+ return data if isinstance(data, dict) else None
111
+
112
+
113
+ def has_full_claude_login(auth_home):
114
+ """Whether the profile holds a claude.ai login: the profile's
115
+ `.credentials.json` (Linux, Windows) or its keychain entry (macOS)."""
116
+ if not auth_home:
117
+ return False
118
+ return is_full_claude_login(read_claude_login_file(auth_home)) or is_full_claude_login(
119
+ peek_keychain_credentials(auth_home)
120
+ )
121
+
122
+
123
+ def claude_auth_type(auth_home):
124
+ """`full-login`, `setup-token`, or None when the profile holds neither."""
125
+ if has_full_claude_login(auth_home):
126
+ return "full-login"
127
+ try:
128
+ with open(os.path.join(auth_home, "credentials", "default.json"), encoding="utf-8") as handle:
129
+ token = json.load(handle).get("access_token")
130
+ except (OSError, ValueError, AttributeError):
131
+ return None
132
+ return "setup-token" if token else None
133
+
134
+
135
+ # `claude setup-token` mints a token valid for one year. Tokens minted before
136
+ # cdx recorded `created_at` fall back to the file's mtime, which a copy or a
137
+ # backup import resets, so their date can read later than it really is.
138
+ SETUP_TOKEN_LIFETIME_SECONDS = 365 * 24 * 3600
139
+ SETUP_TOKEN_WARN_DAYS = 30
140
+
141
+
142
+ def setup_token_expires_at(auth_home):
143
+ """Epoch seconds when the profile's setup-token expires, or None."""
144
+ path = os.path.join(auth_home or "", "credentials", "default.json")
145
+ try:
146
+ with open(path, encoding="utf-8") as handle:
147
+ data = json.load(handle)
148
+ if not isinstance(data, dict) or not data.get("access_token"):
149
+ return None
150
+ created = data.get("created_at")
151
+ if isinstance(created, str):
152
+ from datetime import datetime
153
+ created = datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp()
154
+ elif not isinstance(created, (int, float)):
155
+ created = os.path.getmtime(path)
156
+ except (OSError, ValueError):
157
+ return None
158
+ return created + SETUP_TOKEN_LIFETIME_SECONDS
159
+
160
+
161
+ def setup_token_days_left(auth_home, now=None):
162
+ expires = setup_token_expires_at(auth_home)
163
+ if expires is None:
164
+ return None
165
+ import time
166
+ return int((expires - (time.time() if now is None else now)) // 86400)
167
+
168
+
84
169
  def write_keychain_credentials(auth_home, data):
85
170
  _reject_custom_oauth()
86
171
  user, service = _keychain_identity(auth_home)
@@ -214,7 +214,7 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
214
214
  command,
215
215
  env=run_env,
216
216
  capture_output=True,
217
- text=True,
217
+ text=True, encoding="utf-8", errors="replace",
218
218
  timeout=CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS,
219
219
  check=False,
220
220
  )
package/src/cli.py CHANGED
@@ -6,6 +6,7 @@ import sys
6
6
  from datetime import datetime, timezone
7
7
 
8
8
  from .agent_notify import handle_notify
9
+ from .cli_args import SET_USAGE
9
10
  from .cli_commands import (
10
11
  API_SCHEMA_VERSION,
11
12
  STATUS_USAGE,
@@ -72,10 +73,6 @@ from .cli_render import (
72
73
  )
73
74
  from .config import PROVIDER_OLLAMA
74
75
  from .errors import CdxError
75
- from .provider_runtime import (
76
- LOG_ROTATE_BYTES,
77
- _rotate_log_if_needed,
78
- )
79
76
  from .session_service import create_session_service
80
77
  from .status_view import (
81
78
  _format_blocking_quota,
@@ -138,13 +135,11 @@ VERSION = _resolve_version()
138
135
  # them here documents that and stops F401 from treating them as dead imports.
139
136
  __all__ = [
140
137
  "STATUS_USAGE",
141
- "LOG_ROTATE_BYTES",
142
138
  "_format_blocking_quota",
143
139
  "_format_reset_time",
144
140
  "_format_status_detail",
145
141
  "_format_status_rows",
146
142
  "_pad_table",
147
- "_rotate_log_if_needed",
148
143
  "_visible_len",
149
144
  "cli_entry",
150
145
  "format_json_error",
@@ -253,7 +248,7 @@ def _print_help(use_color=False):
253
248
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
254
249
  f" {_style('cdx configs [--json]', '36', use_color)}",
255
250
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
256
- f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--notify-preview on|off] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
251
+ f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
257
252
  f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--all) [--json]', '36', use_color)}",
258
253
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
259
254
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
@@ -539,7 +534,6 @@ def main(argv, options=None):
539
534
  stderr = options.get("stderr", sys.stderr)
540
535
  stdin_is_tty = options.get("stdin", {}).get("isTTY", hasattr(sys.stdin, "isatty") and sys.stdin.isatty())
541
536
  use_color = _should_use_color(env, stdout)
542
- service = options.get("service") or create_session_service({"env": env})
543
537
  spawn = options.get("spawn")
544
538
  spawn_sync = options.get("spawn_sync")
545
539
  refresh_fn = options.get("refreshClaudeSessionStatus")
@@ -565,6 +559,10 @@ def main(argv, options=None):
565
559
  out(f"{_print_version()}\n")
566
560
  return 0
567
561
 
562
+ # Built after --help/--version: those must answer even where the home
563
+ # directory cannot be resolved (a service or task with a minimal environment).
564
+ service = options.get("service") or create_session_service({"env": env})
565
+
568
566
  if argv == ["--json"]:
569
567
  rows = service["format_list_rows"]()
570
568
  notices = _get_update_notices(service, env, options)