cdx-manager 0.5.6 → 0.6.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.
- package/README.md +66 -4
- package/changelogs/CHANGELOGS_0_5_7.md +45 -0
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +36 -6
- package/src/cli_commands.py +531 -17
- package/src/cli_render.py +27 -12
- package/src/notify.py +229 -6
- package/src/provider_runtime.py +84 -5
- package/src/session_service.py +202 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
package/src/cli_commands.py
CHANGED
|
@@ -3,7 +3,8 @@ import getpass
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
-
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timedelta
|
|
7
8
|
|
|
8
9
|
from .claude_refresh import _refresh_claude_sessions
|
|
9
10
|
from .cli_render import _dim, _info, _success, _warn
|
|
@@ -21,7 +22,10 @@ from .errors import CdxError
|
|
|
21
22
|
from .health import collect_health_report, format_health_report
|
|
22
23
|
from .notify import (
|
|
23
24
|
format_notify_event,
|
|
25
|
+
format_scheduled_notification,
|
|
24
26
|
parse_notify_args,
|
|
27
|
+
resolve_notify_event,
|
|
28
|
+
schedule_notification_event,
|
|
25
29
|
send_desktop_notification,
|
|
26
30
|
wait_for_notification_event,
|
|
27
31
|
)
|
|
@@ -45,6 +49,11 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
|
|
|
45
49
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
46
50
|
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
47
51
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
52
|
+
SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
|
|
53
|
+
UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
|
|
54
|
+
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
55
|
+
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
56
|
+
LAST_USAGE = "Usage: cdx last [--json]"
|
|
48
57
|
API_SCHEMA_VERSION = 1
|
|
49
58
|
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
50
59
|
|
|
@@ -69,6 +78,26 @@ def _write_json(ctx, payload):
|
|
|
69
78
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
70
79
|
|
|
71
80
|
|
|
81
|
+
def _update_notice_warning(ctx):
|
|
82
|
+
notice = ctx.get("update_notice")
|
|
83
|
+
if not notice:
|
|
84
|
+
return None
|
|
85
|
+
return {
|
|
86
|
+
"code": "update_available",
|
|
87
|
+
"message": f"Update available: cdx-manager {notice['latest_version']}",
|
|
88
|
+
"latest_version": notice["latest_version"],
|
|
89
|
+
"url": notice.get("url"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_update_notice(ctx):
|
|
94
|
+
notice = ctx.get("update_notice")
|
|
95
|
+
if not notice:
|
|
96
|
+
return
|
|
97
|
+
text = f"Update available: cdx-manager {notice['latest_version']} (current version installed may be older). Run: cdx update"
|
|
98
|
+
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
99
|
+
|
|
100
|
+
|
|
72
101
|
def _format_bytes(value):
|
|
73
102
|
if value is None:
|
|
74
103
|
return "n/a"
|
|
@@ -302,6 +331,209 @@ def _parse_toggle_args(args, usage):
|
|
|
302
331
|
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
303
332
|
|
|
304
333
|
|
|
334
|
+
def _parse_fast_value(value):
|
|
335
|
+
text = str(value).strip().lower()
|
|
336
|
+
if text in ("on", "true", "1", "yes"):
|
|
337
|
+
return True
|
|
338
|
+
if text in ("off", "false", "0", "no"):
|
|
339
|
+
return False
|
|
340
|
+
raise CdxError(SET_USAGE)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _parse_set_args(args):
|
|
344
|
+
parsed = _parse_flag_args(args, {
|
|
345
|
+
"--power": {"key": "power", "type": "str", "default": None},
|
|
346
|
+
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
347
|
+
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
348
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
349
|
+
}, SET_USAGE, positionals_key="names", max_positionals=1)
|
|
350
|
+
if len(parsed["names"]) != 1:
|
|
351
|
+
raise CdxError(SET_USAGE)
|
|
352
|
+
settings = {
|
|
353
|
+
key: parsed[key]
|
|
354
|
+
for key in ("power", "permission", "fast")
|
|
355
|
+
if parsed[key] is not None
|
|
356
|
+
}
|
|
357
|
+
if not settings:
|
|
358
|
+
raise CdxError(SET_USAGE)
|
|
359
|
+
return {"name": parsed["names"][0], "settings": settings, "json": parsed["json"]}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _parse_unset_args(args):
|
|
363
|
+
parsed = _parse_flag_args(args, {
|
|
364
|
+
"--power": {"key": "power", "type": "bool", "default": False},
|
|
365
|
+
"--permission": {"key": "permission", "type": "bool", "default": False},
|
|
366
|
+
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
367
|
+
"--all": {"key": "all", "type": "bool", "default": False},
|
|
368
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
369
|
+
}, UNSET_USAGE, positionals_key="names", max_positionals=1)
|
|
370
|
+
if len(parsed["names"]) != 1:
|
|
371
|
+
raise CdxError(UNSET_USAGE)
|
|
372
|
+
keys = ["power", "permission", "fast"] if parsed["all"] else [
|
|
373
|
+
key for key in ("power", "permission", "fast") if parsed[key]
|
|
374
|
+
]
|
|
375
|
+
if not keys:
|
|
376
|
+
raise CdxError(UNSET_USAGE)
|
|
377
|
+
return {"name": parsed["names"][0], "keys": keys, "json": parsed["json"]}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _parse_config_args(args):
|
|
381
|
+
parsed = _parse_flag_args(args, {
|
|
382
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
383
|
+
}, CONFIG_USAGE, positionals_key="names", max_positionals=1)
|
|
384
|
+
if len(parsed["names"]) != 1:
|
|
385
|
+
raise CdxError(CONFIG_USAGE)
|
|
386
|
+
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _parse_positive_int(value):
|
|
390
|
+
try:
|
|
391
|
+
parsed = int(value)
|
|
392
|
+
except (TypeError, ValueError) as error:
|
|
393
|
+
raise CdxError(HISTORY_USAGE) from error
|
|
394
|
+
if parsed < 1:
|
|
395
|
+
raise CdxError(HISTORY_USAGE)
|
|
396
|
+
return parsed
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _local_timezone():
|
|
400
|
+
return datetime.now().astimezone().tzinfo
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_datetime_value(value, usage, end_of_day=False):
|
|
404
|
+
text = str(value or "").strip()
|
|
405
|
+
if not text:
|
|
406
|
+
raise CdxError(usage)
|
|
407
|
+
if text.endswith("Z"):
|
|
408
|
+
text = f"{text[:-1]}+00:00"
|
|
409
|
+
is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
|
|
410
|
+
try:
|
|
411
|
+
parsed = datetime.fromisoformat(text)
|
|
412
|
+
except ValueError as error:
|
|
413
|
+
raise CdxError(usage) from error
|
|
414
|
+
if is_date_only and end_of_day:
|
|
415
|
+
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
416
|
+
if parsed.tzinfo is None:
|
|
417
|
+
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
418
|
+
return parsed.astimezone()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_since_value(value, now, usage):
|
|
422
|
+
text = str(value or "").strip().lower()
|
|
423
|
+
if not text:
|
|
424
|
+
raise CdxError(usage)
|
|
425
|
+
if text == "today":
|
|
426
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
427
|
+
if text == "yesterday":
|
|
428
|
+
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
429
|
+
return today - timedelta(days=1)
|
|
430
|
+
unit = text[-1:]
|
|
431
|
+
amount_text = text[:-1]
|
|
432
|
+
if unit in ("m", "h", "d", "w") and amount_text.isdigit():
|
|
433
|
+
amount = int(amount_text)
|
|
434
|
+
if amount < 1:
|
|
435
|
+
raise CdxError(usage)
|
|
436
|
+
multipliers = {
|
|
437
|
+
"m": timedelta(minutes=amount),
|
|
438
|
+
"h": timedelta(hours=amount),
|
|
439
|
+
"d": timedelta(days=amount),
|
|
440
|
+
"w": timedelta(weeks=amount),
|
|
441
|
+
}
|
|
442
|
+
return now - multipliers[unit]
|
|
443
|
+
return _parse_datetime_value(value, usage, end_of_day=False)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _format_period_datetime(value):
|
|
447
|
+
if value is None:
|
|
448
|
+
return None
|
|
449
|
+
return value.isoformat(timespec="seconds")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _parse_history_period(parsed, now):
|
|
453
|
+
since = parsed.get("since")
|
|
454
|
+
from_value = parsed.get("from")
|
|
455
|
+
to_value = parsed.get("to")
|
|
456
|
+
if since and from_value:
|
|
457
|
+
raise CdxError("Usage: cdx history cannot combine --since and --from.")
|
|
458
|
+
start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
|
|
459
|
+
if from_value:
|
|
460
|
+
start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
|
|
461
|
+
end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
|
|
462
|
+
if start and end and start.timestamp() > end.timestamp():
|
|
463
|
+
raise CdxError("Usage: cdx history period start must be before period end.")
|
|
464
|
+
return {
|
|
465
|
+
"from": _format_period_datetime(start),
|
|
466
|
+
"to": _format_period_datetime(end),
|
|
467
|
+
"from_ts": start.timestamp() if start else None,
|
|
468
|
+
"to_ts": end.timestamp() if end else None,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _has_history_period(period):
|
|
473
|
+
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _parse_entry_timestamp(entry):
|
|
477
|
+
value = entry.get("started_at")
|
|
478
|
+
if not value:
|
|
479
|
+
return None
|
|
480
|
+
text = str(value)
|
|
481
|
+
if text.endswith("Z"):
|
|
482
|
+
text = f"{text[:-1]}+00:00"
|
|
483
|
+
try:
|
|
484
|
+
parsed = datetime.fromisoformat(text)
|
|
485
|
+
except ValueError:
|
|
486
|
+
return None
|
|
487
|
+
if parsed.tzinfo is None:
|
|
488
|
+
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
489
|
+
return parsed.timestamp()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _filter_history_period(entries, period):
|
|
493
|
+
if not _has_history_period(period):
|
|
494
|
+
return entries
|
|
495
|
+
filtered = []
|
|
496
|
+
start = period.get("from_ts")
|
|
497
|
+
end = period.get("to_ts")
|
|
498
|
+
for entry in entries:
|
|
499
|
+
timestamp = _parse_entry_timestamp(entry)
|
|
500
|
+
if timestamp is None:
|
|
501
|
+
continue
|
|
502
|
+
if start is not None and timestamp < start:
|
|
503
|
+
continue
|
|
504
|
+
if end is not None and timestamp > end:
|
|
505
|
+
continue
|
|
506
|
+
filtered.append(entry)
|
|
507
|
+
return filtered
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _public_history_period(period):
|
|
511
|
+
return {
|
|
512
|
+
"from": period.get("from"),
|
|
513
|
+
"to": period.get("to"),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _parse_history_args(args, now=None):
|
|
518
|
+
now = now or datetime.now().astimezone()
|
|
519
|
+
parsed = _parse_flag_args(args, {
|
|
520
|
+
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
521
|
+
"--summary": {"key": "summary", "type": "bool", "default": False},
|
|
522
|
+
"--since": {"key": "since", "type": "str", "default": None},
|
|
523
|
+
"--from": {"key": "from", "type": "str", "default": None},
|
|
524
|
+
"--to": {"key": "to", "type": "str", "default": None},
|
|
525
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
526
|
+
}, HISTORY_USAGE, positionals_key="names", max_positionals=1)
|
|
527
|
+
period = _parse_history_period(parsed, now)
|
|
528
|
+
return {
|
|
529
|
+
"name": parsed["names"][0] if parsed["names"] else None,
|
|
530
|
+
"limit": parsed["limit"],
|
|
531
|
+
"summary": parsed["summary"],
|
|
532
|
+
"period": period,
|
|
533
|
+
"json": parsed["json"],
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
305
537
|
def _read_option_value(args, index, usage):
|
|
306
538
|
if index + 1 >= len(args):
|
|
307
539
|
raise CdxError(usage)
|
|
@@ -518,6 +750,220 @@ def handle_enable(rest, ctx):
|
|
|
518
750
|
return 0
|
|
519
751
|
|
|
520
752
|
|
|
753
|
+
def _format_launch_config(session):
|
|
754
|
+
launch = session.get("launch") or {}
|
|
755
|
+
return "\n".join([
|
|
756
|
+
f"{session['name']} ({session['provider']})",
|
|
757
|
+
f"power: {launch.get('power') or 'default'}",
|
|
758
|
+
f"permission: {launch.get('permission') or 'default'}",
|
|
759
|
+
f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
|
|
760
|
+
])
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _format_duration_ms(value):
|
|
764
|
+
if value is None:
|
|
765
|
+
return "-"
|
|
766
|
+
try:
|
|
767
|
+
amount = int(value)
|
|
768
|
+
except (TypeError, ValueError):
|
|
769
|
+
return str(value)
|
|
770
|
+
if amount < 1000:
|
|
771
|
+
return f"{amount}ms"
|
|
772
|
+
seconds = amount / 1000
|
|
773
|
+
if seconds < 60:
|
|
774
|
+
return f"{seconds:.1f}s"
|
|
775
|
+
minutes = int(seconds // 60)
|
|
776
|
+
remaining = int(seconds % 60)
|
|
777
|
+
return f"{minutes}m{remaining:02d}s"
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _summarize_history(entries):
|
|
781
|
+
by_session = {}
|
|
782
|
+
for entry in entries:
|
|
783
|
+
name = entry.get("session_name") or "-"
|
|
784
|
+
row = by_session.setdefault(name, {
|
|
785
|
+
"session_name": name,
|
|
786
|
+
"provider": entry.get("provider") or "-",
|
|
787
|
+
"launches": 0,
|
|
788
|
+
"successes": 0,
|
|
789
|
+
"failures": 0,
|
|
790
|
+
"duration_ms": 0,
|
|
791
|
+
"last_started_at": None,
|
|
792
|
+
})
|
|
793
|
+
row["launches"] += 1
|
|
794
|
+
if entry.get("status") == "success":
|
|
795
|
+
row["successes"] += 1
|
|
796
|
+
elif entry.get("status") == "failed":
|
|
797
|
+
row["failures"] += 1
|
|
798
|
+
try:
|
|
799
|
+
row["duration_ms"] += int(entry.get("duration_ms") or 0)
|
|
800
|
+
except (TypeError, ValueError):
|
|
801
|
+
pass
|
|
802
|
+
started = entry.get("started_at")
|
|
803
|
+
if started and (not row["last_started_at"] or started > row["last_started_at"]):
|
|
804
|
+
row["last_started_at"] = started
|
|
805
|
+
row["provider"] = entry.get("provider") or row["provider"]
|
|
806
|
+
return sorted(
|
|
807
|
+
by_session.values(),
|
|
808
|
+
key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
|
|
809
|
+
reverse=True,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _format_history_period(period):
|
|
814
|
+
if not _has_history_period(period or {}):
|
|
815
|
+
return None
|
|
816
|
+
start = _format_period_display(period.get("from")) or "beginning"
|
|
817
|
+
end = _format_period_display(period.get("to")) or "now"
|
|
818
|
+
return f"Period: {start} -> {end}"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _format_period_display(value):
|
|
822
|
+
if not value:
|
|
823
|
+
return None
|
|
824
|
+
try:
|
|
825
|
+
parsed = datetime.fromisoformat(str(value))
|
|
826
|
+
except ValueError:
|
|
827
|
+
return str(value)
|
|
828
|
+
return parsed.strftime("%Y-%m-%d %H:%M")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _format_history_summary(entries, period=None, use_color=False):
|
|
832
|
+
from .cli_render import _format_relative_age, _pad_table
|
|
833
|
+
|
|
834
|
+
summary = _summarize_history(entries)
|
|
835
|
+
if not summary:
|
|
836
|
+
return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
|
|
837
|
+
rows = [["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]
|
|
838
|
+
for row in summary:
|
|
839
|
+
rows.append([
|
|
840
|
+
row["session_name"],
|
|
841
|
+
row["provider"],
|
|
842
|
+
str(row["launches"]),
|
|
843
|
+
str(row["successes"]),
|
|
844
|
+
str(row["failures"]),
|
|
845
|
+
_format_duration_ms(row["duration_ms"]),
|
|
846
|
+
_format_relative_age(row.get("last_started_at")),
|
|
847
|
+
])
|
|
848
|
+
lines = ["Assistant time:"]
|
|
849
|
+
period_line = _format_history_period(period or {})
|
|
850
|
+
if period_line:
|
|
851
|
+
lines.extend([period_line, ""])
|
|
852
|
+
lines.append(_pad_table(rows))
|
|
853
|
+
return "\n".join(lines)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _format_history(entries, use_color=False):
|
|
857
|
+
from .cli_render import _format_relative_age, _pad_table
|
|
858
|
+
|
|
859
|
+
if not entries:
|
|
860
|
+
return "No launch history."
|
|
861
|
+
rows = [["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]
|
|
862
|
+
for entry in entries:
|
|
863
|
+
transcript_path = entry.get("transcript_path")
|
|
864
|
+
rows.append([
|
|
865
|
+
entry.get("session_name") or "-",
|
|
866
|
+
entry.get("provider") or "-",
|
|
867
|
+
entry.get("status") or "-",
|
|
868
|
+
_format_duration_ms(entry.get("duration_ms")),
|
|
869
|
+
_format_relative_age(entry.get("started_at")),
|
|
870
|
+
os.path.basename(transcript_path) if transcript_path else "-",
|
|
871
|
+
])
|
|
872
|
+
return "\n".join([
|
|
873
|
+
"Recent launches:",
|
|
874
|
+
_pad_table(rows),
|
|
875
|
+
"",
|
|
876
|
+
_dim("Full transcript paths and cwd are available with --json.", use_color),
|
|
877
|
+
])
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def handle_set(rest, ctx):
|
|
881
|
+
parsed = _parse_set_args(rest)
|
|
882
|
+
session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
|
|
883
|
+
message = f"Updated launch settings for {parsed['name']}"
|
|
884
|
+
if parsed["json"]:
|
|
885
|
+
_write_json(ctx, _json_success("set", message, session=session, launch=session.get("launch") or {}))
|
|
886
|
+
return 0
|
|
887
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
888
|
+
ctx["out"](f"{_format_launch_config(session)}\n")
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def handle_unset(rest, ctx):
|
|
893
|
+
parsed = _parse_unset_args(rest)
|
|
894
|
+
session = ctx["service"]["unset_launch_settings"](parsed["name"], parsed["keys"])
|
|
895
|
+
message = f"Cleared launch settings for {parsed['name']}"
|
|
896
|
+
if parsed["json"]:
|
|
897
|
+
_write_json(ctx, _json_success("unset", message, session=session, launch=session.get("launch") or {}))
|
|
898
|
+
return 0
|
|
899
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
900
|
+
ctx["out"](f"{_format_launch_config(session)}\n")
|
|
901
|
+
return 0
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def handle_config(rest, ctx):
|
|
905
|
+
parsed = _parse_config_args(rest)
|
|
906
|
+
session = ctx["service"]["get_session"](parsed["name"])
|
|
907
|
+
if not session:
|
|
908
|
+
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
909
|
+
message = f"Launch settings for {parsed['name']}"
|
|
910
|
+
if parsed["json"]:
|
|
911
|
+
_write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
|
|
912
|
+
return 0
|
|
913
|
+
ctx["out"](f"{_format_launch_config(session)}\n")
|
|
914
|
+
return 0
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def handle_history(rest, ctx):
|
|
918
|
+
now_fn = ctx["options"].get("now") or time.time
|
|
919
|
+
now = datetime.fromtimestamp(now_fn()).astimezone()
|
|
920
|
+
parsed = _parse_history_args(rest, now=now)
|
|
921
|
+
has_period = _has_history_period(parsed["period"])
|
|
922
|
+
limit = 0 if parsed["summary"] or has_period else parsed["limit"]
|
|
923
|
+
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
|
|
924
|
+
entries = _filter_history_period(entries, parsed["period"])
|
|
925
|
+
if has_period and not parsed["summary"]:
|
|
926
|
+
entries = entries[:parsed["limit"]]
|
|
927
|
+
message = (
|
|
928
|
+
f"Listed launch history for {parsed['name']}"
|
|
929
|
+
if parsed["name"]
|
|
930
|
+
else "Listed launch history"
|
|
931
|
+
)
|
|
932
|
+
if parsed["json"]:
|
|
933
|
+
payload = {"history": entries, "period": _public_history_period(parsed["period"])}
|
|
934
|
+
if parsed["summary"]:
|
|
935
|
+
payload["summary"] = _summarize_history(entries)
|
|
936
|
+
_write_json(ctx, _json_success("history", message, **payload))
|
|
937
|
+
return 0
|
|
938
|
+
if parsed["summary"]:
|
|
939
|
+
ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'])}\n")
|
|
940
|
+
return 0
|
|
941
|
+
ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'])}\n")
|
|
942
|
+
return 0
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _resolve_last_launch_session(ctx):
|
|
946
|
+
for entry in ctx["service"]["get_launch_history"](limit=0):
|
|
947
|
+
name = entry.get("session_name")
|
|
948
|
+
if not name:
|
|
949
|
+
continue
|
|
950
|
+
session = ctx["service"]["get_session"](name)
|
|
951
|
+
if session:
|
|
952
|
+
return session
|
|
953
|
+
raise CdxError("No launch history yet. Run cdx <name> first.")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def handle_last(rest, ctx):
|
|
957
|
+
json_flag, args = _parse_json_flag(rest)
|
|
958
|
+
if args:
|
|
959
|
+
raise CdxError(LAST_USAGE)
|
|
960
|
+
session = _resolve_last_launch_session(ctx)
|
|
961
|
+
if not json_flag:
|
|
962
|
+
message = f"Launching last session: {session['name']}"
|
|
963
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
964
|
+
return handle_launch(session["name"], ctx)
|
|
965
|
+
|
|
966
|
+
|
|
521
967
|
def handle_clean(rest, ctx):
|
|
522
968
|
json_flag, args = _parse_json_flag(rest)
|
|
523
969
|
service = ctx["service"]
|
|
@@ -639,6 +1085,38 @@ def handle_notify(rest, ctx):
|
|
|
639
1085
|
env=ctx.get("env"),
|
|
640
1086
|
)
|
|
641
1087
|
|
|
1088
|
+
if parsed["schedule"]:
|
|
1089
|
+
event = resolve_notify_event(
|
|
1090
|
+
ctx["service"]["get_status_rows"](
|
|
1091
|
+
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
1092
|
+
force_refresh=parsed.get("refresh", False),
|
|
1093
|
+
),
|
|
1094
|
+
parsed,
|
|
1095
|
+
(ctx["options"].get("now") or time.time)(),
|
|
1096
|
+
)
|
|
1097
|
+
if event["ready"]:
|
|
1098
|
+
notifier(event["title"], event["message"])
|
|
1099
|
+
schedule = {
|
|
1100
|
+
"scheduled": False,
|
|
1101
|
+
"backend": "immediate",
|
|
1102
|
+
"message": event["message"],
|
|
1103
|
+
"target_timestamp": event.get("target_timestamp"),
|
|
1104
|
+
}
|
|
1105
|
+
else:
|
|
1106
|
+
schedule = schedule_notification_event(
|
|
1107
|
+
ctx["service"]["base_dir"],
|
|
1108
|
+
parsed,
|
|
1109
|
+
event,
|
|
1110
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
1111
|
+
env=ctx.get("env"),
|
|
1112
|
+
now_fn=ctx["options"].get("now"),
|
|
1113
|
+
)
|
|
1114
|
+
if parsed["json"]:
|
|
1115
|
+
_write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
|
|
1116
|
+
else:
|
|
1117
|
+
ctx["out"](f"{format_scheduled_notification(schedule)}\n")
|
|
1118
|
+
return 0
|
|
1119
|
+
|
|
642
1120
|
event = wait_for_notification_event(
|
|
643
1121
|
ctx["service"],
|
|
644
1122
|
parsed,
|
|
@@ -785,6 +1263,9 @@ def handle_status(rest, ctx):
|
|
|
785
1263
|
}
|
|
786
1264
|
for item in refresh_errors
|
|
787
1265
|
]
|
|
1266
|
+
update_warning = _update_notice_warning(ctx)
|
|
1267
|
+
if update_warning:
|
|
1268
|
+
warnings.append(update_warning)
|
|
788
1269
|
|
|
789
1270
|
status_progress = None if parsed["json"] else _make_status_progress(ctx)
|
|
790
1271
|
rows = ctx["service"]["get_status_rows"](
|
|
@@ -797,6 +1278,7 @@ def handle_status(rest, ctx):
|
|
|
797
1278
|
return 0
|
|
798
1279
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
|
|
799
1280
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
1281
|
+
_write_update_notice(ctx)
|
|
800
1282
|
return 0
|
|
801
1283
|
|
|
802
1284
|
row = next((r for r in rows if r["session_name"] == args[0]), None)
|
|
@@ -807,6 +1289,7 @@ def handle_status(rest, ctx):
|
|
|
807
1289
|
return 0
|
|
808
1290
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
809
1291
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
1292
|
+
_write_update_notice(ctx)
|
|
810
1293
|
return 0
|
|
811
1294
|
|
|
812
1295
|
|
|
@@ -1028,16 +1511,11 @@ def handle_update(rest, ctx):
|
|
|
1028
1511
|
|
|
1029
1512
|
|
|
1030
1513
|
def handle_launch(command, ctx, initial_prompt=None):
|
|
1031
|
-
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
1514
|
+
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
1032
1515
|
update_notice = ctx.get("update_notice")
|
|
1033
1516
|
warnings = []
|
|
1034
1517
|
if update_notice:
|
|
1035
|
-
warnings.append(
|
|
1036
|
-
"code": "update_available",
|
|
1037
|
-
"message": f"Update available: cdx-manager {update_notice['latest_version']}",
|
|
1038
|
-
"latest_version": update_notice["latest_version"],
|
|
1039
|
-
"url": update_notice.get("url"),
|
|
1040
|
-
})
|
|
1518
|
+
warnings.append(_update_notice_warning(ctx))
|
|
1041
1519
|
session = ctx["service"]["launch_session"](command)
|
|
1042
1520
|
_ensure_session_authentication(
|
|
1043
1521
|
session,
|
|
@@ -1052,15 +1530,51 @@ def handle_launch(command, ctx, initial_prompt=None):
|
|
|
1052
1530
|
message = f"Launching {session['provider']} session {session['name']}"
|
|
1053
1531
|
if not json_flag:
|
|
1054
1532
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1533
|
+
_write_update_notice(ctx)
|
|
1534
|
+
cwd = ctx.get("cwd") or os.getcwd()
|
|
1535
|
+
runtime_run_id = None
|
|
1536
|
+
|
|
1537
|
+
def runtime_lifecycle(event, info):
|
|
1538
|
+
nonlocal runtime_run_id
|
|
1539
|
+
if event == "started":
|
|
1540
|
+
runtime = ctx["service"]["start_session_runtime"](session["name"], info)
|
|
1541
|
+
runtime_run_id = runtime.get("runId")
|
|
1542
|
+
elif event == "finished" and runtime_run_id:
|
|
1543
|
+
ctx["service"]["finish_session_runtime"](
|
|
1544
|
+
session["name"],
|
|
1545
|
+
runtime_run_id,
|
|
1546
|
+
{"status": "stopped", "returncode": info.get("returncode")},
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
try:
|
|
1550
|
+
run_info = _run_interactive_provider_command(
|
|
1551
|
+
session, "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
|
|
1552
|
+
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
|
1553
|
+
lifecycle_callback=runtime_lifecycle,
|
|
1554
|
+
)
|
|
1555
|
+
except CdxError as error:
|
|
1556
|
+
run_info = getattr(error, "run_info", {}) or {}
|
|
1557
|
+
if runtime_run_id:
|
|
1558
|
+
ctx["service"]["finish_session_runtime"](
|
|
1559
|
+
session["name"],
|
|
1560
|
+
runtime_run_id,
|
|
1561
|
+
{"status": "failed", "returncode": run_info.get("returncode")},
|
|
1562
|
+
)
|
|
1563
|
+
if run_info:
|
|
1564
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
1565
|
+
"status": "failed",
|
|
1566
|
+
"cwd": cwd,
|
|
1567
|
+
"error": str(error),
|
|
1568
|
+
"exit_code": error.exit_code,
|
|
1569
|
+
**run_info,
|
|
1570
|
+
})
|
|
1571
|
+
raise
|
|
1572
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
1573
|
+
"status": "success",
|
|
1574
|
+
"cwd": cwd,
|
|
1575
|
+
"exit_code": 0,
|
|
1576
|
+
**run_info,
|
|
1577
|
+
})
|
|
1064
1578
|
if json_flag:
|
|
1065
1579
|
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
1066
1580
|
return 0
|