cdx-manager 0.12.0 → 0.12.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.12.0-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.12.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
 
@@ -274,7 +274,7 @@ cdx disk profiles --candidates
274
274
 
275
275
  ### Next-Ready Notifications
276
276
 
277
- Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. It is shorthand for `cdx notify --schedule --next-ready`: cdx picks the next known reset, registers a native OS notification, and exits immediately.
277
+ Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. It is shorthand for `cdx notify --schedule --next-ready`: cdx picks the next known reset, registers a native OS notification, and exits immediately. If no future reset is known, it exits successfully and explains that no notification was scheduled.
278
278
 
279
279
  ```bash
280
280
  cdx ready
@@ -372,10 +372,11 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
372
372
  | `cdx reset <name> [--yes] [--json]` | Explicitly consume one available banked Codex rate-limit reset; confirmation is required unless `--yes` is supplied |
373
373
  | `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 |
374
374
  | `cdx import <file> [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
375
- | `cdx doctor [--json]` | Inspect CLI dependencies, provider CLI versions/capability hints, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
375
+ | `cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--json]` | Inspect CLI dependencies, provider CLI versions/capability hints, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines; optionally filter issue severities |
376
376
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
377
377
  | `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 |
378
378
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
379
+ | `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying safe native/Homebrew updates and missing RTK/Ponytail setup |
379
380
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
380
381
  | `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
381
382
  | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
@@ -0,0 +1,25 @@
1
+ # CDX Manager 0.12.1
2
+
3
+ ## Highlights
4
+
5
+ - Added `cdx update all` to inventory and safely update the installed provider CLIs, RTK, and Ponytail across Codex profiles.
6
+ - Made account handoffs use native provider transcripts before terminal capture, preserving actionable conversation instead of terminal noise.
7
+
8
+ ## Changes
9
+
10
+ ### Workstation updates
11
+
12
+ `cdx update all` now reports installed versions and session setup, asks once before applying its plan, and shows progress plus the command and error output when an action fails. It follows the actual installer provenance of each CLI, including the Claude Code cask rather than the separate Claude desktop app.
13
+
14
+ RTK is enabled for sessions that lack it when available. Ponytail marketplaces and plugins are checked per Codex profile; current Codex marketplace caches without a `last_revision` field are resolved from their Git checkout.
15
+
16
+ ### Reliable handoffs
17
+
18
+ Cross-session handoffs now prefer native JSONL transcripts and retain only user and assistant messages. Script-captured terminal logs remain a fallback, but no longer displace structured provider conversation or tool-output noise into the shared context.
19
+
20
+ ## Validation
21
+
22
+ - `npm run lint`
23
+ - `npm test`
24
+ - `logics-manager lint --require-status`
25
+ - `logics-manager audit --group-by-doc`
@@ -0,0 +1,28 @@
1
+ # CDX Manager 0.12.2
2
+
3
+ ## Highlights
4
+
5
+ - `cdx ready` now exits successfully when no future reset is known, clearly reporting that no notification was scheduled.
6
+ - `cdx doctor` can filter diagnostics by severity with `--severity OK|WARN|FAIL`.
7
+ - Unknown `cdx config <name>` sessions now explain how to inspect existing sessions or create the requested one.
8
+
9
+ ## Changes
10
+
11
+ ### Safer ready notifications
12
+
13
+ When `cdx ready` cannot find an upcoming session reset, it returns a successful unscheduled result instead of failing. Named reset notifications with no known reset remain explicit errors.
14
+
15
+ ### Filterable diagnostics
16
+
17
+ `cdx doctor --severity` accepts a comma-separated list of `OK`, `WARN`, and `FAIL` statuses. Text and JSON output retain accurate summaries for the filtered report.
18
+
19
+ ### Actionable configuration errors
20
+
21
+ Unknown-session errors from `cdx config` now direct users to `cdx configs` to inspect available sessions and `cdx add <name>` to create one.
22
+
23
+ ## Validation
24
+
25
+ - `npm run lint`
26
+ - `npm test`
27
+ - `logics-manager lint --require-status`
28
+ - `logics-manager audit --group-by-doc`
@@ -148,6 +148,14 @@
148
148
  "v0.11.2": {
149
149
  "github_tarball_sha256": "513d7c586a662da5ac5daf792fc76f746e924bbd95157a9d0cbbeba34f2991e0",
150
150
  "github_zip_sha256": "d6d633efcbdc28818f68bd87efe3f246fe2983a9f4aa24d03868c978645c593e"
151
+ },
152
+ "v0.12.0": {
153
+ "github_tarball_sha256": "de1bac90e790b5d557f90fd22f7dd317c45646d0e07320c05c8b2336ed9bbced",
154
+ "github_zip_sha256": "213f6217dfd8c7aa979af691c2d63598b4589ca40f36e816f4ae9c9f2da27073"
155
+ },
156
+ "v0.12.1": {
157
+ "github_tarball_sha256": "87e3b11886a8e7fb10233d425f6a1986e62395c89049947678a50cfb20384278",
158
+ "github_zip_sha256": "249f75eb240de7cc421f17579e0040d35eb06e3213568673f743c92191dda0f0"
151
159
  }
152
160
  }
153
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.12.0",
3
+ "version": "0.12.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.12.0"
7
+ version = "0.12.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
@@ -78,7 +78,7 @@ from .status_view import (
78
78
  )
79
79
  from .update_check import check_for_update, check_logics_manager_for_update
80
80
 
81
- VERSION = "0.12.0"
81
+ VERSION = "0.12.2"
82
82
 
83
83
  # Public surface: this module is a facade. Names below are imported above
84
84
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -216,10 +216,10 @@ def _print_help(use_color=False):
216
216
  f" {_style('cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]', '36', use_color)}",
217
217
  f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force] [--json]', '36', use_color)}",
218
218
  f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force|--merge] [--json]', '36', use_color)}",
219
- f" {_style('cdx doctor [--json]', '36', use_color)}",
219
+ f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--json]', '36', use_color)}",
220
220
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
221
221
  f" {_style('cdx view [--json]', '36', use_color)}",
222
- f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
222
+ f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
223
223
  f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
224
224
  f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
225
225
  f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -12,10 +12,10 @@ from .errors import CdxError
12
12
  from .provider_runtime import _normalize_reasoning_effort
13
13
 
14
14
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
15
- DOCTOR_USAGE = "Usage: cdx doctor [--json]"
15
+ DOCTOR_USAGE = "Usage: cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--json]"
16
16
  DISK_USAGE = "Usage: cdx disk [profiles] [--candidates] [--json]"
17
17
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
18
- UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
18
+ UPDATE_USAGE = "Usage: cdx update [all] [--check] [--yes] [--json] [--version TAG]"
19
19
  EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
20
20
  IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
21
21
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text...] [--json]"
@@ -46,6 +46,35 @@ def _parse_json_flag(args):
46
46
  return json_flag, cleaned
47
47
 
48
48
 
49
+ def _parse_doctor_args(args):
50
+ parsed = {"json": False, "severity": None}
51
+ index = 0
52
+ while index < len(args):
53
+ arg = args[index]
54
+ if arg == "--json":
55
+ parsed["json"] = True
56
+ index += 1
57
+ continue
58
+ if arg == "--severity":
59
+ if parsed["severity"] is not None or index + 1 >= len(args):
60
+ raise CdxError(DOCTOR_USAGE)
61
+ value = args[index + 1]
62
+ index += 2
63
+ elif arg.startswith("--severity="):
64
+ if parsed["severity"] is not None:
65
+ raise CdxError(DOCTOR_USAGE)
66
+ value = arg.split("=", 1)[1]
67
+ index += 1
68
+ else:
69
+ raise CdxError(DOCTOR_USAGE)
70
+
71
+ values = [item.strip().upper() for item in value.split(",")]
72
+ if not values or any(item not in {"OK", "WARN", "FAIL"} for item in values):
73
+ raise CdxError(DOCTOR_USAGE)
74
+ parsed["severity"] = ",".join(dict.fromkeys(values))
75
+ return parsed
76
+
77
+
49
78
  def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
50
79
  parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
51
80
  positionals = []
@@ -594,11 +623,16 @@ def _parse_update_args(args):
594
623
  "--json": {"key": "json", "type": "bool", "default": False},
595
624
  "--yes": {"key": "yes", "type": "bool", "default": False},
596
625
  "--version": {"key": "version", "type": "str", "default": None},
597
- }, UPDATE_USAGE)
626
+ }, UPDATE_USAGE, positionals_key="values", max_positionals=1)
627
+ if parsed["values"] not in ([], ["all"]):
628
+ raise CdxError(UPDATE_USAGE)
629
+ parsed["all"] = bool(parsed["values"])
630
+ if parsed["all"] and (parsed["check"] or parsed["version"]):
631
+ raise CdxError("Usage: cdx update all [--yes] [--json]")
598
632
  if parsed["check"] and parsed["version"]:
599
633
  raise CdxError("Usage: cdx update --check cannot be combined with --version.")
600
634
  if parsed["version"] is not None and not parsed["version"].strip():
601
- raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
635
+ raise CdxError(UPDATE_USAGE)
602
636
  return parsed
603
637
 
604
638
 
@@ -17,7 +17,6 @@ from .cli_args import (
17
17
  CAN_RESUME_USAGE,
18
18
  CONTEXT_USAGE,
19
19
  DISK_USAGE,
20
- DOCTOR_USAGE,
21
20
  HANDOFF_USAGE,
22
21
  LAST_USAGE,
23
22
  MEMORY_USAGE,
@@ -33,6 +32,7 @@ from .cli_args import (
33
32
  _parse_config_args,
34
33
  _parse_configs_args,
35
34
  _parse_copy_args,
35
+ _parse_doctor_args,
36
36
  _parse_export_args,
37
37
  _parse_flag_args,
38
38
  _parse_history_args,
@@ -98,7 +98,7 @@ from .context_store import (
98
98
  )
99
99
  from .errors import CdxError
100
100
  from .fs_utils import atomic_write
101
- from .health import collect_health_report, format_health_report
101
+ from .health import collect_health_report, filter_health_report, format_health_report
102
102
  from .notify import (
103
103
  format_notify_event,
104
104
  format_scheduled_notification,
@@ -124,10 +124,46 @@ from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
124
124
  from .run_registry import RunRegistry, build_code_review_report
125
125
  from .run_usage import extract_run_usage
126
126
  from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
127
+ from .update_all import apply_update_all_plan, collect_update_all_plan
127
128
  from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
128
129
  from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
129
130
 
130
131
 
132
+ def _format_update_all(plan, use_color=False):
133
+ labels = {
134
+ "up_to_date": _success("OK", use_color),
135
+ "update_available": _warn("UPDATE", use_color),
136
+ "not_installed": _warn("MISSING", use_color),
137
+ "check_with_native_updater": _info("CHECK", use_color),
138
+ }
139
+ rows = [[_style("TOOL", "1", use_color), _style("CURRENT", "1", use_color), _style("LATEST", "1", use_color), _style("STATUS", "1", use_color)]]
140
+ for item in plan["items"]:
141
+ rows.append([_info(item["name"], use_color), item.get("version") or _dim("-", use_color), item.get("latest_version") or _dim("-", use_color), labels.get(item["status"], item["status"].replace("_", " "))])
142
+ lines = [_style("Workstation update", "1", use_color), _dim("Inventory only — nothing has changed.", use_color), "", _pad_table(rows)]
143
+ missing_rtk = plan["setup"]["rtk_missing_sessions"]
144
+ setup = [f"RTK: enable for {', '.join(missing_rtk)}" if missing_rtk else "RTK: configured for every session"]
145
+ setup.extend(f"Ponytail {plugin['session']}: {labels.get(plugin['status'], plugin['status'].replace('_', ' '))}" for plugin in plan["setup"]["ponytail"])
146
+ lines.extend(["", _style("Session setup", "1", use_color), *setup])
147
+ if plan["steps"]:
148
+ lines.extend(["", _warn(f"{len(plan['steps'])} action(s) ready", use_color), _dim("Confirm to update only the tools listed above.", use_color)])
149
+ else:
150
+ lines.extend(["", _success("Everything managed by CDX is current and configured.", use_color)])
151
+ return "\n".join(lines)
152
+
153
+
154
+ def _format_update_all_result(result, use_color=False):
155
+ if result.get("skipped"):
156
+ return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
157
+ if result.get("returncode") == 0:
158
+ return _success(f"done: {result['name']}", use_color)
159
+ detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
160
+ detail = detail[-500:]
161
+ command = shlex.join(result.get("command") or [])
162
+ suffix = f"\n {_dim(command, use_color)}" if command else ""
163
+ label = _warn(f"failed: {result['name']} (exit {result.get('returncode')})", use_color)
164
+ return f"{label}\n {detail}{suffix}"
165
+
166
+
131
167
  def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False, use_stdin=False):
132
168
  env = ctx.get("env", {})
133
169
  if env_var:
@@ -1664,7 +1700,10 @@ def handle_config(rest, ctx):
1664
1700
  parsed = _parse_config_args(rest)
1665
1701
  session = ctx["service"]["get_session"](parsed["name"])
1666
1702
  if not session:
1667
- raise CdxError(f"Unknown session: {parsed['name']}")
1703
+ raise CdxError(
1704
+ f"Unknown session: {parsed['name']}. Run cdx configs to inspect existing sessions or "
1705
+ f"cdx add {parsed['name']} to create it."
1706
+ )
1668
1707
  message = f"Launch settings for {parsed['name']}"
1669
1708
  if parsed["json"]:
1670
1709
  _write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
@@ -1857,17 +1896,15 @@ def handle_clean(rest, ctx):
1857
1896
 
1858
1897
 
1859
1898
  def handle_doctor(rest, ctx):
1860
- json_flag = "--json" in rest
1861
- unknown = [arg for arg in rest if arg != "--json"]
1862
- if unknown:
1863
- raise CdxError(DOCTOR_USAGE)
1899
+ parsed = _parse_doctor_args(rest)
1864
1900
  report = collect_health_report(
1865
1901
  ctx["service"],
1866
1902
  ctx["service"]["base_dir"],
1867
1903
  env=ctx.get("env"),
1868
1904
  spawn_sync=ctx.get("spawn_sync"),
1869
1905
  )
1870
- if json_flag:
1906
+ report = filter_health_report(report, parsed["severity"])
1907
+ if parsed["json"]:
1871
1908
  _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
1872
1909
  else:
1873
1910
  ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
@@ -2674,6 +2711,38 @@ def handle_logout(rest, ctx):
2674
2711
 
2675
2712
  def handle_update(rest, ctx):
2676
2713
  parsed = _parse_update_args(rest)
2714
+ if parsed["all"]:
2715
+ plan = collect_update_all_plan(ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"))
2716
+ if not parsed["yes"]:
2717
+ if parsed["json"]:
2718
+ _write_json(ctx, _json_success("update.all", "Collected workstation update plan", plan=plan, apply_required=bool(plan["steps"])))
2719
+ return 0
2720
+ ctx["out"](f"{_format_update_all(plan, ctx['use_color'])}\n")
2721
+ if not plan["steps"]:
2722
+ return 0
2723
+ if not ctx["stdin_is_tty"]:
2724
+ raise CdxError("cdx update all requires an interactive terminal or --yes.")
2725
+ if input("Apply this plan? [y/N] ").strip().lower() not in ("y", "yes"):
2726
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
2727
+ return 0
2728
+ def progress(event):
2729
+ if parsed["json"]:
2730
+ return
2731
+ step_name = event["step"]["name"]
2732
+ if event["phase"] == "start":
2733
+ progress_label = _info(f"[{event['index']}/{event['total']}]", ctx["use_color"])
2734
+ ctx["out"](f"{progress_label} {step_name}...\n")
2735
+ else:
2736
+ ctx["out"](f" {_format_update_all_result(event['result'], ctx['use_color'])}\n")
2737
+
2738
+ results = apply_update_all_plan(plan, ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"), progress=progress)
2739
+ failed = [row for row in results if row.get("returncode") != 0]
2740
+ message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
2741
+ if parsed["json"]:
2742
+ _write_json(ctx, _json_success("update.all", message, plan=plan, results=results, failed=len(failed)))
2743
+ else:
2744
+ ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
2745
+ return 1 if failed else 0
2677
2746
  json_flag = parsed["json"]
2678
2747
  current_version = str(ctx.get("version") or "").strip()
2679
2748
  release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
@@ -250,11 +250,10 @@ def _collect_native_handoff_transcript_paths(session):
250
250
 
251
251
 
252
252
  def _latest_handoff_transcript_path(session):
253
- launch_path = _latest_launch_transcript_path(session)
254
- if launch_path:
255
- return launch_path
256
253
  native_paths = _collect_native_handoff_transcript_paths(session)
257
- return native_paths[0] if native_paths else None
254
+ if native_paths:
255
+ return native_paths[0]
256
+ return _latest_launch_transcript_path(session)
258
257
 
259
258
 
260
259
  def _collect_handoff_text_fragments(value):
@@ -281,6 +280,10 @@ def _collect_handoff_text_fragments(value):
281
280
  def _jsonl_record_to_handoff_text(record):
282
281
  if not isinstance(record, dict):
283
282
  return None
283
+ if record.get("type") == "response_item":
284
+ record = record.get("payload")
285
+ if not isinstance(record, dict):
286
+ return None
284
287
  message = record.get("message") if isinstance(record.get("message"), dict) else {}
285
288
  role = (
286
289
  message.get("role")
@@ -289,6 +292,8 @@ def _jsonl_record_to_handoff_text(record):
289
292
  or record.get("sender")
290
293
  or "entry"
291
294
  )
295
+ if role not in ("user", "assistant"):
296
+ return None
292
297
  text_sources = []
293
298
  for key in ("content", "text", "payload"):
294
299
  if key in record:
package/src/health.py CHANGED
@@ -363,6 +363,19 @@ def summarize_health(issues):
363
363
  }
364
364
 
365
365
 
366
+ def filter_health_report(report, severity):
367
+ if severity is None:
368
+ return report
369
+ severities = set(severity.split(","))
370
+ issues = [issue for issue in report["issues"] if issue["status"] in severities]
371
+ return {
372
+ **report,
373
+ "issues": issues,
374
+ "summary": summarize_health(issues),
375
+ "severity": severity,
376
+ }
377
+
378
+
366
379
  def format_health_report(report, use_color=False):
367
380
  rows = [["STATUS", "CHECK", "MESSAGE"]]
368
381
  for issue in report["issues"]:
package/src/notify.py CHANGED
@@ -221,6 +221,13 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
221
221
  now_fn = now_fn or time.time
222
222
  target_timestamp = event.get("target_timestamp")
223
223
  if target_timestamp is None:
224
+ if parsed["mode"] == "next-ready":
225
+ return {
226
+ "scheduled": False,
227
+ "backend": "none",
228
+ "message": event["message"],
229
+ "target_timestamp": None,
230
+ }
224
231
  raise CdxError(f"Cannot schedule notification: {event['message']}")
225
232
  if target_timestamp <= now_fn():
226
233
  return {
@@ -0,0 +1,184 @@
1
+ """Inventory and apply the safe, known updates for a CDX workstation."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+
9
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
10
+
11
+ _TOOLS = (
12
+ (PROVIDER_CODEX, "codex", None, ["codex", "update"]),
13
+ (PROVIDER_CLAUDE, "claude", "claude", ["brew", "upgrade", "--cask", "claude"]),
14
+ (PROVIDER_ANTIGRAVITY, "agy", "antigravity", ["brew", "upgrade", "--cask", "antigravity"]),
15
+ (PROVIDER_OLLAMA, "ollama", "ollama", ["brew", "upgrade", "ollama"]),
16
+ ("rtk", "rtk", "rtk", ["brew", "upgrade", "rtk"]),
17
+ )
18
+ _PONYTAIL_URL = "https://github.com/DietrichGebert/ponytail.git"
19
+
20
+
21
+ def _which(command, env):
22
+ path = env.get("PATH")
23
+ resolved = shutil.which(command, path=path)
24
+ if resolved or not path:
25
+ return resolved
26
+ for directory in path.split(os.pathsep):
27
+ candidate = os.path.join(directory, command)
28
+ if os.path.isfile(candidate):
29
+ return candidate
30
+ return None
31
+
32
+
33
+ def _run(command, env, runner=None, timeout=15):
34
+ runner = runner or subprocess.run
35
+ try:
36
+ result = runner(command, env=env, capture_output=True, text=True, check=False, timeout=timeout)
37
+ except (OSError, subprocess.TimeoutExpired) as error:
38
+ return {"returncode": 1, "stdout": "", "stderr": str(error)}
39
+ if isinstance(result, dict):
40
+ return result
41
+ return {"returncode": result.returncode, "stdout": result.stdout or "", "stderr": result.stderr or ""}
42
+
43
+
44
+ def _version(command, env, runner):
45
+ result = _run([command, "--version"], env, runner)
46
+ text = (result["stdout"] or result["stderr"] or "").strip()
47
+ match = re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text)
48
+ return match.group(1) if match else None
49
+
50
+
51
+ def _npm_latest(package, env, runner):
52
+ if not _which("npm", env):
53
+ return None
54
+ result = _run(["npm", "view", package, "version"], env, runner)
55
+ value = (result["stdout"] or "").strip()
56
+ return value if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", value) else None
57
+
58
+
59
+ def _newer(latest, current):
60
+ def key(value):
61
+ return tuple(int(part) for part in str(value).split("-", 1)[0].split("+", 1)[0].split("."))
62
+ return bool(latest and current and key(latest) > key(current))
63
+
64
+
65
+ def _brew_outdated(env, runner):
66
+ if not _which("brew", env):
67
+ return {}
68
+ result = _run(["brew", "outdated", "--json=v2"], env, runner, timeout=30)
69
+ try:
70
+ payload = json.loads(result["stdout"])
71
+ except (TypeError, ValueError):
72
+ return {}
73
+ rows = {}
74
+ for group in ("formulae", "casks"):
75
+ for item in payload.get(group, []):
76
+ rows[item.get("name")] = item
77
+ return rows
78
+
79
+
80
+ def _brew_cask_for_path(path):
81
+ match = re.search(r"/Caskroom/([^/]+)/", os.path.realpath(path or ""))
82
+ return match.group(1) if match else None
83
+
84
+
85
+ def _ponytail_revision(auth_home, env=None, runner=None):
86
+ try:
87
+ with open(os.path.join(auth_home, "config.toml"), encoding="utf-8") as handle:
88
+ text = handle.read()
89
+ except OSError:
90
+ text = ""
91
+ match = re.search(r'\[marketplaces\.ponytail\].*?last_revision\s*=\s*"([0-9a-f]+)"', text, re.S)
92
+ if match:
93
+ return match.group(1)
94
+ cache = os.path.join(auth_home, ".tmp", "marketplaces", "ponytail")
95
+ result = _run(["git", "-C", cache, "rev-parse", "HEAD"], env or os.environ, runner)
96
+ revision = (result["stdout"] or "").strip()
97
+ return revision if re.fullmatch(r"[0-9a-f]{40}", revision) else None
98
+
99
+
100
+ def collect_update_all_plan(service, env=None, runner=None):
101
+ env = dict(env or os.environ)
102
+ outdated = _brew_outdated(env, runner)
103
+ items, steps = [], []
104
+ for name, command, brew_name, update_command in _TOOLS:
105
+ path = _which(command, env)
106
+ item = {"name": name, "command": command, "installed": bool(path), "path": path, "version": _version(command, env, runner) if path else None}
107
+ cask_name = _brew_cask_for_path(path)
108
+ brew_target = cask_name or brew_name
109
+ brew_row = outdated.get(brew_target) if brew_target else None
110
+ if brew_row:
111
+ command_to_run = ["brew", "upgrade", "--cask", brew_target] if cask_name else update_command
112
+ item.update({"status": "update_available", "latest_version": brew_row.get("current_version"), "update_command": command_to_run, "package": brew_target})
113
+ steps.append({"name": name, "command": command_to_run, "env": {}})
114
+ elif path and name == PROVIDER_CODEX:
115
+ # Codex owns its installer; it is the only reliable updater across npm, Homebrew and standalone installs.
116
+ latest = _npm_latest("@openai/codex", env, runner)
117
+ item.update({"latest_version": latest, "status": "update_available" if _newer(latest, item["version"]) else "up_to_date" if latest else "check_with_native_updater", "update_command": update_command})
118
+ if item["status"] != "up_to_date":
119
+ steps.append({"name": name, "command": update_command, "env": {}})
120
+ else:
121
+ item["status"] = "up_to_date" if path else "not_installed"
122
+ items.append(item)
123
+
124
+ rtk_available = any(item["name"] == "rtk" and item["installed"] for item in items)
125
+ missing_rtk = [session["name"] for session in service["list_sessions"]() if not (session.get("launch") or {}).get("rtk")]
126
+ setup = {"rtk_available": rtk_available, "rtk_missing_sessions": missing_rtk, "ponytail": []}
127
+ if rtk_available and missing_rtk:
128
+ steps.append({"name": "enable RTK", "command": None, "sessions": missing_rtk})
129
+
130
+ codex_sessions = [session for session in service["list_sessions"]() if session.get("provider") == PROVIDER_CODEX]
131
+ ponytail_head = None
132
+ if any(_ponytail_revision(session.get("authHome") or "", env, runner) for session in codex_sessions):
133
+ remote = _run(["git", "ls-remote", _PONYTAIL_URL, "HEAD"], env, runner)
134
+ ponytail_head = (remote["stdout"] or "").split("\t", 1)[0].strip() or None
135
+ for session in codex_sessions:
136
+ revision = _ponytail_revision(session.get("authHome") or "", env, runner)
137
+ row = {"session": session["name"], "installed": bool(revision), "revision": revision}
138
+ if revision:
139
+ row["latest_revision"] = ponytail_head
140
+ row["status"] = "update_available" if ponytail_head and not ponytail_head.startswith(revision) else "up_to_date"
141
+ if row["status"] == "update_available":
142
+ steps.append({"name": f"update Ponytail ({session['name']})", "command": ["codex", "plugin", "marketplace", "upgrade", "ponytail", "--json"], "env": {"CODEX_HOME": session["authHome"]}})
143
+ else:
144
+ row["status"] = "not_installed"
145
+ marketplace_id = f"ponytail-marketplace:{session['name']}"
146
+ steps.extend([
147
+ {"id": marketplace_id, "name": f"add Ponytail marketplace ({session['name']})", "command": ["codex", "plugin", "marketplace", "add", _PONYTAIL_URL], "env": {"CODEX_HOME": session["authHome"]}},
148
+ {"name": f"install Ponytail ({session['name']})", "command": ["codex", "plugin", "add", "ponytail@ponytail", "--json"], "env": {"CODEX_HOME": session["authHome"]}, "requires": marketplace_id},
149
+ ])
150
+ setup["ponytail"].append(row)
151
+ return {"items": items, "setup": setup, "steps": steps}
152
+
153
+
154
+ def apply_update_all_plan(plan, service, env=None, runner=None, progress=None):
155
+ env = dict(env or os.environ)
156
+ results = []
157
+ completed = set()
158
+ for index, step in enumerate(plan["steps"], start=1):
159
+ if progress:
160
+ progress({"phase": "start", "index": index, "total": len(plan["steps"]), "step": step})
161
+ if step.get("requires") and step["requires"] not in completed:
162
+ result = {"name": step["name"], "returncode": None, "skipped": True, "blocked_by": step["requires"]}
163
+ results.append(result)
164
+ if progress:
165
+ progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
166
+ continue
167
+ if step["command"] is None:
168
+ for name in step["sessions"]:
169
+ service["set_launch_settings"](name, {"rtk": True})
170
+ result = {"name": step["name"], "returncode": 0, "sessions": step["sessions"]}
171
+ results.append(result)
172
+ if step.get("id"):
173
+ completed.add(step["id"])
174
+ if progress:
175
+ progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
176
+ continue
177
+ result = _run(step["command"], {**env, **step.get("env", {})}, runner, timeout=120)
178
+ result = {"name": step["name"], "command": step["command"], **result}
179
+ results.append(result)
180
+ if result["returncode"] == 0 and step.get("id"):
181
+ completed.add(step["id"])
182
+ if progress:
183
+ progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
184
+ return results