cdx-manager 0.5.3 → 0.5.4

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.5.3-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.5.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -126,7 +126,7 @@ For a specific version:
126
126
 
127
127
  ```bash
128
128
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
129
- CDX_VERSION=v0.5.3 sh install.sh
129
+ CDX_VERSION=v0.5.4 sh install.sh
130
130
  ```
131
131
 
132
132
  From source:
@@ -0,0 +1,49 @@
1
+ # Changelog (`0.5.3 -> 0.5.4`)
2
+
3
+ Release date: 2026-05-23
4
+
5
+ ## Major Highlights
6
+
7
+ - Added a Logics ADR for the code quality, security, performance, and test coverage improvements completed after `0.5.3`.
8
+ - Hardened bundle import path handling and launch prompt validation without changing CLI contracts or on-disk formats.
9
+ - Improved status parsing and provider handling internals for safer future maintenance.
10
+ - Added structured Codex app-server probe diagnostics while keeping the existing status API behavior compatible.
11
+
12
+ ## Security and Runtime Hardening
13
+
14
+ - Hardened bundle relative path validation against traversal, absolute paths, and Windows drive paths.
15
+ - Validated provider launch `initial_prompt` type and length before it is passed into subprocess arguments.
16
+ - Replaced mutable signal-capture state with explicit `nonlocal` signal forwarding.
17
+ - Added Linux `notify-send` support with backend timeout handling and shell-free notification arguments.
18
+
19
+ ## Status and Provider Internals
20
+
21
+ - Moved status extraction regex compilation to module-level constants.
22
+ - Added shared provider constants for Codex and Claude identity checks.
23
+ - Improved Claude status refresh handling for sync and async refresh functions without per-thread event loops.
24
+ - Added `fetch_codex_rate_limit_diagnostic()` to expose structured failure reasons such as missing auth home, initialize failure, app-server read failure, missing rate limits, and missing Codex CLI.
25
+
26
+ ## CLI Parser Maintenance
27
+
28
+ - Factorized more CLI flag parsing through a shared flat-flag parser.
29
+ - Added `--flag=value` support where appropriate, including `cdx notify --poll=<seconds>`.
30
+ - Preserved existing usage errors and JSON/non-JSON output contracts.
31
+
32
+ ## Documentation
33
+
34
+ - Added `logics/architecture/adr_001_code_quality_and_security_improvements.md` documenting the security, test, quality, and performance decisions.
35
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.4`.
36
+
37
+ ## Validation and Regression Coverage
38
+
39
+ - Added direct session-store rollback tests for add, remove, rename, and replace failure paths.
40
+ - Added status-source tests for key-value, Codex block, Claude block, artifact selection, reset timestamp parsing, and safe relative paths.
41
+ - Added notification tests for macOS, Linux, backend fallback, timeout, and argument handling.
42
+ - Added CLI parser tests for update, status, repair, export/import subsets, corrupt bundles, and clean behavior with and without logs.
43
+ - Added Codex app-server diagnostic tests for success and failure cases.
44
+
45
+ ## Validation and Regression Evidence
46
+
47
+ - `npm test`
48
+ - `npm run lint`
49
+ - `python logics/skills/logics.py lint --require-status`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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.5.3"
7
+ version = "0.5.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,6 +3,7 @@ import threading
3
3
  from datetime import datetime, timezone
4
4
 
5
5
  from .claude_usage import refresh_claude_session_status
6
+ from .config import PROVIDER_CLAUDE
6
7
  from .errors import CdxError
7
8
 
8
9
  CLAUDE_REFRESH_TTL_SECONDS = 10 * 60
@@ -35,7 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
35
36
  sessions = service["list_sessions"]()
36
37
  claude_sessions = [
37
38
  s for s in sessions
38
- if s["provider"] == "claude"
39
+ if s["provider"] == PROVIDER_CLAUDE
39
40
  and s.get("enabled", True) is not False
40
41
  and (not target_names or s["name"] in target_names)
41
42
  and (force or _is_stale(s, ttl_seconds=ttl_seconds))
@@ -45,25 +46,41 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
45
46
 
46
47
  errors = []
47
48
  results = {}
48
- threads = []
49
49
 
50
- def fetch(s):
51
- try:
52
- usage = refresh_fn(s)
53
- if inspect.isawaitable(usage):
54
- import asyncio
55
- usage = asyncio.run(usage)
56
- if usage:
57
- results[s["name"]] = usage
58
- except Exception as e:
59
- errors.append({"session": s["name"], "error": e})
60
-
61
- for s in claude_sessions:
62
- t = threading.Thread(target=fetch, args=(s,))
63
- threads.append(t)
64
- t.start()
65
- for t in threads:
66
- t.join()
50
+ if inspect.iscoroutinefunction(refresh_fn):
51
+ import asyncio
52
+
53
+ async def fetch_all():
54
+ async def fetch_async(s):
55
+ try:
56
+ usage = await refresh_fn(s)
57
+ if usage:
58
+ results[s["name"]] = usage
59
+ except Exception as e:
60
+ errors.append({"session": s["name"], "error": e})
61
+
62
+ await asyncio.gather(*(fetch_async(s) for s in claude_sessions))
63
+
64
+ asyncio.run(fetch_all())
65
+ else:
66
+ threads = []
67
+
68
+ def fetch(s):
69
+ try:
70
+ usage = refresh_fn(s)
71
+ if inspect.isawaitable(usage):
72
+ raise CdxError("Claude refresh function returned an awaitable from a sync callable.")
73
+ if usage:
74
+ results[s["name"]] = usage
75
+ except Exception as e:
76
+ errors.append({"session": s["name"], "error": e})
77
+
78
+ for s in claude_sessions:
79
+ t = threading.Thread(target=fetch, args=(s,), daemon=True)
80
+ threads.append(t)
81
+ t.start()
82
+ for t in threads:
83
+ t.join(timeout=10)
67
84
 
68
85
  for name, usage in results.items():
69
86
  try:
package/src/cli.py CHANGED
@@ -49,7 +49,7 @@ from .status_view import (
49
49
  )
50
50
  from .update_check import check_for_update
51
51
 
52
- VERSION = "0.5.3"
52
+ VERSION = "0.5.4"
53
53
 
54
54
 
55
55
  # ---------------------------------------------------------------------------
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
 
8
8
  from .claude_refresh import _refresh_claude_sessions
9
9
  from .cli_render import _dim, _info, _success, _warn
10
+ from .config import PROVIDER_CODEX
10
11
  from .context_store import (
11
12
  clear_context,
12
13
  edit_context,
@@ -112,7 +113,7 @@ def _build_handoff_context(source, target, transcript_path, transcript, truncate
112
113
 
113
114
 
114
115
  def _handoff_launch_prompt(session, install=None):
115
- if session.get("provider") == "codex":
116
+ if session.get("provider") == PROVIDER_CODEX:
116
117
  context_ref = "$CODEX_HOME/shared-context.md"
117
118
  else:
118
119
  context_ref = (install or {}).get("target_path") or os.path.join(
@@ -131,11 +132,54 @@ def _parse_json_flag(args):
131
132
  return json_flag, cleaned
132
133
 
133
134
 
135
+ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
136
+ parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
137
+ positionals = []
138
+ index = 0
139
+ while index < len(args):
140
+ arg = args[index]
141
+ if arg in schema:
142
+ spec = schema[arg]
143
+ if spec["type"] == "bool":
144
+ parsed[spec["key"]] = True
145
+ index += 1
146
+ continue
147
+ value, index = _read_option_value(args, index, usage)
148
+ parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
149
+ continue
150
+ if arg.startswith("--") and "=" in arg:
151
+ flag, value = arg.split("=", 1)
152
+ spec = schema.get(flag)
153
+ if not spec or spec["type"] == "bool":
154
+ raise CdxError(usage)
155
+ parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
156
+ index += 1
157
+ continue
158
+ if arg.startswith("-"):
159
+ raise CdxError(usage)
160
+ positionals.append(arg)
161
+ if len(positionals) > max_positionals:
162
+ raise CdxError(usage)
163
+ index += 1
164
+
165
+ if positionals_key is not None:
166
+ parsed[positionals_key] = positionals
167
+ return parsed
168
+
169
+
134
170
  def _parse_add_args(args):
135
- if len(args) == 1:
136
- return {"provider": "codex", "name": args[0]}
137
- if len(args) == 2:
138
- return {"provider": args[0], "name": args[1]}
171
+ parsed = _parse_flag_args(
172
+ args,
173
+ {},
174
+ "Usage: cdx add [provider] <name> [--json]",
175
+ positionals_key="values",
176
+ max_positionals=2,
177
+ )
178
+ values = parsed["values"]
179
+ if len(values) == 1:
180
+ return {"provider": PROVIDER_CODEX, "name": values[0]}
181
+ if len(values) == 2:
182
+ return {"provider": values[0], "name": values[1]}
139
183
  raise CdxError("Usage: cdx add [provider] <name> [--json]")
140
184
 
141
185
 
@@ -152,19 +196,21 @@ def _parse_rename_args(args):
152
196
 
153
197
 
154
198
  def _parse_remove_args(args):
155
- force = "--force" in args
156
- names = [a for a in args if a != "--force"]
157
- unknown = [a for a in args if a.startswith("-") and a != "--force"]
158
- if unknown or len(names) != 1 or len(args) > 2:
199
+ parsed = _parse_flag_args(args, {
200
+ "--force": {"key": "force", "type": "bool", "default": False},
201
+ }, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
202
+ if len(parsed["names"]) != 1:
159
203
  raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
160
- return {"name": names[0], "force": force}
204
+ return {"name": parsed["names"][0], "force": parsed["force"]}
161
205
 
162
206
 
163
207
  def _parse_toggle_args(args, usage):
164
- json_flag, cleaned = _parse_json_flag(args)
165
- if len(cleaned) != 1:
208
+ parsed = _parse_flag_args(args, {
209
+ "--json": {"key": "json", "type": "bool", "default": False},
210
+ }, usage, positionals_key="names", max_positionals=1)
211
+ if len(parsed["names"]) != 1:
166
212
  raise CdxError(usage)
167
- return {"name": cleaned[0], "json": json_flag}
213
+ return {"name": parsed["names"][0], "json": parsed["json"]}
168
214
 
169
215
 
170
216
  def _read_option_value(args, index, usage):
@@ -183,37 +229,12 @@ def _parse_session_names(value):
183
229
 
184
230
 
185
231
  def _parse_update_args(args):
186
- parsed = {
187
- "check": False,
188
- "json": False,
189
- "yes": False,
190
- "version": None,
191
- }
192
- index = 0
193
- while index < len(args):
194
- arg = args[index]
195
- if arg == "--check":
196
- parsed["check"] = True
197
- index += 1
198
- continue
199
- if arg == "--json":
200
- parsed["json"] = True
201
- index += 1
202
- continue
203
- if arg == "--yes":
204
- parsed["yes"] = True
205
- index += 1
206
- continue
207
- if arg == "--version":
208
- value, index = _read_option_value(args, index, UPDATE_USAGE)
209
- parsed["version"] = value
210
- continue
211
- if arg.startswith("--version="):
212
- parsed["version"] = arg.split("=", 1)[1]
213
- index += 1
214
- continue
215
- raise CdxError(UPDATE_USAGE)
216
-
232
+ parsed = _parse_flag_args(args, {
233
+ "--check": {"key": "check", "type": "bool", "default": False},
234
+ "--json": {"key": "json", "type": "bool", "default": False},
235
+ "--yes": {"key": "yes", "type": "bool", "default": False},
236
+ "--version": {"key": "version", "type": "str", "default": None},
237
+ }, UPDATE_USAGE)
217
238
  if parsed["check"] and parsed["version"]:
218
239
  raise CdxError("Usage: cdx update --check cannot be combined with --version.")
219
240
  if parsed["version"] is not None and not parsed["version"].strip():
@@ -222,52 +243,19 @@ def _parse_update_args(args):
222
243
 
223
244
 
224
245
  def _parse_export_args(args):
225
- parsed = {
226
- "file_path": None,
227
- "include_auth": False,
228
- "force": False,
229
- "json": False,
230
- "session_names": None,
231
- "passphrase_env": None,
232
- }
233
- index = 0
234
- while index < len(args):
235
- arg = args[index]
236
- if arg == "--include-auth":
237
- parsed["include_auth"] = True
238
- index += 1
239
- continue
240
- if arg == "--force":
241
- parsed["force"] = True
242
- index += 1
243
- continue
244
- if arg == "--json":
245
- parsed["json"] = True
246
- index += 1
247
- continue
248
- if arg == "--sessions":
249
- value, index = _read_option_value(args, index, EXPORT_USAGE)
250
- parsed["session_names"] = _parse_session_names(value)
251
- continue
252
- if arg.startswith("--sessions="):
253
- parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
254
- index += 1
255
- continue
256
- if arg == "--passphrase-env":
257
- value, index = _read_option_value(args, index, EXPORT_USAGE)
258
- parsed["passphrase_env"] = value
259
- continue
260
- if arg.startswith("--passphrase-env="):
261
- parsed["passphrase_env"] = arg.split("=", 1)[1]
262
- index += 1
263
- continue
264
- if arg.startswith("-"):
265
- raise CdxError(EXPORT_USAGE)
266
- if parsed["file_path"] is not None:
267
- raise CdxError(EXPORT_USAGE)
268
- parsed["file_path"] = arg
269
- index += 1
270
-
246
+ parsed = _parse_flag_args(args, {
247
+ "--include-auth": {"key": "include_auth", "type": "bool", "default": False},
248
+ "--force": {"key": "force", "type": "bool", "default": False},
249
+ "--json": {"key": "json", "type": "bool", "default": False},
250
+ "--sessions": {
251
+ "key": "session_names",
252
+ "type": "str",
253
+ "default": None,
254
+ "transform": _parse_session_names,
255
+ },
256
+ "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
257
+ }, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
258
+ parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
271
259
  if not parsed["file_path"]:
272
260
  raise CdxError(EXPORT_USAGE)
273
261
  if parsed["passphrase_env"] and not parsed["include_auth"]:
@@ -276,47 +264,18 @@ def _parse_export_args(args):
276
264
 
277
265
 
278
266
  def _parse_import_args(args):
279
- parsed = {
280
- "file_path": None,
281
- "force": False,
282
- "json": False,
283
- "session_names": None,
284
- "passphrase_env": None,
285
- }
286
- index = 0
287
- while index < len(args):
288
- arg = args[index]
289
- if arg == "--force":
290
- parsed["force"] = True
291
- index += 1
292
- continue
293
- if arg == "--json":
294
- parsed["json"] = True
295
- index += 1
296
- continue
297
- if arg == "--sessions":
298
- value, index = _read_option_value(args, index, IMPORT_USAGE)
299
- parsed["session_names"] = _parse_session_names(value)
300
- continue
301
- if arg.startswith("--sessions="):
302
- parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
303
- index += 1
304
- continue
305
- if arg == "--passphrase-env":
306
- value, index = _read_option_value(args, index, IMPORT_USAGE)
307
- parsed["passphrase_env"] = value
308
- continue
309
- if arg.startswith("--passphrase-env="):
310
- parsed["passphrase_env"] = arg.split("=", 1)[1]
311
- index += 1
312
- continue
313
- if arg.startswith("-"):
314
- raise CdxError(IMPORT_USAGE)
315
- if parsed["file_path"] is not None:
316
- raise CdxError(IMPORT_USAGE)
317
- parsed["file_path"] = arg
318
- index += 1
319
-
267
+ parsed = _parse_flag_args(args, {
268
+ "--force": {"key": "force", "type": "bool", "default": False},
269
+ "--json": {"key": "json", "type": "bool", "default": False},
270
+ "--sessions": {
271
+ "key": "session_names",
272
+ "type": "str",
273
+ "default": None,
274
+ "transform": _parse_session_names,
275
+ },
276
+ "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
277
+ }, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
278
+ parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
320
279
  if not parsed["file_path"]:
321
280
  raise CdxError(IMPORT_USAGE)
322
281
  return parsed
@@ -558,21 +517,20 @@ def handle_doctor(rest, ctx):
558
517
 
559
518
 
560
519
  def handle_repair(rest, ctx):
561
- json_flag = "--json" in rest
562
- dry_run = "--dry-run" in rest or "--force" not in rest
563
- force = "--force" in rest
564
- allowed = {"--json", "--dry-run", "--force"}
565
- unknown = [arg for arg in rest if arg not in allowed]
566
- if unknown:
567
- raise CdxError(REPAIR_USAGE)
520
+ parsed = _parse_flag_args(rest, {
521
+ "--json": {"key": "json", "type": "bool", "default": False},
522
+ "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
523
+ "--force": {"key": "force", "type": "bool", "default": False},
524
+ }, REPAIR_USAGE)
525
+ dry_run = parsed["dry_run"] or not parsed["force"]
568
526
  report = repair_health(
569
527
  ctx["service"],
570
528
  ctx["service"]["base_dir"],
571
529
  env=ctx.get("env"),
572
530
  dry_run=dry_run,
573
- force=force,
531
+ force=parsed["force"],
574
532
  )
575
- if json_flag:
533
+ if parsed["json"]:
576
534
  _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
577
535
  else:
578
536
  ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
@@ -704,22 +662,23 @@ def handle_context(rest, ctx):
704
662
 
705
663
 
706
664
  def handle_status(rest, ctx):
707
- json_flag = "--json" in rest
708
- small_flag = "--small" in rest or "-s" in rest
709
- refresh_flag = "--refresh" in rest
710
- status_flags = {"--json", "--small", "-s", "--refresh"}
711
- args = [a for a in rest if a not in status_flags]
712
- unknown_flags = [a for a in args if a.startswith("-")]
713
- if unknown_flags or (json_flag and small_flag):
665
+ parsed = _parse_flag_args(rest, {
666
+ "--json": {"key": "json", "type": "bool", "default": False},
667
+ "--small": {"key": "small", "type": "bool", "default": False},
668
+ "-s": {"key": "small", "type": "bool", "default": False},
669
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
670
+ }, STATUS_USAGE, positionals_key="args", max_positionals=1)
671
+ if parsed["json"] and parsed["small"]:
714
672
  raise CdxError(STATUS_USAGE)
715
- if len(args) > 1 or (len(args) == 1 and small_flag):
673
+ args = parsed["args"]
674
+ if len(args) == 1 and parsed["small"]:
716
675
  raise CdxError(STATUS_USAGE)
717
676
 
718
677
  refresh_result = _refresh_claude_sessions(
719
678
  ctx["service"],
720
679
  ctx.get("refresh_fn"),
721
680
  target_names=args if len(args) == 1 else None,
722
- force=refresh_flag,
681
+ force=parsed["refresh"],
723
682
  )
724
683
  refresh_errors = [
725
684
  {
@@ -739,17 +698,17 @@ def handle_status(rest, ctx):
739
698
 
740
699
  rows = ctx["service"]["get_status_rows"]()
741
700
  if len(args) == 0:
742
- if json_flag:
701
+ if parsed["json"]:
743
702
  _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
744
703
  return 0
745
- ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
704
+ ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
746
705
  _write_refresh_warnings(refresh_errors, ctx)
747
706
  return 0
748
707
 
749
708
  row = next((r for r in rows if r["session_name"] == args[0]), None)
750
709
  if not row:
751
710
  raise CdxError(f"Unknown session: {args[0]}")
752
- if json_flag:
711
+ if parsed["json"]:
753
712
  _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
754
713
  return 0
755
714
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
@@ -1002,7 +961,7 @@ def handle_launch(command, ctx, initial_prompt=None):
1002
961
  if update_notice.get("url"):
1003
962
  text = f"{text} {update_notice['url']}"
1004
963
  ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
1005
- if session["provider"] == "codex":
964
+ if session["provider"] == PROVIDER_CODEX:
1006
965
  if not json_flag:
1007
966
  ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
1008
967
  _run_interactive_provider_command(
@@ -106,10 +106,10 @@ def _write_json_line(process, payload):
106
106
  process.stdin.flush()
107
107
 
108
108
 
109
- def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
109
+ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
110
110
  auth_home = session.get("authHome")
111
111
  if not auth_home:
112
- return None
112
+ return {"ok": False, "reason": "missing_auth_home", "status": None}
113
113
 
114
114
  env = os.environ.copy()
115
115
  env["CODEX_HOME"] = auth_home
@@ -139,7 +139,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
139
139
  })
140
140
  initialized = _read_response(output, 1, timeout)
141
141
  if not initialized or initialized.get("error"):
142
- return None
142
+ return {
143
+ "ok": False,
144
+ "reason": "initialize_failed",
145
+ "status": None,
146
+ "response": initialized,
147
+ }
143
148
 
144
149
  _write_json_line(process, {
145
150
  "jsonrpc": "2.0",
@@ -149,13 +154,28 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
149
154
  })
150
155
  response = _read_response(output, 2, timeout)
151
156
  if not response or response.get("error"):
152
- return None
157
+ return {
158
+ "ok": False,
159
+ "reason": "rate_limits_read_failed",
160
+ "status": None,
161
+ "response": response,
162
+ }
153
163
  result = response.get("result") or {}
154
164
  by_limit = result.get("rateLimitsByLimitId") or {}
155
165
  snapshot = by_limit.get("codex") or result.get("rateLimits")
156
- return normalize_codex_rate_limit_snapshot(snapshot)
157
- except (OSError, ValueError, BrokenPipeError):
158
- return None
166
+ status = normalize_codex_rate_limit_snapshot(snapshot)
167
+ if not status:
168
+ return {
169
+ "ok": False,
170
+ "reason": "missing_rate_limits",
171
+ "status": None,
172
+ "response": response,
173
+ }
174
+ return {"ok": True, "reason": None, "status": status, "response": response}
175
+ except FileNotFoundError as error:
176
+ return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
177
+ except (OSError, ValueError, BrokenPipeError) as error:
178
+ return {"ok": False, "reason": "probe_failed", "status": None, "error": str(error)}
159
179
  finally:
160
180
  if process is not None:
161
181
  try:
@@ -166,3 +186,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
166
186
  process.kill()
167
187
  except OSError:
168
188
  pass
189
+
190
+
191
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
192
+ diagnostic = fetch_codex_rate_limit_diagnostic(
193
+ session,
194
+ timeout=timeout,
195
+ popen_factory=popen_factory,
196
+ )
197
+ return diagnostic.get("status") if diagnostic.get("ok") else None
package/src/config.py CHANGED
@@ -1,6 +1,10 @@
1
1
  import os
2
2
  from pathlib import Path
3
3
 
4
+ PROVIDER_CODEX = "codex"
5
+ PROVIDER_CLAUDE = "claude"
6
+ PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE)
7
+
4
8
 
5
9
  def get_cdx_home(env=None):
6
10
  if env is None:
package/src/notify.py CHANGED
@@ -29,6 +29,13 @@ def parse_notify_args(args):
29
29
  raise CdxError("--poll must be a number of seconds") from error
30
30
  i += 2
31
31
  continue
32
+ if arg.startswith("--poll="):
33
+ try:
34
+ poll = max(1, int(arg.split("=", 1)[1]))
35
+ except ValueError as error:
36
+ raise CdxError("--poll must be a number of seconds") from error
37
+ i += 1
38
+ continue
32
39
  if arg.startswith("-"):
33
40
  raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
34
41
  cleaned.append(arg)
@@ -121,7 +128,24 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
121
128
  _send_windows_notification(title, message, spawn_sync, env)
122
129
  elif shutil_which("osascript", env):
123
130
  script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
124
- spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
131
+ _run_notification_command(
132
+ ["osascript", "-e", script],
133
+ spawn_sync,
134
+ env,
135
+ )
136
+ elif shutil_which("notify-send", env):
137
+ _run_notification_command(
138
+ ["notify-send", str(title), str(message)],
139
+ spawn_sync,
140
+ env,
141
+ )
142
+
143
+
144
+ def _run_notification_command(argv, spawn_sync, env):
145
+ try:
146
+ spawn_sync(argv, env=env, capture_output=True, text=True, timeout=5)
147
+ except (FileNotFoundError, OSError):
148
+ pass
125
149
 
126
150
 
127
151
  def _send_windows_notification(title, message, spawn_sync, env):
@@ -7,6 +7,7 @@ import subprocess
7
7
  import sys
8
8
  from datetime import datetime, timezone
9
9
 
10
+ from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
10
11
  from .errors import CdxError
11
12
 
12
13
 
@@ -99,10 +100,15 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
99
100
 
100
101
 
101
102
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
103
+ if initial_prompt is not None:
104
+ if not isinstance(initial_prompt, str):
105
+ raise CdxError("initial_prompt must be a string.")
106
+ if len(initial_prompt) > 32768:
107
+ raise CdxError("initial_prompt exceeds maximum allowed length.")
102
108
  cwd = cwd or os.getcwd()
103
109
  env_override = env_override or {}
104
110
  env = {**os.environ, **env_override}
105
- if session["provider"] == "claude":
111
+ if session["provider"] == PROVIDER_CLAUDE:
106
112
  args = ["--name", session["name"]]
107
113
  if initial_prompt:
108
114
  args.append(initial_prompt)
@@ -130,7 +136,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
130
136
 
131
137
  def _build_login_status_spec(session, env_override=None):
132
138
  env = {**os.environ, **(env_override or {})}
133
- if session["provider"] == "claude":
139
+ if session["provider"] == PROVIDER_CLAUDE:
134
140
  env.update(_home_env_overrides(_get_auth_home(session)))
135
141
 
136
142
  def parser(output):
@@ -155,7 +161,7 @@ def _build_login_status_spec(session, env_override=None):
155
161
  def _build_auth_action_spec(session, action, cwd=None, env_override=None):
156
162
  cwd = cwd or os.getcwd()
157
163
  env = {**os.environ, **(env_override or {})}
158
- if session["provider"] == "claude":
164
+ if session["provider"] == PROVIDER_CLAUDE:
159
165
  env.update(_home_env_overrides(_get_auth_home(session)))
160
166
  return {"command": "claude", "args": ["auth", action],
161
167
  "options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
@@ -184,7 +190,7 @@ def _resolve_command(command, env=None):
184
190
  def _probe_provider_auth(session, spawn_sync=None, env_override=None):
185
191
  spawn_sync = spawn_sync or subprocess.run
186
192
  spec = _build_login_status_spec(session, env_override)
187
- if session.get("provider") == "codex":
193
+ if session.get("provider") == PROVIDER_CODEX:
188
194
  auth_path = os.path.join(_get_auth_home(session), "auth.json")
189
195
  if os.path.isfile(auth_path):
190
196
  return True
@@ -250,11 +256,12 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
250
256
  spec = _fallback_launch_spec_or_raise(spec, error)
251
257
  child = start_child(spec)
252
258
 
253
- forwarded_signal = [None]
259
+ forwarded_signal = None
254
260
  handlers = []
255
261
 
256
262
  def forward(sig, _frame=None):
257
- forwarded_signal[0] = sig
263
+ nonlocal forwarded_signal
264
+ forwarded_signal = sig
258
265
  try:
259
266
  if hasattr(child, "send_signal"):
260
267
  child.send_signal(sig)
@@ -283,7 +290,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
283
290
 
284
291
  try:
285
292
  child.wait()
286
- if forwarded_signal[0] is None and child.returncode != 0 and _should_retry_without_transcript(spec):
293
+ if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
287
294
  spec = _fallback_launch_spec_or_raise(spec)
288
295
  child = start_child(spec)
289
296
  child.wait()
@@ -301,10 +308,10 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
301
308
  except (OSError, ValueError):
302
309
  pass
303
310
 
304
- if forwarded_signal[0] is not None:
311
+ if forwarded_signal is not None:
305
312
  raise CdxError(
306
- f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
307
- _signal_exit_code(forwarded_signal[0]),
313
+ f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
314
+ _signal_exit_code(forwarded_signal),
308
315
  )
309
316
  if child.returncode != 0:
310
317
  raise CdxError(
@@ -8,14 +8,14 @@ from datetime import datetime, timezone
8
8
  from urllib.parse import quote
9
9
 
10
10
  from .backup_bundle import decode_bundle, encode_bundle
11
- from .config import get_cdx_home
11
+ from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
12
12
  from .codex_usage import fetch_codex_rate_limits
13
13
  from .errors import CdxError
14
14
  from .session_store import create_session_store
15
15
  from .status_source import find_latest_status_artifact
16
16
 
17
- DEFAULT_PROVIDER = "codex"
18
- ALLOWED_PROVIDERS = {"codex", "claude"}
17
+ DEFAULT_PROVIDER = PROVIDER_CODEX
18
+ ALLOWED_PROVIDERS = set(PROVIDERS)
19
19
  MAX_SESSION_NAME_LENGTH = 64
20
20
  RESERVED_SESSION_NAMES = {
21
21
  "add",
@@ -81,8 +81,15 @@ def _local_now_iso():
81
81
 
82
82
 
83
83
  def _safe_relpath(path):
84
- normalized = str(path or "").replace("\\", "/").strip("/")
85
- if not normalized or normalized.startswith("../") or "/../" in f"/{normalized}/":
84
+ normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
85
+ if (
86
+ not normalized
87
+ or normalized == "."
88
+ or normalized.startswith("/")
89
+ or (len(normalized) > 1 and normalized[1] == ":")
90
+ or normalized == ".."
91
+ or normalized.startswith("../")
92
+ ):
86
93
  raise CdxError("Bundle contains an unsafe file path.")
87
94
  return normalized
88
95
 
@@ -253,7 +260,7 @@ def create_session_service(options=None):
253
260
 
254
261
  def _get_session_auth_home(name, provider):
255
262
  root = _get_session_root(name)
256
- if provider == "claude":
263
+ if provider == PROVIDER_CLAUDE:
257
264
  return os.path.join(root, "claude-home")
258
265
  return root
259
266
 
@@ -326,7 +333,7 @@ def create_session_service(options=None):
326
333
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
327
334
  _ensure_private_dir(session_root)
328
335
  _ensure_private_dir(auth_home)
329
- if normalized_provider == "codex":
336
+ if normalized_provider == PROVIDER_CODEX:
330
337
  _seed_codex_auth_from_global(auth_home, env=env)
331
338
  now = _local_now_iso()
332
339
  session = {
@@ -553,7 +560,7 @@ def create_session_service(options=None):
553
560
  source_root = session.get("authHome") or _get_session_auth_home(
554
561
  session["name"], session["provider"]
555
562
  )
556
- if session["provider"] == "codex" and codex_status_fetcher:
563
+ if session["provider"] == PROVIDER_CODEX and codex_status_fetcher:
557
564
  live_status = codex_status_fetcher({**session, "authHome": source_root})
558
565
  if live_status:
559
566
  record_status(session["name"], live_status)
@@ -561,7 +568,7 @@ def create_session_service(options=None):
561
568
 
562
569
  expected_account_email = (
563
570
  _read_expected_account_email(source_root)
564
- if session["provider"] == "codex"
571
+ if session["provider"] == PROVIDER_CODEX
565
572
  else None
566
573
  )
567
574
  artifact = find_latest_status_artifact(
@@ -570,7 +577,7 @@ def create_session_service(options=None):
570
577
  expected_account_email=expected_account_email,
571
578
  )
572
579
  if (
573
- session["provider"] == "codex"
580
+ session["provider"] == PROVIDER_CODEX
574
581
  and not artifact
575
582
  and os.path.abspath(base_dir) == os.path.abspath(get_cdx_home(env))
576
583
  ):
@@ -3,6 +3,7 @@ import os
3
3
  import re
4
4
  from datetime import datetime, timezone
5
5
 
6
+ from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
6
7
 
7
8
  _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
8
9
  _ANSI_TERMINAL_CONTROL = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
@@ -14,6 +15,23 @@ MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
14
15
  MAX_STATUS_READ_BYTES = 512 * 1024
15
16
  MAX_STATUS_CANDIDATE_FILES = 64
16
17
 
18
+ _KEY_VALUE_PATTERNS = [
19
+ ("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
20
+ ("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
21
+ ("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
22
+ ("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
23
+ ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
24
+ ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
25
+ ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
26
+ ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
27
+ ("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
28
+ ("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
29
+ ("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
30
+ ("remaining_5h_pct", re.compile(r"remaining\s+5h\s*[:=]\s*(\d{1,3})%", re.I)),
31
+ ("remaining_week_pct", re.compile(r"week(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
32
+ ("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
33
+ ]
34
+
17
35
 
18
36
  def _strip_ansi(text):
19
37
  return _ANSI_ESCAPE.sub("", str(text or ""))
@@ -185,7 +203,7 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
185
203
  index = max(index + 1, end_index)
186
204
  return blocks
187
205
 
188
- if provider != "codex":
206
+ if provider != PROVIDER_CODEX:
189
207
  for block in collect_blocks(
190
208
  re.compile(r"^\s*(?:[│|]\s*)?Current session\b", re.I),
191
209
  [re.compile(p, re.I) for p in [
@@ -195,7 +213,7 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
195
213
  ):
196
214
  items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
197
215
 
198
- if provider != "claude":
216
+ if provider != PROVIDER_CLAUDE:
199
217
  for block in collect_blocks(
200
218
  re.compile(r"^\s*(?:[│|]\s*)?5h\s+limit\b", re.I),
201
219
  [re.compile(p, re.I) for p in [
@@ -412,23 +430,7 @@ def extract_named_statuses_from_text(text):
412
430
  lines = [l.strip() for l in normalized.split("\n") if l.strip()]
413
431
  result = {}
414
432
 
415
- key_value_patterns = [
416
- ("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
417
- ("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
418
- ("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
419
- ("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
420
- ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
421
- ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
422
- ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
423
- ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
424
- ("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
425
- ("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
426
- ("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
427
- ("remaining_5h_pct", re.compile(r"remaining\s+5h\s*[:=]\s*(\d{1,3})%", re.I)),
428
- ("remaining_week_pct", re.compile(r"week(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
429
- ("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
430
- ]
431
- for field, pattern in key_value_patterns:
433
+ for field, pattern in _KEY_VALUE_PATTERNS:
432
434
  if field not in result:
433
435
  m = pattern.search(normalized)
434
436
  if m:
@@ -620,7 +622,7 @@ def find_latest_status_artifact(root_dir, provider=None, expected_account_email=
620
622
 
621
623
  best = None
622
624
  for candidate in records:
623
- if provider == "codex" and not _account_matches_expected(
625
+ if provider == PROVIDER_CODEX and not _account_matches_expected(
624
626
  candidate["text"], expected_account_email
625
627
  ):
626
628
  continue