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 +4 -4
- package/bin/python-runner.js +4 -7
- package/changelogs/CHANGELOGS_0_9_14.md +27 -0
- package/changelogs/CHANGELOGS_0_9_15.md +39 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +2 -0
- package/src/claude_refresh.py +9 -2
- package/src/claude_usage.py +36 -5
- package/src/cli.py +7 -6
- package/src/cli_args.py +23 -7
- package/src/cli_commands.py +37 -23
- package/src/cli_render.py +1 -1
- package/src/codex_usage.py +3 -1
- package/src/context_store.py +3 -4
- package/src/fs_utils.py +32 -0
- package/src/provider_runtime.py +45 -13
- package/src/run_registry.py +47 -14
- package/src/run_usage.py +3 -1
- package/src/session_service.py +137 -100
- package/src/session_store.py +11 -0
- package/src/status_source.py +101 -25
- package/src/status_view.py +19 -6
- package/src/update_check.py +4 -0
- package/src/update_manager.py +3 -1
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
|
|
|
@@ -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.
|
package/bin/python-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
package/pyproject.toml
CHANGED
package/src/backup_bundle.py
CHANGED
|
@@ -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
|
package/src/claude_refresh.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
98
|
+
return {"refreshed": sorted(recorded), "errors": errors}
|
package/src/claude_usage.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
188
|
-
utilization_7d =
|
|
218
|
+
utilization_5h = _safe_float(util_5h)
|
|
219
|
+
utilization_7d = _safe_float(util_7d)
|
|
189
220
|
|
|
190
|
-
reset_5h_at = _format_reset_date(
|
|
191
|
-
reset_week_at = _format_reset_date(
|
|
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.
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
593
|
-
raise CdxError("--passphrase-env
|
|
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
|
|
package/src/cli_commands.py
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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")
|
|
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 "-"
|
package/src/codex_usage.py
CHANGED
|
@@ -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
|
|
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
|
package/src/context_store.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|