cdx-manager 0.2.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,131 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
8
+
9
+
10
+ def _format_relative_age(iso_value):
11
+ if not iso_value:
12
+ return "-"
13
+ try:
14
+ ts = datetime.fromisoformat(iso_value.replace("Z", "+00:00"))
15
+ delta_s = (datetime.now(timezone.utc) - ts).total_seconds()
16
+ except (ValueError, TypeError):
17
+ return "-"
18
+ if delta_s < 0:
19
+ return "just now"
20
+ minutes = int(delta_s // 60)
21
+ if minutes < 1:
22
+ return "just now"
23
+ if minutes < 60:
24
+ return f"{minutes}m ago"
25
+ hours = minutes // 60
26
+ if hours < 24:
27
+ return f"{hours}h ago"
28
+ return f"{hours // 24}d ago"
29
+
30
+
31
+ def _format_pct(value):
32
+ if value is None:
33
+ return "n/a"
34
+ return f"{value}%"
35
+
36
+
37
+ def _visible_len(value):
38
+ return len(ANSI_RE.sub("", str(value)))
39
+
40
+
41
+ def _pad_table(columns):
42
+ widths = [
43
+ max(_visible_len(row[i]) for row in columns)
44
+ for i in range(len(columns[0]))
45
+ ]
46
+ lines = []
47
+ for row in columns:
48
+ cells = []
49
+ for i in range(len(row)):
50
+ value = str(row[i])
51
+ cells.append(value + " " * (widths[i] - _visible_len(value)))
52
+ lines.append(" ".join(cells))
53
+ return "\n".join(lines)
54
+
55
+
56
+ def _should_use_color(env, stdout):
57
+ if env.get("NO_COLOR") is not None:
58
+ return False
59
+ if env.get("CLICOLOR_FORCE") not in (None, "", "0"):
60
+ return True
61
+ if env.get("CLICOLOR") == "0":
62
+ return False
63
+ if env.get("TERM") == "dumb":
64
+ return False
65
+ return bool(hasattr(stdout, "isatty") and stdout.isatty())
66
+
67
+
68
+ def _style(text, code, use_color=False):
69
+ if not use_color:
70
+ return str(text)
71
+ return f"\033[{code}m{text}\033[0m"
72
+
73
+
74
+ def _style_pct(value, use_color=False):
75
+ text = _format_pct(value)
76
+ if value is None:
77
+ return _style(text, "2", use_color)
78
+ if value == 0:
79
+ return _style(text, "31", use_color)
80
+ if value <= 10:
81
+ return _style(text, "33", use_color)
82
+ return _style(text, "32", use_color)
83
+
84
+
85
+ def _success(text, use_color=False):
86
+ return _style(text, "32", use_color)
87
+
88
+
89
+ def _warn(text, use_color=False):
90
+ return _style(text, "33", use_color)
91
+
92
+
93
+ def _info(text, use_color=False):
94
+ return _style(text, "36", use_color)
95
+
96
+
97
+ def _dim(text, use_color=False):
98
+ return _style(text, "2", use_color)
99
+
100
+
101
+ def format_error(error, env=None, stderr=None):
102
+ return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
103
+
104
+
105
+ def _format_sessions(service, use_color=False):
106
+ rows = service["format_list_rows"]()
107
+ has_provider = any(r.get("provider") for r in rows)
108
+ headers = ["SESSION"]
109
+ if has_provider:
110
+ headers.append("PROVIDER")
111
+ headers.append("UPDATED")
112
+ headers = [_style(header, "1", use_color) for header in headers]
113
+ table_rows = []
114
+ for r in rows:
115
+ parts = [r["name"]]
116
+ if has_provider:
117
+ parts.append(r.get("provider") or "n/a")
118
+ parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
119
+ table_rows.append(parts)
120
+ lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
121
+ lines += [
122
+ _style("Next actions:", "1", use_color),
123
+ f" {_style('cdx add <name>', '36', use_color)}",
124
+ f" {_style('cdx <name>', '36', use_color)}",
125
+ f" {_style('cdx login <name>', '36', use_color)}",
126
+ f" {_style('cdx logout <name>', '36', use_color)}",
127
+ f" {_style('cdx ren <source> <dest>', '36', use_color)}",
128
+ f" {_style('cdx rmv <name>', '36', use_color)}",
129
+ f" {_style('cdx status', '36', use_color)}",
130
+ ]
131
+ return "\n".join(lines)
package/src/config.py ADDED
@@ -0,0 +1,8 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ def get_cdx_home(env=None):
6
+ if env is None:
7
+ env = os.environ
8
+ return env.get("CDX_HOME", str(Path.home() / ".cdx"))
package/src/errors.py ADDED
@@ -0,0 +1,4 @@
1
+ class CdxError(Exception):
2
+ def __init__(self, message, exit_code=1):
3
+ super().__init__(message)
4
+ self.exit_code = exit_code
package/src/health.py ADDED
@@ -0,0 +1,125 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ from urllib.parse import quote, unquote
6
+
7
+ from .cli_render import _pad_table, _style
8
+
9
+
10
+ def _encode(name):
11
+ return quote(name, safe="")
12
+
13
+
14
+ def _state_file_path(base_dir, name):
15
+ return os.path.join(base_dir, "state", f"{_encode(name)}.json")
16
+
17
+
18
+ def _profiles_dir(base_dir):
19
+ return os.path.join(base_dir, "profiles")
20
+
21
+
22
+ def _issue(status, code, message, detail=None, repairable=False):
23
+ return {
24
+ "status": status,
25
+ "code": code,
26
+ "message": message,
27
+ "detail": detail,
28
+ "repairable": repairable,
29
+ }
30
+
31
+
32
+ def collect_health_report(service, base_dir, env=None):
33
+ env = env or os.environ
34
+ issues = []
35
+
36
+ for command in ("codex", "claude"):
37
+ path = shutil.which(command, path=env.get("PATH"))
38
+ status = "OK" if path else "WARN"
39
+ issues.append(_issue(status, f"{command}_cli", f"{command} CLI {'found' if path else 'not found'}", path))
40
+
41
+ script_bin = env.get("CDX_SCRIPT_BIN", "script")
42
+ script_path = shutil.which(script_bin, path=env.get("PATH"))
43
+ issues.append(_issue(
44
+ "OK" if script_path else "WARN",
45
+ "script_cli",
46
+ f"{script_bin} CLI {'found' if script_path else 'not found; Codex will launch without transcript fallback'}",
47
+ script_path,
48
+ ))
49
+
50
+ issues.append(_check_cdx_home(base_dir))
51
+ sessions = service["list_sessions"]()
52
+ session_names = {session["name"] for session in sessions}
53
+ for session in sessions:
54
+ name = session["name"]
55
+ root = session.get("sessionRoot") or service["get_session_root"](name)
56
+ if not os.path.isdir(root):
57
+ issues.append(_issue("FAIL", "missing_profile", f"session {name} profile is missing", root))
58
+ state_path = _state_file_path(base_dir, name)
59
+ if not os.path.isfile(state_path):
60
+ issues.append(_issue("FAIL", "missing_state", f"session {name} state file is missing", state_path, True))
61
+
62
+ issues.extend(_collect_profile_issues(base_dir, session_names))
63
+ return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
64
+
65
+
66
+ def _check_cdx_home(base_dir):
67
+ try:
68
+ os.makedirs(base_dir, exist_ok=True)
69
+ fd, path = tempfile.mkstemp(prefix=".cdx-doctor.", dir=base_dir)
70
+ os.close(fd)
71
+ os.unlink(path)
72
+ return _issue("OK", "cdx_home_writable", "CDX_HOME is writable", base_dir)
73
+ except OSError as error:
74
+ return _issue("FAIL", "cdx_home_writable", "CDX_HOME is not writable", f"{base_dir}: {error}")
75
+
76
+
77
+ def _collect_profile_issues(base_dir, session_names):
78
+ profile_dir = _profiles_dir(base_dir)
79
+ if not os.path.isdir(profile_dir):
80
+ return []
81
+ issues = []
82
+ encoded_session_names = {_encode(name) for name in session_names}
83
+ for entry in sorted(os.listdir(profile_dir)):
84
+ path = os.path.join(profile_dir, entry)
85
+ if not os.path.isdir(path):
86
+ continue
87
+ if entry.startswith(".") and ".remove." in entry:
88
+ issues.append(_issue("WARN", "quarantine_profile", f"pending quarantine profile: {entry}", path, True))
89
+ continue
90
+ if entry.startswith("."):
91
+ continue
92
+ if entry not in encoded_session_names:
93
+ issues.append(_issue("WARN", "orphan_profile", f"orphan profile: {unquote(entry)}", path, True))
94
+ return issues
95
+
96
+
97
+ def summarize_health(issues):
98
+ return {
99
+ "ok": sum(1 for issue in issues if issue["status"] == "OK"),
100
+ "warn": sum(1 for issue in issues if issue["status"] == "WARN"),
101
+ "fail": sum(1 for issue in issues if issue["status"] == "FAIL"),
102
+ "repairable": sum(1 for issue in issues if issue.get("repairable")),
103
+ }
104
+
105
+
106
+ def format_health_report(report, use_color=False):
107
+ rows = [["STATUS", "CHECK", "MESSAGE"]]
108
+ for issue in report["issues"]:
109
+ status = issue["status"]
110
+ style = "32" if status == "OK" else "33" if status == "WARN" else "31"
111
+ rows.append([_style(status, style, use_color), issue["code"], issue["message"]])
112
+ summary = report["summary"]
113
+ return "\n".join([
114
+ _pad_table(rows),
115
+ "",
116
+ _style(
117
+ f"Summary: {summary['ok']} OK, {summary['warn']} WARN, {summary['fail']} FAIL, {summary['repairable']} repairable.",
118
+ "1",
119
+ use_color,
120
+ ),
121
+ ])
122
+
123
+
124
+ def health_json(report):
125
+ return json.dumps(report, indent=2)
package/src/notify.py ADDED
@@ -0,0 +1,138 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import time
5
+
6
+ from .errors import CdxError
7
+ from .status_view import _parse_reset_timestamp, _recommend_priority_sessions
8
+
9
+
10
+ def parse_notify_args(args):
11
+ json_flag = "--json" in args
12
+ once = "--once" in args
13
+ at_reset = "--at-reset" in args
14
+ next_ready = "--next-ready" in args
15
+ poll = 60
16
+ cleaned = []
17
+ i = 0
18
+ while i < len(args):
19
+ arg = args[i]
20
+ if arg in ("--json", "--once", "--at-reset", "--next-ready"):
21
+ i += 1
22
+ continue
23
+ if arg == "--poll":
24
+ if i + 1 >= len(args):
25
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
26
+ try:
27
+ poll = max(1, int(args[i + 1]))
28
+ except ValueError as error:
29
+ raise CdxError("--poll must be a number of seconds") from error
30
+ i += 2
31
+ continue
32
+ if arg.startswith("-"):
33
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
34
+ cleaned.append(arg)
35
+ i += 1
36
+ if at_reset == next_ready:
37
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
38
+ if at_reset and len(cleaned) != 1:
39
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once]")
40
+ if next_ready and cleaned:
41
+ raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once]")
42
+ return {
43
+ "name": cleaned[0] if cleaned else None,
44
+ "mode": "at-reset" if at_reset else "next-ready",
45
+ "poll": poll,
46
+ "once": once,
47
+ "json": json_flag,
48
+ }
49
+
50
+
51
+ def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None):
52
+ notifier = notifier or send_desktop_notification
53
+ sleep_fn = sleep_fn or time.sleep
54
+ now_fn = now_fn or time.time
55
+ while True:
56
+ event = resolve_notify_event(service["get_status_rows"](), parsed, now_fn())
57
+ if event["ready"] or parsed["once"]:
58
+ if event["ready"]:
59
+ notifier(event["title"], event["message"])
60
+ return event
61
+ sleep_fn(parsed["poll"])
62
+
63
+
64
+ def resolve_notify_event(rows, parsed, now_ts=None):
65
+ now_ts = time.time() if now_ts is None else now_ts
66
+ if parsed["mode"] == "next-ready":
67
+ priority = _recommend_priority_sessions(rows)
68
+ if not priority:
69
+ return _event(False, "cdx", "No session status available", None)
70
+ first = priority[0]
71
+ if _is_available(first):
72
+ return _event(True, "cdx", f"{first['session_name']} is ready", first["session_name"])
73
+ timestamp = _next_reset_timestamp(first)
74
+ if timestamp is not None and timestamp <= now_ts:
75
+ return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
76
+ return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
77
+
78
+ row = next((item for item in rows if item["session_name"] == parsed["name"]), None)
79
+ if not row:
80
+ raise CdxError(f"Unknown session: {parsed['name']}")
81
+ timestamp = _next_reset_timestamp(row)
82
+ if timestamp is None:
83
+ return _event(False, "cdx", f"No reset time known for {row['session_name']}", row["session_name"])
84
+ if timestamp <= now_ts:
85
+ return _event(True, "cdx", f"{row['session_name']} reset is due", row["session_name"], timestamp)
86
+ return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
87
+
88
+
89
+ def _is_available(row):
90
+ value = row.get("available_pct")
91
+ return value is not None and value > 0
92
+
93
+
94
+ def _next_reset_timestamp(row):
95
+ values = [row.get("reset_5h_at"), row.get("reset_week_at"), row.get("reset_at")]
96
+ timestamps = [
97
+ timestamp
98
+ for timestamp in (_parse_reset_timestamp(value) for value in values)
99
+ if timestamp is not None
100
+ ]
101
+ if not timestamps:
102
+ return None
103
+ return min(timestamps)
104
+
105
+
106
+ def _event(ready, title, message, session_name, target_timestamp=None):
107
+ return {
108
+ "ready": ready,
109
+ "title": title,
110
+ "message": message,
111
+ "session": session_name,
112
+ "target_timestamp": target_timestamp,
113
+ }
114
+
115
+
116
+ def send_desktop_notification(title, message, spawn_sync=None, env=None):
117
+ spawn_sync = spawn_sync or subprocess.run
118
+ env = env or os.environ
119
+ if shutil_which("osascript", env):
120
+ script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
121
+ spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
122
+
123
+
124
+ def shutil_which(command, env):
125
+ import shutil
126
+ return shutil.which(command, path=env.get("PATH"))
127
+
128
+
129
+ def _escape_applescript(value):
130
+ return str(value).replace("\\", "\\\\").replace('"', '\\"')
131
+
132
+
133
+ def format_notify_event(event):
134
+ return event["message"]
135
+
136
+
137
+ def notify_json(event):
138
+ return json.dumps(event, indent=2)