cdx-manager 0.3.3 → 0.4.0

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.
@@ -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,36 +19,62 @@ 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
 
39
+ def _json_success(action, message, warnings=None, **extra):
40
+ payload = {
41
+ "schema_version": API_SCHEMA_VERSION,
42
+ "ok": True,
43
+ "action": action,
44
+ "message": message,
45
+ "warnings": warnings or [],
46
+ }
47
+ payload.update(extra)
48
+ return payload
49
+
50
+
51
+ def _write_json(ctx, payload):
52
+ ctx["out"](f"{json.dumps(payload, indent=2)}\n")
53
+
54
+
55
+ def _parse_json_flag(args):
56
+ json_flag = "--json" in args
57
+ cleaned = [arg for arg in args if arg != "--json"]
58
+ return json_flag, cleaned
59
+
60
+
35
61
  def _parse_add_args(args):
36
62
  if len(args) == 1:
37
63
  return {"provider": "codex", "name": args[0]}
38
64
  if len(args) == 2:
39
65
  return {"provider": args[0], "name": args[1]}
40
- raise CdxError("Usage: cdx add [provider] <name>")
66
+ raise CdxError("Usage: cdx add [provider] <name> [--json]")
41
67
 
42
68
 
43
69
  def _parse_copy_args(args):
44
70
  if len(args) != 2:
45
- raise CdxError("Usage: cdx cp <source> <dest>")
71
+ raise CdxError("Usage: cdx cp <source> <dest> [--json]")
46
72
  return {"source": args[0], "dest": args[1]}
47
73
 
48
74
 
49
75
  def _parse_rename_args(args):
50
76
  if len(args) != 2:
51
- raise CdxError("Usage: cdx ren <source> <dest>")
77
+ raise CdxError("Usage: cdx ren <source> <dest> [--json]")
52
78
  return {"source": args[0], "dest": args[1]}
53
79
 
54
80
 
@@ -57,10 +83,146 @@ def _parse_remove_args(args):
57
83
  names = [a for a in args if a != "--force"]
58
84
  unknown = [a for a in args if a.startswith("-") and a != "--force"]
59
85
  if unknown or len(names) != 1 or len(args) > 2:
60
- raise CdxError("Usage: cdx rmv <name> [--force]")
86
+ raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
61
87
  return {"name": names[0], "force": force}
62
88
 
63
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
+
64
226
  def _confirm_removal(name):
65
227
  answer = input(f"Remove session {name}? [y/N] ")
66
228
  return answer.strip().lower() in ("y", "yes")
@@ -78,10 +240,10 @@ def _resolve_confirmation(confirm_fn, name):
78
240
 
79
241
 
80
242
  def handle_add(rest, ctx):
81
- parsed = _parse_add_args(rest)
243
+ json_flag, args = _parse_json_flag(rest)
244
+ parsed = _parse_add_args(args)
82
245
  session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
83
246
  message = f"Created session {parsed['name']} ({parsed['provider']})"
84
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
85
247
  _ensure_session_authentication(
86
248
  session,
87
249
  ctx["service"],
@@ -100,28 +262,50 @@ def handle_add(rest, ctx):
100
262
  "lastAuthenticatedAt": now,
101
263
  "lastLoggedOutAt": auth.get("lastLoggedOutAt"),
102
264
  })
265
+ if json_flag:
266
+ _write_json(ctx, _json_success(
267
+ "add",
268
+ message,
269
+ session=ctx["service"]["get_session"](parsed["name"]),
270
+ ))
271
+ return 0
272
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
103
273
  return 0
104
274
 
105
275
 
106
276
  def handle_copy(rest, ctx):
107
- parsed = _parse_copy_args(rest)
277
+ json_flag, args = _parse_json_flag(rest)
278
+ parsed = _parse_copy_args(args)
108
279
  result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
109
280
  overwritten = " (overwritten)" if result["overwritten"] else ""
110
281
  message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
282
+ if json_flag:
283
+ _write_json(ctx, _json_success(
284
+ "copy",
285
+ message,
286
+ session=result["session"],
287
+ overwritten=result["overwritten"],
288
+ ))
289
+ return 0
111
290
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
112
291
  return 0
113
292
 
114
293
 
115
294
  def handle_rename(rest, ctx):
116
- parsed = _parse_rename_args(rest)
117
- ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
295
+ json_flag, args = _parse_json_flag(rest)
296
+ parsed = _parse_rename_args(args)
297
+ session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
118
298
  message = f"Renamed session {parsed['source']} to {parsed['dest']}"
299
+ if json_flag:
300
+ _write_json(ctx, _json_success("rename", message, session=session))
301
+ return 0
119
302
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
120
303
  return 0
121
304
 
122
305
 
123
306
  def handle_remove(rest, ctx):
124
- parsed = _parse_remove_args(rest)
307
+ json_flag, args = _parse_json_flag(rest)
308
+ parsed = _parse_remove_args(args)
125
309
  if not parsed["force"]:
126
310
  confirm_fn = ctx["options"].get("confirmRemove")
127
311
  if confirm_fn:
@@ -131,30 +315,47 @@ def handle_remove(rest, ctx):
131
315
  else:
132
316
  confirmed = _confirm_removal(parsed["name"])
133
317
  if not confirmed:
318
+ if json_flag:
319
+ _write_json(ctx, _json_success("remove", "Cancelled.", cancelled=True, session=None))
320
+ return 0
134
321
  ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
135
322
  return 0
136
- ctx["service"]["remove_session"](parsed["name"])
323
+ removed = ctx["service"]["remove_session"](parsed["name"])
137
324
  message = f"Removed session {parsed['name']}"
325
+ if json_flag:
326
+ _write_json(ctx, _json_success("remove", message, session=removed, cancelled=False))
327
+ return 0
138
328
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
139
329
  return 0
140
330
 
141
331
 
142
332
  def handle_clean(rest, ctx):
333
+ json_flag, args = _parse_json_flag(rest)
143
334
  service = ctx["service"]
144
- if len(rest) == 0:
335
+ if len(args) == 0:
145
336
  targets = service["list_sessions"]()
146
- elif len(rest) == 1:
147
- session = service["get_session"](rest[0])
337
+ elif len(args) == 1:
338
+ session = service["get_session"](args[0])
148
339
  if not session:
149
- raise CdxError(f"Unknown session: {rest[0]}")
340
+ raise CdxError(f"Unknown session: {args[0]}")
150
341
  targets = [session]
151
342
  else:
152
- raise CdxError("Usage: cdx clean [name]")
343
+ raise CdxError("Usage: cdx clean [name] [--json]")
153
344
 
345
+ cleaned_sessions = []
154
346
  for session in targets:
155
347
  log_paths = _list_launch_transcript_paths(session)
156
348
  if not log_paths:
157
349
  message = f"{session['name']}: no log found"
350
+ cleaned_sessions.append({
351
+ "session_name": session["name"],
352
+ "cleared": False,
353
+ "files_cleared": 0,
354
+ "freed_kb": 0,
355
+ "message": message,
356
+ })
357
+ if json_flag:
358
+ continue
158
359
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
159
360
  continue
160
361
  total_size = 0
@@ -171,10 +372,30 @@ def handle_clean(rest, ctx):
171
372
  f"Cleared {session['name']} logs ({cleared} file"
172
373
  f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
173
374
  )
375
+ cleaned_sessions.append({
376
+ "session_name": session["name"],
377
+ "cleared": True,
378
+ "files_cleared": cleared,
379
+ "freed_kb": round(total_size / 1024),
380
+ "message": message,
381
+ })
382
+ if json_flag:
383
+ continue
174
384
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
175
385
  else:
176
386
  message = f"{session['name']}: no log found"
387
+ cleaned_sessions.append({
388
+ "session_name": session["name"],
389
+ "cleared": False,
390
+ "files_cleared": 0,
391
+ "freed_kb": 0,
392
+ "message": message,
393
+ })
394
+ if json_flag:
395
+ continue
177
396
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
397
+ if json_flag:
398
+ _write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
178
399
  return 0
179
400
 
180
401
 
@@ -189,7 +410,7 @@ def handle_doctor(rest, ctx):
189
410
  env=ctx.get("env"),
190
411
  )
191
412
  if json_flag:
192
- ctx["out"](f"{health_json(report)}\n")
413
+ _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
193
414
  else:
194
415
  ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
195
416
  return 0
@@ -211,7 +432,7 @@ def handle_repair(rest, ctx):
211
432
  force=force,
212
433
  )
213
434
  if json_flag:
214
- ctx["out"](f"{repair_json(report)}\n")
435
+ _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
215
436
  else:
216
437
  ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
217
438
  if dry_run:
@@ -238,7 +459,7 @@ def handle_notify(rest, ctx):
238
459
  now_fn=ctx["options"].get("now"),
239
460
  )
240
461
  if parsed["json"]:
241
- ctx["out"](f"{notify_json(event)}\n")
462
+ _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
242
463
  else:
243
464
  ctx["out"](f"{format_notify_event(event)}\n")
244
465
  return 0
@@ -269,12 +490,19 @@ def handle_status(rest, ctx):
269
490
  }
270
491
  for item in refresh_result.get("errors", [])
271
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
+ ]
272
501
 
273
502
  rows = ctx["service"]["get_status_rows"]()
274
503
  if len(args) == 0:
275
504
  if json_flag:
276
- ctx["out"](f"{json.dumps(rows, indent=2)}\n")
277
- _write_refresh_warnings(refresh_errors, ctx, stream="err")
505
+ _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
278
506
  return 0
279
507
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
280
508
  _write_refresh_warnings(refresh_errors, ctx)
@@ -284,8 +512,7 @@ def handle_status(rest, ctx):
284
512
  if not row:
285
513
  raise CdxError(f"Unknown session: {args[0]}")
286
514
  if json_flag:
287
- ctx["out"](f"{json.dumps(row, indent=2)}\n")
288
- _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))
289
516
  return 0
290
517
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
291
518
  _write_refresh_warnings(refresh_errors, ctx)
@@ -300,14 +527,83 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
300
527
  write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
301
528
 
302
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
+
303
598
  def handle_login(rest, ctx):
304
- if len(rest) != 1:
305
- raise CdxError("Usage: cdx login <name>")
599
+ json_flag, args = _parse_json_flag(rest)
600
+ if len(args) != 1:
601
+ raise CdxError("Usage: cdx login <name> [--json]")
306
602
  if not ctx["stdin_is_tty"]:
307
603
  raise CdxError("Login requires an interactive terminal.")
308
- session = ctx["service"]["get_session"](rest[0])
604
+ session = ctx["service"]["get_session"](args[0])
309
605
  if not session:
310
- raise CdxError(f"Unknown session: {rest[0]}")
606
+ raise CdxError(f"Unknown session: {args[0]}")
311
607
  _run_interactive_provider_command(
312
608
  session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
313
609
  signal_emitter=ctx.get("signal_emitter")
@@ -317,36 +613,53 @@ def handle_login(rest, ctx):
317
613
  signal_emitter=ctx.get("signal_emitter")
318
614
  )
319
615
  now = _local_now_iso()
320
- ctx["service"]["update_auth_state"](rest[0], lambda auth: {
616
+ ctx["service"]["update_auth_state"](args[0], lambda auth: {
321
617
  **auth, "status": "authenticated",
322
618
  "lastCheckedAt": now, "lastAuthenticatedAt": now,
323
619
  })
324
620
  message = f"Reauthenticated session {session['name']} ({session['provider']})"
621
+ if json_flag:
622
+ _write_json(ctx, _json_success("login", message, session=ctx["service"]["get_session"](session["name"])))
623
+ return 0
325
624
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
326
625
  return 0
327
626
 
328
627
 
329
628
  def handle_logout(rest, ctx):
330
- if len(rest) != 1:
331
- raise CdxError("Usage: cdx logout <name>")
332
- session = ctx["service"]["get_session"](rest[0])
629
+ json_flag, args = _parse_json_flag(rest)
630
+ if len(args) != 1:
631
+ raise CdxError("Usage: cdx logout <name> [--json]")
632
+ session = ctx["service"]["get_session"](args[0])
333
633
  if not session:
334
- raise CdxError(f"Unknown session: {rest[0]}")
634
+ raise CdxError(f"Unknown session: {args[0]}")
335
635
  _run_interactive_provider_command(
336
636
  session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
337
637
  signal_emitter=ctx.get("signal_emitter")
338
638
  )
339
639
  now = _local_now_iso()
340
- ctx["service"]["update_auth_state"](rest[0], lambda auth: {
640
+ ctx["service"]["update_auth_state"](args[0], lambda auth: {
341
641
  **auth, "status": "logged_out",
342
642
  "lastCheckedAt": now, "lastLoggedOutAt": now,
343
643
  })
344
644
  message = f"Logged out session {session['name']} ({session['provider']})"
645
+ if json_flag:
646
+ _write_json(ctx, _json_success("logout", message, session=ctx["service"]["get_session"](session["name"])))
647
+ return 0
345
648
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
346
649
  return 0
347
650
 
348
651
 
349
652
  def handle_launch(command, ctx):
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
+ })
350
663
  session = ctx["service"]["launch_session"](command)
351
664
  _ensure_session_authentication(
352
665
  session,
@@ -359,11 +672,20 @@ def handle_launch(command, ctx):
359
672
  signal_emitter=ctx.get("signal_emitter"),
360
673
  )
361
674
  message = f"Launching {session['provider']} session {session['name']}"
362
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
675
+ if not json_flag:
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")
363
682
  if session["provider"] == "codex":
364
- ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
683
+ if not json_flag:
684
+ ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
365
685
  _run_interactive_provider_command(
366
686
  session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
367
687
  signal_emitter=ctx.get("signal_emitter")
368
688
  )
689
+ if json_flag:
690
+ _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
369
691
  return 0
package/src/health.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import os
3
3
  import shutil
4
+ import sys
4
5
  import tempfile
5
6
  from urllib.parse import quote, unquote
6
7
 
@@ -43,7 +44,7 @@ def collect_health_report(service, base_dir, env=None):
43
44
  issues.append(_issue(
44
45
  "OK" if script_path else "WARN",
45
46
  "script_cli",
46
- f"{script_bin} CLI {'found' if script_path else 'not found; Codex will launch without transcript fallback'}",
47
+ _script_cli_message(script_bin, bool(script_path)),
47
48
  script_path,
48
49
  ))
49
50
 
@@ -74,6 +75,17 @@ def _check_cdx_home(base_dir):
74
75
  return _issue("FAIL", "cdx_home_writable", "CDX_HOME is not writable", f"{base_dir}: {error}")
75
76
 
76
77
 
78
+ def _script_cli_message(script_bin, is_available):
79
+ if is_available:
80
+ return f"{script_bin} CLI found"
81
+ if sys.platform == "win32":
82
+ return (
83
+ f"{script_bin} CLI not found; Codex will launch without transcript capture "
84
+ f"(expected on many Windows setups)"
85
+ )
86
+ return f"{script_bin} CLI not found; Codex will launch without transcript fallback"
87
+
88
+
77
89
  def _collect_profile_issues(base_dir, session_names):
78
90
  profile_dir = _profiles_dir(base_dir)
79
91
  if not os.path.isdir(profile_dir):
@@ -187,6 +187,15 @@ def _signal_exit_code(sig):
187
187
  return mapping.get(sig, 1)
188
188
 
189
189
 
190
+ def _signal_name(sig):
191
+ if hasattr(sig, "name"):
192
+ return sig.name
193
+ try:
194
+ return signal.Signals(sig).name
195
+ except (TypeError, ValueError):
196
+ return str(sig)
197
+
198
+
190
199
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
191
200
  env_override=None, signal_emitter=None):
192
201
  spawn = spawn or subprocess.Popen
@@ -260,7 +269,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
260
269
 
261
270
  if forwarded_signal[0] is not None:
262
271
  raise CdxError(
263
- f"{spec['label']} interrupted by {forwarded_signal[0].name} for session {session['name']}",
272
+ f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
264
273
  _signal_exit_code(forwarded_signal[0]),
265
274
  )
266
275
  if child.returncode != 0: