cdx-manager 0.3.4 → 0.4.1

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.
@@ -0,0 +1,134 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+
8
+ from .errors import CdxError
9
+
10
+
11
+ BUNDLE_SCHEMA_VERSION = 1
12
+ _SALT_BYTES = 16
13
+ _NONCE_BYTES = 16
14
+ _SCRYPT_N = 2 ** 14
15
+ _SCRYPT_R = 8
16
+ _SCRYPT_P = 1
17
+ _PBKDF2_ITERATIONS = 200000
18
+
19
+
20
+ def _now_iso():
21
+ return datetime.now(timezone.utc).astimezone().isoformat()
22
+
23
+
24
+ def _b64_encode(data):
25
+ return base64.b64encode(data).decode("ascii")
26
+
27
+
28
+ def _b64_decode(data):
29
+ try:
30
+ return base64.b64decode(data.encode("ascii"))
31
+ except (AttributeError, ValueError, UnicodeEncodeError) as error:
32
+ raise CdxError("Bundle contains invalid base64 data.") from error
33
+
34
+
35
+ def read_bundle_meta(data):
36
+ try:
37
+ wrapper = json.loads(data.decode("utf-8"))
38
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
39
+ raise CdxError("Invalid bundle format.") from error
40
+
41
+ if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
42
+ raise CdxError("Unsupported bundle schema version.")
43
+ return wrapper
44
+
45
+
46
+ def _derive_keys(passphrase, salt):
47
+ if not passphrase:
48
+ raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
49
+ if isinstance(passphrase, str):
50
+ passphrase = passphrase.encode("utf-8")
51
+ if hasattr(hashlib, "scrypt"):
52
+ key_material = hashlib.scrypt(
53
+ passphrase,
54
+ salt=salt,
55
+ n=_SCRYPT_N,
56
+ r=_SCRYPT_R,
57
+ p=_SCRYPT_P,
58
+ dklen=64,
59
+ )
60
+ else:
61
+ key_material = hashlib.pbkdf2_hmac(
62
+ "sha256",
63
+ passphrase,
64
+ salt,
65
+ _PBKDF2_ITERATIONS,
66
+ dklen=64,
67
+ )
68
+ return key_material[:32], key_material[32:]
69
+
70
+
71
+ def _xor_keystream(data, key, nonce):
72
+ output = bytearray()
73
+ counter = 0
74
+ while len(output) < len(data):
75
+ block = hashlib.sha256(key + nonce + counter.to_bytes(8, "big")).digest()
76
+ output.extend(block)
77
+ counter += 1
78
+ return bytes(a ^ b for a, b in zip(data, output[:len(data)]))
79
+
80
+
81
+ def encode_bundle(payload, include_auth=False, passphrase=None):
82
+ payload_bytes = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
83
+ wrapper = {
84
+ "schema_version": BUNDLE_SCHEMA_VERSION,
85
+ "bundle_version": 1,
86
+ "created_at": _now_iso(),
87
+ "include_auth": bool(include_auth),
88
+ "encrypted": bool(include_auth),
89
+ "session_names": [item["name"] for item in payload.get("sessions", [])],
90
+ }
91
+ if include_auth:
92
+ salt = os.urandom(_SALT_BYTES)
93
+ nonce = os.urandom(_NONCE_BYTES)
94
+ enc_key, mac_key = _derive_keys(passphrase, salt)
95
+ ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
96
+ mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
97
+ wrapper.update({
98
+ "salt": _b64_encode(salt),
99
+ "nonce": _b64_encode(nonce),
100
+ "hmac_sha256": _b64_encode(mac),
101
+ "payload": _b64_encode(ciphertext),
102
+ })
103
+ else:
104
+ wrapper["payload"] = _b64_encode(payload_bytes)
105
+ return json.dumps(wrapper, indent=2).encode("utf-8")
106
+
107
+
108
+ def decode_bundle(data, passphrase=None):
109
+ wrapper = read_bundle_meta(data)
110
+
111
+ encrypted = bool(wrapper.get("encrypted"))
112
+ payload_b64 = wrapper.get("payload")
113
+ if not isinstance(payload_b64, str):
114
+ raise CdxError("Bundle payload is missing.")
115
+
116
+ if encrypted:
117
+ salt = _b64_decode(wrapper.get("salt", ""))
118
+ nonce = _b64_decode(wrapper.get("nonce", ""))
119
+ expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
120
+ ciphertext = _b64_decode(payload_b64)
121
+ enc_key, mac_key = _derive_keys(passphrase, salt)
122
+ actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
123
+ if not hmac.compare_digest(actual_mac, expected_mac):
124
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.")
125
+ payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
126
+ else:
127
+ payload_bytes = _b64_decode(payload_b64)
128
+
129
+ try:
130
+ payload = json.loads(payload_bytes.decode("utf-8"))
131
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
132
+ raise CdxError("Bundle payload is corrupt.") from error
133
+
134
+ return {"meta": wrapper, "payload": payload}
package/src/cli.py CHANGED
@@ -5,11 +5,14 @@ import os
5
5
  import sys
6
6
 
7
7
  from .cli_commands import (
8
+ API_SCHEMA_VERSION,
8
9
  STATUS_USAGE,
9
10
  handle_add,
10
11
  handle_clean,
11
12
  handle_copy,
12
13
  handle_doctor,
14
+ handle_export,
15
+ handle_import,
13
16
  handle_launch,
14
17
  handle_login,
15
18
  handle_logout,
@@ -39,8 +42,9 @@ from .status_view import (
39
42
  _format_status_detail,
40
43
  _format_status_rows,
41
44
  )
45
+ from .update_check import check_for_update
42
46
 
43
- VERSION = "0.3.4"
47
+ VERSION = "0.4.1"
44
48
 
45
49
 
46
50
  # ---------------------------------------------------------------------------
@@ -64,6 +68,8 @@ def _print_help(use_color=False):
64
68
  f" {_style('cdx logout <name> [--json]', '36', use_color)}",
65
69
  f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
66
70
  f" {_style('cdx clean [name] [--json]', '36', use_color)}",
71
+ f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
72
+ f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
67
73
  f" {_style('cdx doctor [--json]', '36', use_color)}",
68
74
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
69
75
  f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
@@ -96,6 +102,7 @@ def format_json_error(error):
96
102
  elif "requires an interactive terminal" in message or "requires confirmation" in message:
97
103
  code = "interactive_terminal_required"
98
104
  return json.dumps({
105
+ "schema_version": API_SCHEMA_VERSION,
99
106
  "ok": False,
100
107
  "error": {
101
108
  "code": code,
@@ -105,6 +112,35 @@ def format_json_error(error):
105
112
  }, indent=2)
106
113
 
107
114
 
115
+ def _get_update_notice(service, env, options):
116
+ checker = options.get("checkForUpdate") or check_for_update
117
+ return checker(
118
+ service["base_dir"],
119
+ VERSION,
120
+ env=env,
121
+ now_fn=options.get("now"),
122
+ )
123
+
124
+
125
+ def _update_warning_payload(notice):
126
+ if not notice:
127
+ return []
128
+ message = f"Update available: cdx-manager {notice['latest_version']} (current {VERSION})"
129
+ return [{
130
+ "code": "update_available",
131
+ "message": message,
132
+ "latest_version": notice["latest_version"],
133
+ "url": notice.get("url"),
134
+ }]
135
+
136
+
137
+ def _update_warning_text(notice):
138
+ if not notice:
139
+ return None
140
+ suffix = f" {notice['url']}" if notice.get("url") else ""
141
+ return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
142
+
143
+
108
144
  # ---------------------------------------------------------------------------
109
145
  # main()
110
146
  # ---------------------------------------------------------------------------
@@ -145,11 +181,15 @@ def main(argv, options=None):
145
181
 
146
182
  if argv == ["--json"]:
147
183
  rows = service["format_list_rows"]()
148
- out(f"{json.dumps(_list_json_payload(rows), indent=2)}\n")
184
+ notice = _get_update_notice(service, env, options)
185
+ out(f"{json.dumps(_list_json_payload(rows, notice=notice), indent=2)}\n")
149
186
  return 0
150
187
 
151
188
  if not argv:
189
+ notice = _get_update_notice(service, env, options)
152
190
  out(f"{_format_sessions(service, use_color=use_color)}\n")
191
+ if notice:
192
+ out(f"{_style(_update_warning_text(notice), '33', use_color)}\n")
153
193
  return 0
154
194
 
155
195
  command, *rest = argv
@@ -165,6 +205,9 @@ def main(argv, options=None):
165
205
  "spawn": spawn,
166
206
  "spawn_sync": spawn_sync,
167
207
  "stdin_is_tty": stdin_is_tty,
208
+ "update_notice": _get_update_notice(service, env, options) if command not in (
209
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "notify", "status", "login", "logout", "export", "import", "help", "version"
210
+ ) else None,
168
211
  "use_color": use_color,
169
212
  }
170
213
 
@@ -183,6 +226,12 @@ def main(argv, options=None):
183
226
  if command == "clean":
184
227
  return handle_clean(rest, ctx)
185
228
 
229
+ if command == "export":
230
+ return handle_export(rest, ctx)
231
+
232
+ if command == "import":
233
+ return handle_import(rest, ctx)
234
+
186
235
  if command == "doctor":
187
236
  return handle_doctor(rest, ctx)
188
237
 
@@ -215,12 +264,13 @@ def main(argv, options=None):
215
264
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
216
265
 
217
266
 
218
- def _list_json_payload(rows):
267
+ def _list_json_payload(rows, notice=None):
219
268
  return {
269
+ "schema_version": API_SCHEMA_VERSION,
220
270
  "ok": True,
221
271
  "action": "list",
222
272
  "message": "Listed known sessions",
223
- "warnings": [],
273
+ "warnings": _update_warning_payload(notice),
224
274
  "sessions": rows,
225
275
  }
226
276
 
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import getpass
2
3
  import json
3
4
  import os
4
5
  from datetime import datetime
@@ -6,10 +7,9 @@ from datetime import datetime
6
7
  from .claude_refresh import _refresh_claude_sessions
7
8
  from .cli_render import _dim, _info, _success, _warn
8
9
  from .errors import CdxError
9
- from .health import collect_health_report, format_health_report, health_json
10
+ from .health import collect_health_report, format_health_report
10
11
  from .notify import (
11
12
  format_notify_event,
12
- notify_json,
13
13
  parse_notify_args,
14
14
  send_desktop_notification,
15
15
  wait_for_notification_event,
@@ -19,25 +19,30 @@ from .provider_runtime import (
19
19
  _list_launch_transcript_paths,
20
20
  _run_interactive_provider_command,
21
21
  )
22
- from .repair import format_repair_report, repair_health, repair_json
22
+ from .repair import format_repair_report, repair_health
23
+ from .backup_bundle import read_bundle_meta
23
24
  from .status_view import _format_status_detail, _format_status_rows
24
25
 
25
26
 
26
27
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
27
28
  DOCTOR_USAGE = "Usage: cdx doctor [--json]"
28
29
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
30
+ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
31
+ IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
32
+ API_SCHEMA_VERSION = 1
29
33
 
30
34
 
31
35
  def _local_now_iso():
32
36
  return datetime.now().astimezone().isoformat()
33
37
 
34
38
 
35
- def _json_success(action, message, **extra):
39
+ def _json_success(action, message, warnings=None, **extra):
36
40
  payload = {
41
+ "schema_version": API_SCHEMA_VERSION,
37
42
  "ok": True,
38
43
  "action": action,
39
44
  "message": message,
40
- "warnings": [],
45
+ "warnings": warnings or [],
41
46
  }
42
47
  payload.update(extra)
43
48
  return payload
@@ -82,6 +87,142 @@ def _parse_remove_args(args):
82
87
  return {"name": names[0], "force": force}
83
88
 
84
89
 
90
+ def _read_option_value(args, index, usage):
91
+ if index + 1 >= len(args):
92
+ raise CdxError(usage)
93
+ return args[index + 1], index + 2
94
+
95
+
96
+ def _parse_session_names(value):
97
+ if value is None:
98
+ return None
99
+ names = [item.strip() for item in value.split(",") if item.strip()]
100
+ if not names:
101
+ raise CdxError("At least one session name is required in --sessions.")
102
+ return names
103
+
104
+
105
+ def _parse_export_args(args):
106
+ parsed = {
107
+ "file_path": None,
108
+ "include_auth": False,
109
+ "force": False,
110
+ "json": False,
111
+ "session_names": None,
112
+ "passphrase_env": None,
113
+ }
114
+ index = 0
115
+ while index < len(args):
116
+ arg = args[index]
117
+ if arg == "--include-auth":
118
+ parsed["include_auth"] = True
119
+ index += 1
120
+ continue
121
+ if arg == "--force":
122
+ parsed["force"] = True
123
+ index += 1
124
+ continue
125
+ if arg == "--json":
126
+ parsed["json"] = True
127
+ index += 1
128
+ continue
129
+ if arg == "--sessions":
130
+ value, index = _read_option_value(args, index, EXPORT_USAGE)
131
+ parsed["session_names"] = _parse_session_names(value)
132
+ continue
133
+ if arg.startswith("--sessions="):
134
+ parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
135
+ index += 1
136
+ continue
137
+ if arg == "--passphrase-env":
138
+ value, index = _read_option_value(args, index, EXPORT_USAGE)
139
+ parsed["passphrase_env"] = value
140
+ continue
141
+ if arg.startswith("--passphrase-env="):
142
+ parsed["passphrase_env"] = arg.split("=", 1)[1]
143
+ index += 1
144
+ continue
145
+ if arg.startswith("-"):
146
+ raise CdxError(EXPORT_USAGE)
147
+ if parsed["file_path"] is not None:
148
+ raise CdxError(EXPORT_USAGE)
149
+ parsed["file_path"] = arg
150
+ index += 1
151
+
152
+ if not parsed["file_path"]:
153
+ raise CdxError(EXPORT_USAGE)
154
+ if parsed["passphrase_env"] and not parsed["include_auth"]:
155
+ raise CdxError("--passphrase-env requires --include-auth for export.")
156
+ return parsed
157
+
158
+
159
+ def _parse_import_args(args):
160
+ parsed = {
161
+ "file_path": None,
162
+ "force": False,
163
+ "json": False,
164
+ "session_names": None,
165
+ "passphrase_env": None,
166
+ }
167
+ index = 0
168
+ while index < len(args):
169
+ arg = args[index]
170
+ if arg == "--force":
171
+ parsed["force"] = True
172
+ index += 1
173
+ continue
174
+ if arg == "--json":
175
+ parsed["json"] = True
176
+ index += 1
177
+ continue
178
+ if arg == "--sessions":
179
+ value, index = _read_option_value(args, index, IMPORT_USAGE)
180
+ parsed["session_names"] = _parse_session_names(value)
181
+ continue
182
+ if arg.startswith("--sessions="):
183
+ parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
184
+ index += 1
185
+ continue
186
+ if arg == "--passphrase-env":
187
+ value, index = _read_option_value(args, index, IMPORT_USAGE)
188
+ parsed["passphrase_env"] = value
189
+ continue
190
+ if arg.startswith("--passphrase-env="):
191
+ parsed["passphrase_env"] = arg.split("=", 1)[1]
192
+ index += 1
193
+ continue
194
+ if arg.startswith("-"):
195
+ raise CdxError(IMPORT_USAGE)
196
+ if parsed["file_path"] is not None:
197
+ raise CdxError(IMPORT_USAGE)
198
+ parsed["file_path"] = arg
199
+ index += 1
200
+
201
+ if not parsed["file_path"]:
202
+ raise CdxError(IMPORT_USAGE)
203
+ return parsed
204
+
205
+
206
+ def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
207
+ env = ctx.get("env", {})
208
+ if env_var:
209
+ passphrase = env.get(env_var)
210
+ if not passphrase:
211
+ raise CdxError(f"Environment variable {env_var} is empty or unset.")
212
+ return passphrase
213
+ if not ctx["stdin_is_tty"]:
214
+ raise CdxError("Encrypted bundle export/import requires an interactive terminal or --passphrase-env.")
215
+ getpass_fn = ctx["options"].get("getpass") or getpass.getpass
216
+ passphrase = getpass_fn(prompt)
217
+ if not passphrase:
218
+ raise CdxError("Bundle passphrase cannot be empty.")
219
+ if confirm:
220
+ confirmation = getpass_fn("Confirm bundle passphrase: ")
221
+ if passphrase != confirmation:
222
+ raise CdxError("Bundle passphrase confirmation does not match.")
223
+ return passphrase
224
+
225
+
85
226
  def _confirm_removal(name):
86
227
  answer = input(f"Remove session {name}? [y/N] ")
87
228
  return answer.strip().lower() in ("y", "yes")
@@ -269,7 +410,7 @@ def handle_doctor(rest, ctx):
269
410
  env=ctx.get("env"),
270
411
  )
271
412
  if json_flag:
272
- ctx["out"](f"{health_json(report)}\n")
413
+ _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
273
414
  else:
274
415
  ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
275
416
  return 0
@@ -291,7 +432,7 @@ def handle_repair(rest, ctx):
291
432
  force=force,
292
433
  )
293
434
  if json_flag:
294
- ctx["out"](f"{repair_json(report)}\n")
435
+ _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
295
436
  else:
296
437
  ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
297
438
  if dry_run:
@@ -318,7 +459,7 @@ def handle_notify(rest, ctx):
318
459
  now_fn=ctx["options"].get("now"),
319
460
  )
320
461
  if parsed["json"]:
321
- ctx["out"](f"{notify_json(event)}\n")
462
+ _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
322
463
  else:
323
464
  ctx["out"](f"{format_notify_event(event)}\n")
324
465
  return 0
@@ -349,12 +490,19 @@ def handle_status(rest, ctx):
349
490
  }
350
491
  for item in refresh_result.get("errors", [])
351
492
  ]
493
+ warnings = [
494
+ {
495
+ "code": "claude_refresh_failed",
496
+ "session": item.get("session") or "unknown",
497
+ "message": item.get("error") or "unknown error",
498
+ }
499
+ for item in refresh_errors
500
+ ]
352
501
 
353
502
  rows = ctx["service"]["get_status_rows"]()
354
503
  if len(args) == 0:
355
504
  if json_flag:
356
- ctx["out"](f"{json.dumps(rows, indent=2)}\n")
357
- _write_refresh_warnings(refresh_errors, ctx, stream="err")
505
+ _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
358
506
  return 0
359
507
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
360
508
  _write_refresh_warnings(refresh_errors, ctx)
@@ -364,8 +512,7 @@ def handle_status(rest, ctx):
364
512
  if not row:
365
513
  raise CdxError(f"Unknown session: {args[0]}")
366
514
  if json_flag:
367
- ctx["out"](f"{json.dumps(row, indent=2)}\n")
368
- _write_refresh_warnings(refresh_errors, ctx, stream="err")
515
+ _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
369
516
  return 0
370
517
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
371
518
  _write_refresh_warnings(refresh_errors, ctx)
@@ -380,6 +527,74 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
380
527
  write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
381
528
 
382
529
 
530
+ def handle_export(rest, ctx):
531
+ parsed = _parse_export_args(rest)
532
+ passphrase = None
533
+ if parsed["include_auth"]:
534
+ passphrase = _resolve_bundle_passphrase(
535
+ ctx,
536
+ parsed["passphrase_env"],
537
+ "Bundle passphrase: ",
538
+ confirm=True,
539
+ )
540
+ result = ctx["service"]["export_bundle"](
541
+ parsed["file_path"],
542
+ include_auth=parsed["include_auth"],
543
+ session_names=parsed["session_names"],
544
+ passphrase=passphrase,
545
+ force=parsed["force"],
546
+ )
547
+ session_count = len(result["session_names"])
548
+ auth_suffix = " with auth" if result["include_auth"] else ""
549
+ message = f"Exported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} to {result['path']}"
550
+ payload = _json_success(
551
+ "export",
552
+ message,
553
+ bundle=result,
554
+ )
555
+ if parsed["json"]:
556
+ _write_json(ctx, payload)
557
+ return 0
558
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
559
+ return 0
560
+
561
+
562
+ def handle_import(rest, ctx):
563
+ parsed = _parse_import_args(rest)
564
+ passphrase = None
565
+ try:
566
+ with open(parsed["file_path"], "rb") as handle:
567
+ meta = read_bundle_meta(handle.read())
568
+ except OSError as error:
569
+ raise CdxError(f"Bundle file not found: {parsed['file_path']}") from error
570
+ if meta.get("encrypted"):
571
+ passphrase = _resolve_bundle_passphrase(
572
+ ctx,
573
+ parsed["passphrase_env"],
574
+ "Bundle passphrase: ",
575
+ confirm=False,
576
+ )
577
+ result = ctx["service"]["import_bundle"](
578
+ parsed["file_path"],
579
+ passphrase=passphrase,
580
+ session_names=parsed["session_names"],
581
+ force=parsed["force"],
582
+ )
583
+ session_count = len(result["session_names"])
584
+ auth_suffix = " with auth" if result["include_auth"] else ""
585
+ message = f"Imported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} from {result['path']}"
586
+ payload = _json_success(
587
+ "import",
588
+ message,
589
+ bundle=result,
590
+ )
591
+ if parsed["json"]:
592
+ _write_json(ctx, payload)
593
+ return 0
594
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
595
+ return 0
596
+
597
+
383
598
  def handle_login(rest, ctx):
384
599
  json_flag, args = _parse_json_flag(rest)
385
600
  if len(args) != 1:
@@ -436,6 +651,15 @@ def handle_logout(rest, ctx):
436
651
 
437
652
  def handle_launch(command, ctx):
438
653
  json_flag = "--json" in ctx["options"].get("raw_args", [])
654
+ update_notice = ctx.get("update_notice")
655
+ warnings = []
656
+ if update_notice:
657
+ warnings.append({
658
+ "code": "update_available",
659
+ "message": f"Update available: cdx-manager {update_notice['latest_version']}",
660
+ "latest_version": update_notice["latest_version"],
661
+ "url": update_notice.get("url"),
662
+ })
439
663
  session = ctx["service"]["launch_session"](command)
440
664
  _ensure_session_authentication(
441
665
  session,
@@ -450,6 +674,11 @@ def handle_launch(command, ctx):
450
674
  message = f"Launching {session['provider']} session {session['name']}"
451
675
  if not json_flag:
452
676
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
677
+ if update_notice:
678
+ text = f"Update available: cdx-manager {update_notice['latest_version']} (current version installed may be older)."
679
+ if update_notice.get("url"):
680
+ text = f"{text} {update_notice['url']}"
681
+ ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
453
682
  if session["provider"] == "codex":
454
683
  if not json_flag:
455
684
  ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
@@ -458,5 +687,5 @@ def handle_launch(command, ctx):
458
687
  signal_emitter=ctx.get("signal_emitter")
459
688
  )
460
689
  if json_flag:
461
- _write_json(ctx, _json_success("launch", message, session=ctx["service"]["get_session"](session["name"])))
690
+ _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
462
691
  return 0