cdx-manager 0.9.13 → 0.9.15

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.9.13-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.9.15-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
 
@@ -359,8 +359,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
359
359
  | `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports cross-provider handoff |
360
360
  | `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
361
361
  | `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
362
- | `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
363
- | `cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
362
+ | `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
363
+ | `cdx import <file> [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
364
364
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
365
365
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
366
366
  | `cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--tls-cert <path>] [--tls-key <path>] [--open] [--no-open]` | Open the Logics browser/focus viewer by delegating to `logics-manager view`; all viewer flags are forwarded; JSON mode reports diagnostics without launching it |
@@ -510,7 +510,7 @@ cdx import backup-auth.cdx --passphrase-env CDX_BUNDLE_PASSPHRASE
510
510
  Notes:
511
511
 
512
512
  - `--include-auth` is encrypted, requires a passphrase, and exports only provider credential files rather than full profile caches or logs.
513
- - Without `--passphrase-env`, `cdx` prompts in an interactive terminal.
513
+ - Without `--passphrase-env`, `cdx` prompts in an interactive terminal. Non-interactive callers can pass `--passphrase-stdin` to read the passphrase from stdin (one line) instead of exposing it in the child environment.
514
514
  - `--sessions work,perso` exports or imports only a subset.
515
515
  - `--force` allows overwriting existing destination sessions during import or replacing an existing bundle file during export.
516
516
  - Auth bundles contain credentials. Treat them like secrets and delete them after transfer.
@@ -7,12 +7,6 @@ const { spawnSync } = require("node:child_process");
7
7
 
8
8
  const PYTHON_VERSION_CHECK = "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)";
9
9
  const PYTHON_CACHE_PREFIX = path.join(os.tmpdir(), "cdx-manager-pycache");
10
- const SIGNAL_EXIT_CODES = {
11
- SIGHUP: 129,
12
- SIGINT: 130,
13
- SIGTERM: 143,
14
- };
15
-
16
10
  const WINDOWS_CANDIDATES = [
17
11
  { command: "py", args: ["-3"], label: "py -3" },
18
12
  { command: "python", args: [], label: "python" },
@@ -148,7 +142,10 @@ function runPython(args, options = {}) {
148
142
  }
149
143
 
150
144
  if (result.signal) {
151
- return SIGNAL_EXIT_CODES[result.signal] || 128;
145
+ // Conventional 128+signum so supervisors can tell SIGKILL (137) from
146
+ // SIGSEGV (139) instead of a flat 128.
147
+ const signum = os.constants.signals[result.signal];
148
+ return signum ? 128 + signum : 128;
152
149
  }
153
150
 
154
151
  return typeof result.status === "number" ? result.status : 1;
@@ -0,0 +1,27 @@
1
+ # CDX Manager 0.9.14
2
+
3
+ ## Highlights
4
+
5
+ - Added `--passphrase-stdin` for auth bundle export/import flows, avoiding passphrases in child process environments.
6
+
7
+ ## Changes
8
+
9
+ ### Auth bundle passphrase input
10
+
11
+ `cdx export --include-auth` and `cdx import` now accept `--passphrase-stdin` as an alternative to `--passphrase-env`.
12
+
13
+ This lets non-interactive callers provide the bundle passphrase through standard input instead of storing it in an environment variable. The new flag is mutually exclusive with `--passphrase-env`, and export still requires `--include-auth` before a passphrase is accepted.
14
+
15
+ Usage strings, command help, README examples, and regression coverage were updated for the new input mode.
16
+
17
+ ## Validation
18
+
19
+ - `npm run release:validate`
20
+ - `npm run lint`
21
+ - `npm test`
22
+ - `logics-manager lint --require-status`
23
+ - `logics-manager audit`
24
+ - `git diff --check`
25
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
26
+ - `python -m build`
27
+ - `python -m twine check dist/*`
@@ -0,0 +1,39 @@
1
+ # CDX Manager 0.9.15
2
+
3
+ ## Highlights
4
+
5
+ - Hardened concurrent session, run, and file updates against lost or partial writes.
6
+ - Improved status accuracy when multiple Codex and Claude data sources disagree.
7
+ - Tightened credential handling, headless process cleanup, CLI parsing, and release gating.
8
+
9
+ ## Changes
10
+
11
+ ### Reliable state and status
12
+
13
+ Session and run read-modify-write operations now stay under a single lock, and context, settings, and bundle exports use atomic replacement. Merge imports preserve live local runtime state, malformed JSONL usage records no longer discard an entire run, and refresh tolerates late or failed per-session writes.
14
+
15
+ Status selection now ranks structured, attributed, and fresh records consistently, preserves compatible fields across equally trusted candidates, handles year-wrapped reset timestamps, and treats a zero credit balance as no remaining credit.
16
+
17
+ ### Safer auth and process handling
18
+
19
+ Credential files and directories are private from creation, and temporary Claude setup-token transcripts are removed on both success and failure.
20
+
21
+ Headless timeouts terminate the complete process group, while signal exits consistently return `128 + signum`.
22
+
23
+ ### CLI, updates, and release automation
24
+
25
+ Argument parsing now rejects conflicting target selectors and flags used as values, while subcommand help remains scoped to the subcommand. Headless selection no longer imposes an implicit reasoning-effort floor.
26
+
27
+ Failed Logics Manager version checks are no longer cached, missing updater return codes fail safely, non-epoch Claude reset headers no longer abort refresh, and publication now gates on the latest CI run for the release commit.
28
+
29
+ ## Validation
30
+
31
+ - `npm run release:validate`
32
+ - `npm run lint`
33
+ - `npm test`
34
+ - `logics-manager lint --require-status`
35
+ - `logics-manager audit`
36
+ - `git diff --check`
37
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
38
+ - `python -m build`
39
+ - `python -m twine check dist/*`
@@ -116,6 +116,14 @@
116
116
  "v0.9.12": {
117
117
  "github_tarball_sha256": "ed9de98e1b4df5e42a1e389580f03d4b4b2e09ee8f63d23110090e6f164b8ae6",
118
118
  "github_zip_sha256": "55f9ead146411ae740db1cae3f91c56d6ebe33c03345f9d4bfd487e45962e412"
119
+ },
120
+ "v0.9.13": {
121
+ "github_tarball_sha256": "4541dcecbfc12705f8e50e61ab1cb1f53dd6a209cc4a6d33ec3a4e533a3da3eb",
122
+ "github_zip_sha256": "a8df11be1910a0562e0f97f69e9f35ee2fdf90ef50cb40689c85bf5ee5937027"
123
+ },
124
+ "v0.9.14": {
125
+ "github_tarball_sha256": "5469be5448eee56b5f0834d0c42d9ed3ca088158f08052bd8addc552635fe33d",
126
+ "github_zip_sha256": "7eb5650cb348e3ef2a082cd7fb39d7f4c170d4a095fe88719a7ebbfb7601db48"
119
127
  }
120
128
  }
121
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
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.9.13"
7
+ version = "0.9.15"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,6 +40,8 @@ def read_bundle_meta(data):
40
40
  except (UnicodeDecodeError, json.JSONDecodeError) as error:
41
41
  raise CdxError("Invalid bundle format.") from error
42
42
 
43
+ if not isinstance(wrapper, dict):
44
+ raise CdxError("Invalid bundle format.")
43
45
  if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
44
46
  raise CdxError("Unsupported bundle schema version.")
45
47
  return wrapper
@@ -83,9 +83,16 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
83
83
  for t in threads:
84
84
  t.join(timeout=10)
85
85
 
86
+ # A worker that outlived its join can still insert into results/errors;
87
+ # snapshot both so late writes can't mutate what we iterate.
88
+ results = dict(results)
89
+ errors = list(errors)
90
+
91
+ recorded = []
86
92
  for name, usage in results.items():
87
93
  try:
88
94
  service["record_status"](name, usage)
89
- except CdxError:
95
+ recorded.append(name)
96
+ except (CdxError, OSError):
90
97
  errors.append({"session": name, "error": CdxError(f"Failed to record Claude status for {name}")})
91
- return {"refreshed": sorted(results), "errors": errors}
98
+ return {"refreshed": sorted(recorded), "errors": errors}
@@ -103,10 +103,41 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
103
103
 
104
104
 
105
105
  def _format_reset_date(unix_seconds):
106
- dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
106
+ try:
107
+ dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
108
+ except (TypeError, ValueError, OSError):
109
+ return None
107
110
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
108
111
 
109
112
 
113
+ def _parse_reset_epoch(value):
114
+ # The reset headers are unix seconds today, but an RFC3339 value must
115
+ # not abort the whole refresh.
116
+ if value in (None, ""):
117
+ return None
118
+ text = str(value).strip()
119
+ try:
120
+ return float(text)
121
+ except ValueError:
122
+ pass
123
+ try:
124
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
125
+ except ValueError:
126
+ return None
127
+ if parsed.tzinfo is None:
128
+ parsed = parsed.replace(tzinfo=timezone.utc)
129
+ return parsed.timestamp()
130
+
131
+
132
+ def _safe_float(value):
133
+ if value is None:
134
+ return None
135
+ try:
136
+ return float(value)
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
110
141
  def _remaining_from_utilization(value):
111
142
  if value is None:
112
143
  return None
@@ -184,11 +215,11 @@ def fetch_claude_rate_limit_headers(access_token):
184
215
  if util_5h is None and util_7d is None:
185
216
  return None
186
217
 
187
- utilization_5h = float(util_5h) if util_5h is not None else None
188
- utilization_7d = float(util_7d) if util_7d is not None else None
218
+ utilization_5h = _safe_float(util_5h)
219
+ utilization_7d = _safe_float(util_7d)
189
220
 
190
- reset_5h_at = _format_reset_date(int(reset_5h)) if reset_5h else None
191
- reset_week_at = _format_reset_date(int(reset_7d)) if reset_7d else None
221
+ reset_5h_at = _format_reset_date(_parse_reset_epoch(reset_5h)) if reset_5h else None
222
+ reset_week_at = _format_reset_date(_parse_reset_epoch(reset_7d)) if reset_7d else None
192
223
  reset_at = reset_week_at or reset_5h_at
193
224
 
194
225
  return {
package/src/cli.py CHANGED
@@ -66,7 +66,7 @@ from .status_view import (
66
66
  )
67
67
  from .update_check import check_for_update, check_logics_manager_for_update
68
68
 
69
- VERSION = "0.9.13"
69
+ VERSION = "0.9.15"
70
70
 
71
71
  # Public surface: this module is a facade. Names below are imported above
72
72
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -192,8 +192,8 @@ def _print_help(use_color=False):
192
192
  f" {_style('cdx enable <name> [--json]', '36', use_color)}",
193
193
  f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
194
194
  f" {_style('cdx clean [name] [--json]', '36', use_color)}",
195
- f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
196
- f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force|--merge] [--json]', '36', use_color)}",
195
+ f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force] [--json]', '36', use_color)}",
196
+ f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force|--merge] [--json]', '36', use_color)}",
197
197
  f" {_style('cdx doctor [--json]', '36', use_color)}",
198
198
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
199
199
  f" {_style('cdx view [--json]', '36', use_color)}",
@@ -324,14 +324,15 @@ def main(argv, options=None):
324
324
  def err(text):
325
325
  stderr.write(text)
326
326
 
327
- # Flags
328
- if "--help" in argv or "-h" in argv:
327
+ # Flags — only when leading, so 'cdx history -h' reaches the subcommand
328
+ # instead of being hijacked as a malformed top-level help call.
329
+ if argv[:1] in (["--help"], ["-h"]):
329
330
  if len(argv) != 1:
330
331
  raise CdxError("Usage: cdx --help")
331
332
  out(f"{_print_help(use_color=use_color)}\n")
332
333
  return 0
333
334
 
334
- if "--version" in argv or "-v" in argv:
335
+ if argv[:1] in (["--version"], ["-v"]):
335
336
  if len(argv) != 1:
336
337
  raise CdxError("Usage: cdx --version")
337
338
  out(f"{_print_version()}\n")
package/src/cli_args.py CHANGED
@@ -15,8 +15,8 @@ STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECON
15
15
  DOCTOR_USAGE = "Usage: cdx doctor [--json]"
16
16
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
17
17
  UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
18
- EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
19
- IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
18
+ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
19
+ IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
20
20
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
21
21
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
22
22
  SET_USAGE = "Usage: 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] [--priority 0..100] [--json]"
@@ -55,7 +55,7 @@ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=
55
55
  parsed[spec["key"]] = True
56
56
  index += 1
57
57
  continue
58
- value, index = _read_option_value(args, index, usage)
58
+ value, index = _read_option_value(args, index, usage, schema=schema)
59
59
  parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
60
60
  continue
61
61
  if arg.startswith("--") and "=" in arg:
@@ -162,6 +162,8 @@ def _parse_set_args(args):
162
162
  raise CdxError(SET_USAGE)
163
163
  if parsed["names"] and parsed["provider"]:
164
164
  raise CdxError(SET_USAGE)
165
+ if parsed["sessions"] and parsed["provider"]:
166
+ raise CdxError(SET_USAGE)
165
167
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
166
168
  raise CdxError(SET_USAGE)
167
169
  settings = {
@@ -200,6 +202,8 @@ def _parse_unset_args(args):
200
202
  raise CdxError(UNSET_USAGE)
201
203
  if parsed["names"] and parsed["provider"]:
202
204
  raise CdxError(UNSET_USAGE)
205
+ if parsed["sessions"] and parsed["provider"]:
206
+ raise CdxError(UNSET_USAGE)
203
207
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
204
208
  raise CdxError(UNSET_USAGE)
205
209
  keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
@@ -501,10 +505,16 @@ def _parse_stats_args(args, now=None):
501
505
  }
502
506
 
503
507
 
504
- def _read_option_value(args, index, usage):
508
+ def _read_option_value(args, index, usage, schema=None):
505
509
  if index + 1 >= len(args):
506
510
  raise CdxError(usage)
507
- return args[index + 1], index + 2
511
+ value = args[index + 1]
512
+ # A known flag is never a value: '--model --json' is a missing value,
513
+ # not model='--json'. Literal flag-like values remain expressible as
514
+ # '--model=--json'.
515
+ if schema and value in schema:
516
+ raise CdxError(usage)
517
+ return value, index + 2
508
518
 
509
519
 
510
520
  def _parse_session_names(value):
@@ -585,12 +595,15 @@ def _parse_export_args(args):
585
595
  "transform": _parse_session_names,
586
596
  },
587
597
  "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
598
+ "--passphrase-stdin": {"key": "passphrase_stdin", "type": "bool", "default": False},
588
599
  }, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
589
600
  parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
590
601
  if not parsed["file_path"]:
591
602
  raise CdxError(EXPORT_USAGE)
592
- if parsed["passphrase_env"] and not parsed["include_auth"]:
593
- raise CdxError("--passphrase-env requires --include-auth for export.")
603
+ if parsed["passphrase_env"] and parsed["passphrase_stdin"]:
604
+ raise CdxError("--passphrase-env and --passphrase-stdin are mutually exclusive.")
605
+ if (parsed["passphrase_env"] or parsed["passphrase_stdin"]) and not parsed["include_auth"]:
606
+ raise CdxError("--passphrase-env/--passphrase-stdin requires --include-auth for export.")
594
607
  return parsed
595
608
 
596
609
 
@@ -606,12 +619,15 @@ def _parse_import_args(args):
606
619
  "transform": _parse_session_names,
607
620
  },
608
621
  "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
622
+ "--passphrase-stdin": {"key": "passphrase_stdin", "type": "bool", "default": False},
609
623
  }, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
610
624
  parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
611
625
  if not parsed["file_path"]:
612
626
  raise CdxError(IMPORT_USAGE)
613
627
  if parsed["force"] and parsed["merge"]:
614
628
  raise CdxError("--force and --merge are mutually exclusive.")
629
+ if parsed["passphrase_env"] and parsed["passphrase_stdin"]:
630
+ raise CdxError("--passphrase-env and --passphrase-stdin are mutually exclusive.")
615
631
  return parsed
616
632
 
617
633
 
@@ -79,6 +79,7 @@ from .context_store import (
79
79
  write_context,
80
80
  )
81
81
  from .errors import CdxError
82
+ from .fs_utils import atomic_write
82
83
  from .health import collect_health_report, format_health_report
83
84
  from .notify import (
84
85
  format_notify_event,
@@ -107,15 +108,22 @@ from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_l
107
108
  from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
108
109
 
109
110
 
110
- def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
111
+ def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False, use_stdin=False):
111
112
  env = ctx.get("env", {})
112
113
  if env_var:
113
114
  passphrase = env.get(env_var)
114
115
  if not passphrase:
115
116
  raise CdxError(f"Environment variable {env_var} is empty or unset.")
116
117
  return passphrase
118
+ if use_stdin:
119
+ reader = ctx["options"].get("read_stdin")
120
+ raw = reader() if callable(reader) else sys.stdin.readline()
121
+ passphrase = (raw or "").rstrip("\r\n")
122
+ if not passphrase:
123
+ raise CdxError("Bundle passphrase from stdin is empty.")
124
+ return passphrase
117
125
  if not ctx["stdin_is_tty"]:
118
- raise CdxError("Encrypted bundle export/import requires an interactive terminal or --passphrase-env.")
126
+ raise CdxError("Encrypted bundle export/import requires an interactive terminal, --passphrase-env, or --passphrase-stdin.")
119
127
  getpass_fn = ctx["options"].get("getpass") or getpass.getpass
120
128
  passphrase = getpass_fn(prompt)
121
129
  if not passphrase:
@@ -163,20 +171,19 @@ def _extract_claude_oauth_token(text):
163
171
 
164
172
  def _write_claude_oauth_token(auth_home, token):
165
173
  cred_dir = os.path.join(auth_home, "credentials")
166
- os.makedirs(cred_dir, exist_ok=True)
174
+ os.makedirs(cred_dir, mode=0o700, exist_ok=True)
175
+ try:
176
+ os.chmod(cred_dir, 0o700)
177
+ except OSError:
178
+ pass
167
179
  cred_path = os.path.join(cred_dir, "default.json")
168
180
  payload = {
169
181
  "version": "1.0",
170
182
  "type": "oauth_token",
171
183
  "access_token": token,
172
184
  }
173
- with open(cred_path, "w", encoding="utf-8") as handle:
174
- json.dump(payload, handle, indent=2)
175
- handle.write("\n")
176
- try:
177
- os.chmod(cred_path, 0o600)
178
- except OSError:
179
- pass
185
+ # atomic_write keeps the token 0o600 from creation — no umask window.
186
+ atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
180
187
  return cred_path
181
188
 
182
189
 
@@ -197,18 +204,22 @@ def _bootstrap_claude_setup_token(session, ctx):
197
204
  "Claude setup-token completed, but cdx could not capture the token. "
198
205
  "Run claude setup-token and save the token under credentials/default.json."
199
206
  )
200
- with open(transcript_path, encoding="utf-8", errors="replace") as handle:
201
- transcript = handle.read()
207
+ try:
208
+ with open(transcript_path, encoding="utf-8", errors="replace") as handle:
209
+ transcript = handle.read()
210
+ finally:
211
+ # The transcript holds the ~1-year OAuth token in cleartext; it must
212
+ # not outlive the extraction, whether it succeeds or not.
213
+ try:
214
+ os.remove(transcript_path)
215
+ except OSError:
216
+ pass
202
217
  token = _extract_claude_oauth_token(transcript)
203
218
  if not token:
204
219
  raise CdxError(
205
220
  "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
206
- f"Transcript kept at: {transcript_path}"
221
+ "Run claude setup-token manually and save the token under credentials/default.json."
207
222
  )
208
- try:
209
- os.remove(transcript_path)
210
- except OSError:
211
- pass
212
223
  auth_home = session.get("authHome") or ""
213
224
  cred_path = _write_claude_oauth_token(auth_home, token)
214
225
  # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
@@ -812,11 +823,12 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
812
823
  session for session in ctx["service"]["list_sessions"]()
813
824
  if session.get("provider") == provider and session.get("enabled", True) is not False
814
825
  ]
815
- minimum = _reasoning_rank(min_reasoning_effort)
816
- sessions = [
817
- session for session in sessions
818
- if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
819
- ]
826
+ if min_reasoning_effort:
827
+ minimum = _reasoning_rank(min_reasoning_effort)
828
+ sessions = [
829
+ session for session in sessions
830
+ if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
831
+ ]
820
832
  rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
821
833
  row_by_name = {row.get("session_name"): row for row in rows}
822
834
  candidates = []
@@ -1757,6 +1769,7 @@ def handle_export(rest, ctx):
1757
1769
  parsed["passphrase_env"],
1758
1770
  "Bundle passphrase: ",
1759
1771
  confirm=True,
1772
+ use_stdin=parsed["passphrase_stdin"],
1760
1773
  )
1761
1774
  result = ctx["service"]["export_bundle"](
1762
1775
  parsed["file_path"],
@@ -1796,6 +1809,7 @@ def handle_import(rest, ctx):
1796
1809
  parsed["passphrase_env"],
1797
1810
  "Bundle passphrase: ",
1798
1811
  confirm=False,
1812
+ use_stdin=parsed["passphrase_stdin"],
1799
1813
  )
1800
1814
  result = ctx["service"]["import_bundle"](
1801
1815
  parsed["file_path"],
@@ -1962,7 +1976,7 @@ def handle_update(rest, ctx):
1962
1976
  base_prefix=ctx["options"].get("basePrefix"),
1963
1977
  )
1964
1978
  results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
1965
- failed = any((result.get("returncode") not in (0, None)) for result in results)
1979
+ failed = any((result.get("returncode") != 0) for result in results)
1966
1980
  if failed:
1967
1981
  raise CdxError(format_update_failure(results))
1968
1982
 
package/src/cli_render.py CHANGED
@@ -10,7 +10,7 @@ def _format_relative_age(iso_value):
10
10
  if not iso_value:
11
11
  return "-"
12
12
  try:
13
- ts = datetime.fromisoformat(iso_value.replace("Z", "+00:00"))
13
+ ts = datetime.fromisoformat(str(iso_value).replace("Z", "+00:00"))
14
14
  delta_s = (datetime.now(timezone.utc) - ts).total_seconds()
15
15
  except (ValueError, TypeError):
16
16
  return "-"
@@ -6,6 +6,8 @@ import subprocess
6
6
  import threading
7
7
  from datetime import datetime, timezone
8
8
 
9
+ from .status_source import _is_zero_credit_balance
10
+
9
11
  try:
10
12
  import fcntl
11
13
  except ImportError: # pragma: no cover - non-POSIX platforms
@@ -92,7 +94,7 @@ def normalize_codex_rate_limit_snapshot(snapshot):
92
94
  credit_balance = None
93
95
  if isinstance(credits, dict):
94
96
  credit_balance = credits.get("balance")
95
- if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
97
+ if not credits.get("hasCredits") and not credits.get("unlimited") and _is_zero_credit_balance(credit_balance):
96
98
  credit_balance = None
97
99
  elif credits is not None:
98
100
  credit_balance = credits
@@ -5,6 +5,7 @@ import subprocess
5
5
  from datetime import datetime
6
6
 
7
7
  from .errors import CdxError
8
+ from .fs_utils import atomic_write
8
9
  from .session_store import _ensure_dir
9
10
 
10
11
  DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
@@ -57,8 +58,7 @@ def read_context(base_dir, cwd=None):
57
58
  def write_context(base_dir, content, cwd=None):
58
59
  path = get_context_path(base_dir, cwd)
59
60
  _ensure_dir(os.path.dirname(path))
60
- with open(path, "w", encoding="utf-8") as handle:
61
- handle.write(content.rstrip() + "\n")
61
+ atomic_write(path, content.rstrip() + "\n", mode=0o644)
62
62
  return {
63
63
  "path": path,
64
64
  "updated_at": _local_now_iso(),
@@ -117,8 +117,7 @@ def install_context_for_session(base_dir, session, cwd=None):
117
117
  raise CdxError(f"Session auth home missing for {session['name']}")
118
118
  target_path = os.path.join(auth_home, "shared-context.md")
119
119
  _ensure_dir(os.path.dirname(target_path))
120
- with open(target_path, "w", encoding="utf-8") as handle:
121
- handle.write(content.rstrip() + "\n")
120
+ atomic_write(target_path, content.rstrip() + "\n", mode=0o644)
122
121
  return {
123
122
  "source_path": get_context_path(base_dir, cwd),
124
123
  "target_path": target_path,
package/src/fs_utils.py CHANGED
@@ -2,6 +2,38 @@ import inspect
2
2
  import os
3
3
  import shutil
4
4
  import stat
5
+ import tempfile
6
+
7
+
8
+ def atomic_write(path, data, mode=None):
9
+ """Write str/bytes via temp file + rename so a crash can't leave a torn file.
10
+
11
+ mkstemp creates the temp file 0o600, so the content is never readable by
12
+ other users mid-write; pass mode to set the final permissions before the
13
+ rename makes the file visible.
14
+ """
15
+ directory = os.path.dirname(os.path.abspath(path))
16
+ os.makedirs(directory, exist_ok=True)
17
+ binary = isinstance(data, (bytes, bytearray))
18
+ fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory)
19
+ try:
20
+ if binary:
21
+ handle = os.fdopen(fd, "wb")
22
+ else:
23
+ handle = os.fdopen(fd, "w", encoding="utf-8")
24
+ with handle:
25
+ handle.write(data)
26
+ handle.flush()
27
+ os.fsync(handle.fileno())
28
+ if mode is not None and os.name != "nt":
29
+ os.chmod(temp_path, mode)
30
+ os.replace(temp_path, path)
31
+ except Exception:
32
+ try:
33
+ os.unlink(temp_path)
34
+ except FileNotFoundError:
35
+ pass
36
+ raise
5
37
 
6
38
 
7
39
  def _make_user_writable(path):