cdx-manager 0.7.1 → 0.7.2

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.7.1-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.7.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -134,7 +134,7 @@ For a specific version:
134
134
 
135
135
  ```bash
136
136
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
137
- CDX_VERSION=v0.7.1 sh install.sh
137
+ CDX_VERSION=v0.7.2 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -264,6 +264,7 @@ cdx set personal --power low --permission review
264
264
  cdx set --sessions all --permission auto
265
265
  cdx set --provider ollama --model llama3.2
266
266
  cdx set work --priority 80
267
+ cdx set work --rtk on
267
268
  cdx power all low
268
269
  cdx perm provider:claude review
269
270
  cdx model provider:ollama llama3.2
@@ -277,12 +278,13 @@ cdx unset work --power
277
278
  cdx unset --sessions work,personal --fast
278
279
  cdx unset --provider claude --permission
279
280
  cdx unset work --priority
281
+ cdx unset work --rtk
280
282
  cdx unset work --all
281
283
  cdx power all default
282
284
  cdx model provider:ollama default
283
285
  ```
284
286
 
285
- `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability.
287
+ `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability. `--rtk on` injects a launch instruction that encourages assistants to use RTK (`rtk <command>`) for noisy terminal commands when RTK is available, while keeping raw commands for exact output.
286
288
 
287
289
  ### Launch History
288
290
 
@@ -319,8 +321,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
319
321
  | `cdx enable <name> [--json]` | Re-enable a disabled session |
320
322
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
321
323
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
322
- | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--model MODEL] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
323
- | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--model\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
324
+ | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--model MODEL] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
325
+ | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--rtk\|--model\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
324
326
  | `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 |
325
327
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
326
328
  | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
@@ -338,6 +340,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
338
340
  | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
339
341
  | `cdx select --provider PROVIDER [--min-reasoning-effort low\|medium\|high] [--min-power low\|medium\|high] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
340
342
  | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--reasoning-effort low\|medium\|high] [--power low\|medium\|high] [--permission MODE] [--timeout-seconds N] --json` | Run one headless task and return a stable JSON result |
343
+ | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known headless token usage by session |
341
344
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
342
345
  | `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
343
346
  | `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
@@ -369,6 +372,7 @@ Commands with machine-readable output:
369
372
  - `cdx context ... --json`
370
373
  - `cdx handoff ... --json`
371
374
  - `cdx history ... --json`
375
+ - `cdx stats ... --json`
372
376
  - `cdx last --json`
373
377
  - `cdx doctor --json`
374
378
  - `cdx repair --json`
@@ -439,6 +443,13 @@ cdx run \
439
443
 
440
444
  The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
441
445
 
446
+ Known headless token usage is persisted in launch history and can be aggregated later:
447
+
448
+ ```bash
449
+ cdx stats --since 7d --json
450
+ cdx stats work
451
+ ```
452
+
442
453
  `cdx select` exposes the same session selection logic directly:
443
454
 
444
455
  ```bash
@@ -0,0 +1,41 @@
1
+ # Changelog (`0.7.1 -> 0.7.2`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Update Reliability
6
+
7
+ - Fixed Unix standalone update detection for installs under `~/.local/share/cdx-manager/<version>` so `cdx update` uses the standalone installer instead of incorrectly upgrading npm.
8
+ - Added a post-update PATH verification check that runs the resolved `cdx -v` and warns when another older executable shadows the updated install.
9
+ - Reduced the environment passed to the post-update version check so sensitive variables are not forwarded to the verification subprocess.
10
+ - Aligned the PowerShell standalone installer with the Windows launcher by accepting `py`, `python`, or `python3`.
11
+
12
+ ## Headless Usage and Stats
13
+
14
+ - Persisted normalized token usage from `cdx run --json` into launch history for future aggregation.
15
+ - Added `cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]` to aggregate launches, successes, failures, duration, known usage runs, and token totals by session.
16
+ - Continued extracting provider stdout usage even when a headless provider exits with an error, preserving useful usage data from structured failure payloads.
17
+
18
+ ## RTK Preference
19
+
20
+ - Added `cdx set <session> --rtk on|off` and `cdx unset <session> --rtk` to store a per-session preference that encourages assistants to use RTK for noisy terminal commands.
21
+ - Injected the RTK preference into interactive and headless launch prompts without rewriting commands automatically.
22
+ - Added `cdx doctor` detection for the `rtk` CLI.
23
+
24
+ ## Coverage and Tests
25
+
26
+ - Added focused coverage for update checking, context storage, RTK launch preferences, post-update shadow warnings, and per-session stats aggregation.
27
+ - Fixed Windows CI portability in tests that exercise npm shim paths, version mismatch warnings, and context write byte counts.
28
+ - Increased the Python test suite to 260 tests.
29
+
30
+ ## Release Metadata
31
+
32
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.2`.
33
+
34
+ ## Validation and Regression Evidence
35
+
36
+ - `npm run lint`
37
+ - `npm test`
38
+ - `npm pack --dry-run`
39
+ - `node bin/cdx.js -v`
40
+ - `python3 -m build`
41
+ - `python3 -m twine check dist/*`
@@ -32,6 +32,10 @@
32
32
  "v0.7.0": {
33
33
  "github_tarball_sha256": "c80231884bf20c9ae74144a1ec16e0685c2fdac67d7d93a3c6219c5ac6fc14dd",
34
34
  "github_zip_sha256": "0e6af29e59d3a93a07e07656d6c0c93803a674e65830ef33fa2322649b7139c2"
35
+ },
36
+ "v0.7.1": {
37
+ "github_tarball_sha256": "276cf2f094405bef674290ff8d1f2401f99afc10981c97efe4fe8293801715c8",
38
+ "github_zip_sha256": "5d5cdefec3ebcd61d4149b71895f4d5b79e604cf874b2d567910e578a8164670"
35
39
  }
36
40
  }
37
41
  }
package/install.ps1 CHANGED
@@ -12,14 +12,14 @@ if (-not $ChecksumsUrl) {
12
12
  $ChecksumsUrl = "https://raw.githubusercontent.com/$repo/main/checksums/release-archives.json"
13
13
  }
14
14
 
15
- function Require-Command {
15
+ function Has-Command {
16
16
  param([string]$Name)
17
- if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
18
- throw "cdx install: missing required command: $Name"
19
- }
17
+ return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
20
18
  }
21
19
 
22
- Require-Command python
20
+ if (-not (Has-Command py) -and -not (Has-Command python) -and -not (Has-Command python3)) {
21
+ throw "cdx install: missing required command: py, python, or python3"
22
+ }
23
23
 
24
24
  if (-not $Prefix) {
25
25
  $Prefix = Join-Path $env:LOCALAPPDATA "cdx-manager"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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.7.1"
7
+ version = "0.7.2"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -30,6 +30,7 @@ from .cli_commands import (
30
30
  handle_rename,
31
31
  handle_run,
32
32
  handle_select,
33
+ handle_stats,
33
34
  handle_status,
34
35
  handle_set,
35
36
  handle_unset,
@@ -57,7 +58,7 @@ from .status_view import (
57
58
  )
58
59
  from .update_check import check_for_update
59
60
 
60
- VERSION = "0.7.1"
61
+ VERSION = "0.7.2"
61
62
 
62
63
 
63
64
  # ---------------------------------------------------------------------------
@@ -79,9 +80,10 @@ def _print_help(use_color=False):
79
80
  f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
80
81
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
81
82
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
82
- f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
83
- f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--priority|--all) [--json]', '36', use_color)}",
83
+ f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
84
+ f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--model|--priority|--all) [--json]', '36', use_color)}",
84
85
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
86
+ f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
85
87
  f" {_style('cdx last [--json]', '36', use_color)}",
86
88
  f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
87
89
  f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
@@ -238,7 +240,7 @@ def main(argv, options=None):
238
240
  "version": VERSION,
239
241
  "cwd": options.get("cwd") or os.getcwd(),
240
242
  "update_notice": _get_update_notice(service, env, options) if command not in (
241
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "power", "perm", "fast", "model", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "select", "run", "help", "version"
243
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "power", "perm", "fast", "model", "history", "stats", "handoff", "login", "logout", "disable", "enable", "export", "import", "select", "run", "help", "version"
242
244
  ) else None,
243
245
  "use_color": use_color,
244
246
  }
@@ -305,6 +307,9 @@ def main(argv, options=None):
305
307
  if command == "history":
306
308
  return handle_history(rest, ctx)
307
309
 
310
+ if command == "stats":
311
+ return handle_stats(rest, ctx)
312
+
308
313
  if command == "last":
309
314
  return handle_last(rest, ctx)
310
315
 
@@ -43,7 +43,7 @@ from .backup_bundle import read_bundle_meta
43
43
  from .run_usage import empty_usage, extract_run_usage
44
44
  from .status_view import _format_status_detail, _format_status_rows
45
45
  from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
46
- from .update_manager import build_update_plan, format_update_failure, run_update_plan
46
+ from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
47
47
 
48
48
 
49
49
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
@@ -54,11 +54,12 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
54
54
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
55
55
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
56
56
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
57
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--priority 0..100] [--json]"
58
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--priority|--all) [--json]"
57
+ SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--model MODEL] [--priority 0..100] [--json]"
58
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--model|--priority|--all) [--json]"
59
59
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
60
60
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
61
61
  HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
62
+ STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
62
63
  LAST_USAGE = "Usage: cdx last [--json]"
63
64
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json"
64
65
  RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--reasoning-effort low|medium|high] [--power low|medium|high] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
@@ -504,6 +505,7 @@ def _parse_set_args(args):
504
505
  "--power": {"key": "power", "type": "str", "default": None},
505
506
  "--permission": {"key": "permission", "type": "str", "default": None},
506
507
  "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
508
+ "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
507
509
  "--model": {"key": "model", "type": "str", "default": None},
508
510
  "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
509
511
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
@@ -520,7 +522,7 @@ def _parse_set_args(args):
520
522
  raise CdxError(SET_USAGE)
521
523
  settings = {
522
524
  key: parsed[key]
523
- for key in ("power", "permission", "fast", "model", "priority")
525
+ for key in ("power", "permission", "fast", "rtk", "model", "priority")
524
526
  if parsed[key] is not None
525
527
  }
526
528
  if not settings:
@@ -539,6 +541,7 @@ def _parse_unset_args(args):
539
541
  "--power": {"key": "power", "type": "bool", "default": False},
540
542
  "--permission": {"key": "permission", "type": "bool", "default": False},
541
543
  "--fast": {"key": "fast", "type": "bool", "default": False},
544
+ "--rtk": {"key": "rtk", "type": "bool", "default": False},
542
545
  "--model": {"key": "model", "type": "bool", "default": False},
543
546
  "--priority": {"key": "priority", "type": "bool", "default": False},
544
547
  "--all": {"key": "all", "type": "bool", "default": False},
@@ -554,8 +557,8 @@ def _parse_unset_args(args):
554
557
  raise CdxError(UNSET_USAGE)
555
558
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
556
559
  raise CdxError(UNSET_USAGE)
557
- keys = ["power", "permission", "fast", "model", "priority"] if parsed["all"] else [
558
- key for key in ("power", "permission", "fast", "model", "priority") if parsed[key]
560
+ keys = ["power", "permission", "fast", "rtk", "model", "priority"] if parsed["all"] else [
561
+ key for key in ("power", "permission", "fast", "rtk", "model", "priority") if parsed[key]
559
562
  ]
560
563
  if not keys:
561
564
  raise CdxError(UNSET_USAGE)
@@ -818,6 +821,22 @@ def _parse_history_args(args, now=None):
818
821
  }
819
822
 
820
823
 
824
+ def _parse_stats_args(args, now=None):
825
+ now = now or datetime.now().astimezone()
826
+ parsed = _parse_flag_args(args, {
827
+ "--since": {"key": "since", "type": "str", "default": None},
828
+ "--from": {"key": "from", "type": "str", "default": None},
829
+ "--to": {"key": "to", "type": "str", "default": None},
830
+ "--json": {"key": "json", "type": "bool", "default": False},
831
+ }, STATS_USAGE, positionals_key="names", max_positionals=1)
832
+ period = _parse_history_period(parsed, now)
833
+ return {
834
+ "name": parsed["names"][0] if parsed["names"] else None,
835
+ "period": period,
836
+ "json": parsed["json"],
837
+ }
838
+
839
+
821
840
  def _read_option_value(args, index, usage):
822
841
  if index + 1 >= len(args):
823
842
  raise CdxError(usage)
@@ -1225,9 +1244,9 @@ def _run_cdx_error_code(error):
1225
1244
 
1226
1245
  def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
1227
1246
  run_info = run_info or {}
1228
- usage = (
1247
+ usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
1229
1248
  extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
1230
- if ok and session else
1249
+ if session else
1231
1250
  empty_usage()
1232
1251
  )
1233
1252
  return {
@@ -1324,6 +1343,8 @@ def handle_run(rest, ctx):
1324
1343
  timeout_seconds=parsed.get("timeout_seconds"),
1325
1344
  spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1326
1345
  )
1346
+ usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
1347
+ run_info = {**run_info, "usage": usage}
1327
1348
  ok = run_info.get("returncode") == 0
1328
1349
  if ok:
1329
1350
  ctx["service"]["record_launch_history"](session["name"], {
@@ -1374,6 +1395,7 @@ def _format_launch_config(session):
1374
1395
  f"power: {launch.get('power') or 'default'}",
1375
1396
  f"permission: {launch.get('permission') or 'default'}",
1376
1397
  f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
1398
+ f"rtk: {'on' if launch.get('rtk') is True else 'off' if launch.get('rtk') is False else 'default'}",
1377
1399
  f"model: {launch.get('model') or 'default'}",
1378
1400
  f"priority: {launch.get('priority') if launch.get('priority') is not None else 'default'}",
1379
1401
  ])
@@ -1543,6 +1565,79 @@ def _summarize_history(entries):
1543
1565
  )
1544
1566
 
1545
1567
 
1568
+ def _token_value(usage, key):
1569
+ if not isinstance(usage, dict):
1570
+ return None
1571
+ value = usage.get(key)
1572
+ try:
1573
+ parsed = int(value)
1574
+ except (TypeError, ValueError):
1575
+ return None
1576
+ return parsed if parsed >= 0 else None
1577
+
1578
+
1579
+ def _summarize_stats(entries):
1580
+ rows = {}
1581
+ for entry in entries:
1582
+ name = entry.get("session_name") or "-"
1583
+ row = rows.setdefault(name, {
1584
+ "session_name": name,
1585
+ "provider": entry.get("provider") or "-",
1586
+ "launches": 0,
1587
+ "successes": 0,
1588
+ "failures": 0,
1589
+ "duration_ms": 0,
1590
+ "usage_runs": 0,
1591
+ "input_tokens": 0,
1592
+ "output_tokens": 0,
1593
+ "reasoning_tokens": 0,
1594
+ "total_tokens": 0,
1595
+ "last_started_at": None,
1596
+ })
1597
+ row["launches"] += 1
1598
+ if entry.get("status") == "success":
1599
+ row["successes"] += 1
1600
+ elif entry.get("status") == "failed":
1601
+ row["failures"] += 1
1602
+ try:
1603
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
1604
+ except (TypeError, ValueError):
1605
+ pass
1606
+ usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
1607
+ parsed_usage = {
1608
+ key: _token_value(usage, key)
1609
+ for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
1610
+ }
1611
+ if any(value is not None for value in parsed_usage.values()):
1612
+ row["usage_runs"] += 1
1613
+ for key, value in parsed_usage.items():
1614
+ row[key] += value or 0
1615
+ started = entry.get("started_at")
1616
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
1617
+ row["last_started_at"] = started
1618
+ row["provider"] = entry.get("provider") or row["provider"]
1619
+ return sorted(
1620
+ rows.values(),
1621
+ key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
1622
+ reverse=True,
1623
+ )
1624
+
1625
+
1626
+ def _stats_totals(rows):
1627
+ return {
1628
+ "sessions": len(rows),
1629
+ "launches": sum(row["launches"] for row in rows),
1630
+ "successes": sum(row["successes"] for row in rows),
1631
+ "failures": sum(row["failures"] for row in rows),
1632
+ "duration_ms": sum(row["duration_ms"] for row in rows),
1633
+ "usage_runs": sum(row["usage_runs"] for row in rows),
1634
+ "input_tokens": sum(row["input_tokens"] for row in rows),
1635
+ "output_tokens": sum(row["output_tokens"] for row in rows),
1636
+ "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
1637
+ "total_tokens": sum(row["total_tokens"] for row in rows),
1638
+ }
1639
+
1640
+
1546
1641
  def _format_history_period(period):
1547
1642
  if not _has_history_period(period or {}):
1548
1643
  return None
@@ -1610,6 +1705,55 @@ def _format_history(entries, use_color=False):
1610
1705
  ])
1611
1706
 
1612
1707
 
1708
+ def _format_token_count(value):
1709
+ try:
1710
+ amount = int(value)
1711
+ except (TypeError, ValueError):
1712
+ return "-"
1713
+ if amount >= 1000000:
1714
+ return f"{amount / 1000000:.1f}M"
1715
+ if amount >= 1000:
1716
+ return f"{amount / 1000:.1f}K"
1717
+ return str(amount)
1718
+
1719
+
1720
+ def _format_stats(rows, totals, period=None, use_color=False):
1721
+ from .cli_render import _format_relative_age, _pad_table
1722
+
1723
+ if not rows:
1724
+ return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
1725
+ table = [["SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"]]
1726
+ for row in rows:
1727
+ table.append([
1728
+ row["session_name"],
1729
+ row["provider"],
1730
+ str(row["launches"]),
1731
+ str(row["usage_runs"]),
1732
+ _format_token_count(row["input_tokens"]),
1733
+ _format_token_count(row["output_tokens"]),
1734
+ _format_token_count(row["reasoning_tokens"]),
1735
+ _format_token_count(row["total_tokens"]),
1736
+ _format_duration_ms(row["duration_ms"]),
1737
+ _format_relative_age(row.get("last_started_at")),
1738
+ ])
1739
+ lines = ["Assistant stats:"]
1740
+ period_line = _format_history_period(period or {})
1741
+ if period_line:
1742
+ lines.extend([period_line, ""])
1743
+ lines.append(_pad_table(table))
1744
+ lines.extend([
1745
+ "",
1746
+ _dim(
1747
+ "Totals: "
1748
+ f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
1749
+ f"{_format_token_count(totals['total_tokens'])} tokens, "
1750
+ f"{_format_duration_ms(totals['duration_ms'])}.",
1751
+ use_color,
1752
+ ),
1753
+ ])
1754
+ return "\n".join(lines)
1755
+
1756
+
1613
1757
  def _apply_launch_settings(parsed, ctx, action="set"):
1614
1758
  targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
1615
1759
  sessions = [
@@ -1722,6 +1866,32 @@ def handle_history(rest, ctx):
1722
1866
  return 0
1723
1867
 
1724
1868
 
1869
+ def handle_stats(rest, ctx):
1870
+ now_fn = ctx["options"].get("now") or time.time
1871
+ now = datetime.fromtimestamp(now_fn()).astimezone()
1872
+ parsed = _parse_stats_args(rest, now=now)
1873
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
1874
+ entries = _filter_history_period(entries, parsed["period"])
1875
+ rows = _summarize_stats(entries)
1876
+ totals = _stats_totals(rows)
1877
+ message = (
1878
+ f"Calculated stats for {parsed['name']}"
1879
+ if parsed["name"]
1880
+ else "Calculated stats"
1881
+ )
1882
+ if parsed["json"]:
1883
+ _write_json(ctx, _json_success(
1884
+ "stats",
1885
+ message,
1886
+ period=_public_history_period(parsed["period"]),
1887
+ stats=rows,
1888
+ totals=totals,
1889
+ ))
1890
+ return 0
1891
+ ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'])}\n")
1892
+ return 0
1893
+
1894
+
1725
1895
  def _resolve_last_launch_session(ctx):
1726
1896
  for entry in ctx["service"]["get_launch_history"](limit=0):
1727
1897
  name = entry.get("session_name")
@@ -2352,11 +2522,21 @@ def handle_update(rest, ctx):
2352
2522
  if failed:
2353
2523
  raise CdxError(format_update_failure(results))
2354
2524
 
2525
+ warnings = []
2526
+ version_warning = verify_updated_command(
2527
+ target_version,
2528
+ runner=ctx["options"].get("runVersionCheck"),
2529
+ env=ctx.get("env"),
2530
+ )
2531
+ if version_warning:
2532
+ warnings.append(version_warning)
2533
+
2355
2534
  message = f"Updated cdx-manager to {target_version}"
2356
2535
  if json_flag:
2357
2536
  _write_json(ctx, _json_success(
2358
2537
  "update",
2359
2538
  message,
2539
+ warnings=warnings,
2360
2540
  updated=True,
2361
2541
  current_version=current_version,
2362
2542
  target_version=target_version,
@@ -2365,6 +2545,8 @@ def handle_update(rest, ctx):
2365
2545
  ))
2366
2546
  return 0
2367
2547
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2548
+ for warning in warnings:
2549
+ ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
2368
2550
  return 0
2369
2551
 
2370
2552
 
package/src/health.py CHANGED
@@ -39,6 +39,14 @@ def collect_health_report(service, base_dir, env=None):
39
39
  status = "OK" if path else "WARN"
40
40
  issues.append(_issue(status, f"{command}_cli", f"{command} CLI {'found' if path else 'not found'}", path))
41
41
 
42
+ rtk_path = shutil.which("rtk", path=env.get("PATH"))
43
+ issues.append(_issue(
44
+ "OK" if rtk_path else "WARN",
45
+ "rtk_cli",
46
+ "RTK CLI found" if rtk_path else "RTK CLI not found; assistants can still run normally but may use more context on noisy commands",
47
+ rtk_path,
48
+ ))
49
+
42
50
  script_bin = env.get("CDX_SCRIPT_BIN", "script")
43
51
  script_path = shutil.which(script_bin, path=env.get("PATH"))
44
52
  issues.append(_issue(
@@ -15,6 +15,10 @@ from .errors import CdxError
15
15
 
16
16
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
17
17
  REASONING_EFFORT_VALUES = {"low", "medium", "high"}
18
+ RTK_PROMPT = (
19
+ "When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
20
+ "Use raw commands when exact, unfiltered output is required."
21
+ )
18
22
  LAUNCH_PERMISSION_ARGS = {
19
23
  PROVIDER_CLAUDE: {
20
24
  "review": ["--permission-mode", "plan"],
@@ -284,7 +288,20 @@ def _default_script_args(transcript_path, spec):
284
288
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
285
289
 
286
290
 
291
+ def _rtk_enabled(session):
292
+ return (session.get("launch") or {}).get("rtk") is True
293
+
294
+
295
+ def _with_launch_preferences(session, initial_prompt=None):
296
+ if not _rtk_enabled(session):
297
+ return initial_prompt
298
+ if initial_prompt:
299
+ return f"{RTK_PROMPT}\n\n{initial_prompt}"
300
+ return RTK_PROMPT
301
+
302
+
287
303
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
304
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
288
305
  _validate_initial_prompt(initial_prompt)
289
306
  cwd = cwd or os.getcwd()
290
307
  env_override = env_override or {}
@@ -358,6 +375,7 @@ def _validate_initial_prompt(initial_prompt):
358
375
 
359
376
 
360
377
  def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
378
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
361
379
  _validate_initial_prompt(initial_prompt)
362
380
  cwd = cwd or os.getcwd()
363
381
  env = {**os.environ, **(env_override or {})}
@@ -123,6 +123,18 @@ def _normalize_launch_settings(settings):
123
123
  normalized["fast"] = False
124
124
  else:
125
125
  raise CdxError(f"Unsupported fast value: {settings['fast']}")
126
+ if "rtk" in settings and settings["rtk"] is not None:
127
+ value = settings["rtk"]
128
+ if isinstance(value, bool):
129
+ normalized["rtk"] = value
130
+ else:
131
+ text = str(value).strip().lower()
132
+ if text in ("on", "true", "1", "yes"):
133
+ normalized["rtk"] = True
134
+ elif text in ("off", "false", "0", "no"):
135
+ normalized["rtk"] = False
136
+ else:
137
+ raise CdxError(f"Unsupported rtk value: {settings['rtk']}")
126
138
  if "model" in settings and settings["model"] is not None:
127
139
  model = str(settings["model"]).strip()
128
140
  if not model:
@@ -800,7 +812,7 @@ def create_session_service(options=None):
800
812
  raise CdxError(f"Unknown session: {name}")
801
813
  if not keys:
802
814
  raise CdxError("At least one launch setting is required.")
803
- allowed = {"power", "permission", "fast", "model", "priority"}
815
+ allowed = {"power", "permission", "fast", "rtk", "model", "priority"}
804
816
  unknown = [key for key in keys if key not in allowed]
805
817
  if unknown:
806
818
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -1,4 +1,6 @@
1
1
  import os
2
+ import re
3
+ import shutil
2
4
  import subprocess
3
5
  import sys
4
6
  from pathlib import Path
@@ -22,7 +24,14 @@ def _normalize_version(value):
22
24
 
23
25
 
24
26
  def _is_standalone_install(package_root):
25
- return package_root.parent.name == "versions"
27
+ if package_root.parent.name == "versions":
28
+ return True
29
+ return (
30
+ package_root.parent.name == "cdx-manager"
31
+ and re.match(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$", package_root.name) is not None
32
+ and (package_root / "install.sh").exists()
33
+ and (package_root / "bin" / "cdx").exists()
34
+ )
26
35
 
27
36
 
28
37
  def _is_source_checkout(package_root):
@@ -200,6 +209,76 @@ def run_update_plan(plan, runner=None, env=None):
200
209
  return results
201
210
 
202
211
 
212
+ def _version_check_env(env):
213
+ source = env or os.environ
214
+ allowed = {
215
+ key: source[key]
216
+ for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR")
217
+ if source.get(key)
218
+ }
219
+ return allowed
220
+
221
+
222
+ def verify_updated_command(target_version, runner=None, env=None):
223
+ target = _normalize_version(target_version)
224
+ if not target:
225
+ return None
226
+ env = env or os.environ
227
+ executable = shutil.which("cdx", path=env.get("PATH"))
228
+ if not executable:
229
+ return {
230
+ "code": "update_version_check_failed",
231
+ "message": "Updated cdx-manager, but no cdx executable was found on PATH.",
232
+ "target_version": target,
233
+ "resolved_version": None,
234
+ "path": None,
235
+ }
236
+ runner = runner or subprocess.run
237
+ try:
238
+ result = runner(
239
+ [executable, "-v"],
240
+ cwd=None,
241
+ env=_version_check_env(env),
242
+ check=False,
243
+ capture_output=True,
244
+ text=True,
245
+ )
246
+ except OSError as error:
247
+ return {
248
+ "code": "update_version_check_failed",
249
+ "message": f"Updated cdx-manager, but failed to verify {executable}: {error}",
250
+ "target_version": target,
251
+ "resolved_version": None,
252
+ "path": executable,
253
+ }
254
+
255
+ version = str(_result_text(result, "stdout") or "").strip().splitlines()
256
+ version = _normalize_version(version[0]) if version else None
257
+ code = _result_code(result)
258
+ if code not in (0, None) or not version:
259
+ message = str(_result_text(result, "stderr") or _result_text(result, "stdout") or "").strip()
260
+ suffix = f": {message}" if message else "."
261
+ return {
262
+ "code": "update_version_check_failed",
263
+ "message": f"Updated cdx-manager, but failed to verify {executable}{suffix}",
264
+ "target_version": target,
265
+ "resolved_version": version,
266
+ "path": executable,
267
+ }
268
+ if version != target:
269
+ return {
270
+ "code": "update_version_mismatch",
271
+ "message": (
272
+ f"Updated cdx-manager to {target}, but PATH resolves {executable} "
273
+ f"which reports {version}."
274
+ ),
275
+ "target_version": target,
276
+ "resolved_version": version,
277
+ "path": executable,
278
+ }
279
+ return None
280
+
281
+
203
282
  def format_update_failure(results):
204
283
  if not results:
205
284
  return "Update failed."