cdx-manager 0.17.0 → 0.18.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.
@@ -0,0 +1,167 @@
1
+ """One-line setup hints, shown only while they are still actionable.
2
+
3
+ CDX has optional surfaces a user cannot discover by reading the output of the
4
+ command they already run: agent alerts, and the tray companion. A hint is how
5
+ they learn, so the bar is that it must be worth interrupting for.
6
+
7
+ Four rules keep it from becoming noise, and each exists because the obvious
8
+ version of this feature fails without it:
9
+
10
+ - Never in `--json`. That is a contract, and a helpful line inside it is a
11
+ parse error somewhere else.
12
+ - At most one per invocation, and not again for six hours. Advice that repeats
13
+ on every command stops being read, including the day it matters.
14
+ - Retired after three showings. A user who saw it three times and did not act
15
+ has answered; asking a fourth time is nagging, not helping.
16
+ - Silent on any failure. A hint is the least important thing on the screen,
17
+ so it never breaks, slows, or interrupts the command that carried it.
18
+
19
+ Every condition is answered from local state already on disk. Nothing here
20
+ probes a provider, touches the network, or costs a measurable amount of time.
21
+ """
22
+ import json
23
+ import os
24
+ import time
25
+
26
+ HINT_STATE_VERSION = 1
27
+ # Long enough that a burst of commands shows nothing, short enough that a hint
28
+ # still reaches someone who uses CDX a few times a day.
29
+ QUIET_SECONDS = 6 * 60 * 60
30
+ MAX_SHOWINGS = 3
31
+
32
+
33
+ def _state_path(base_dir):
34
+ return os.path.join(base_dir, "hints.json")
35
+
36
+
37
+ def _read_state(base_dir):
38
+ """What was shown and when. A damaged file reads as empty, never as an error."""
39
+ try:
40
+ with open(_state_path(base_dir), encoding="utf-8") as handle:
41
+ state = json.load(handle)
42
+ except (OSError, ValueError):
43
+ return {"version": HINT_STATE_VERSION, "hints": {}}
44
+ if not isinstance(state, dict) or state.get("version") != HINT_STATE_VERSION:
45
+ return {"version": HINT_STATE_VERSION, "hints": {}}
46
+ if not isinstance(state.get("hints"), dict):
47
+ state["hints"] = {}
48
+ return state
49
+
50
+
51
+ def _write_state(base_dir, state):
52
+ try:
53
+ os.makedirs(base_dir, exist_ok=True)
54
+ with open(_state_path(base_dir), "w", encoding="utf-8") as handle:
55
+ json.dump(state, handle, indent=2)
56
+ except OSError:
57
+ # Unwritable state means hints repeat rather than disappear. That is the
58
+ # right way to fail: a lost preference is better than a lost command.
59
+ pass
60
+
61
+
62
+ def _tray_installed(base_dir, env):
63
+ if (env.get("CDX_TRAY_BIN") or "").strip():
64
+ return True
65
+ try:
66
+ from .tray_install import read_state as read_install_state
67
+ return bool(read_install_state(base_dir))
68
+ except Exception: # noqa: BLE001 - a hint never propagates a failure
69
+ return False
70
+
71
+
72
+ def _autostart_on(env):
73
+ try:
74
+ from .tray_autostart import status as autostart_status
75
+ return bool(autostart_status(env=env).get("enabled"))
76
+ except Exception: # noqa: BLE001
77
+ return False
78
+
79
+
80
+ def _alerts_on(sessions):
81
+ return any(
82
+ session.get("notify") and session.get("enabled", True) is not False
83
+ for session in sessions
84
+ )
85
+
86
+
87
+ def available_hints(base_dir, env, sessions):
88
+ """Every hint that is true right now, most useful first.
89
+
90
+ Ordered rather than scored: the tray is worth more than its autostart, and
91
+ both are worth more than nothing to alert about. A user with no sessions is
92
+ told nothing at all, because every hint here presumes there is something to
93
+ watch.
94
+ """
95
+ if not sessions:
96
+ return []
97
+
98
+ hints = []
99
+ installed = _tray_installed(base_dir, env)
100
+ if not installed:
101
+ hints.append((
102
+ "tray_not_installed",
103
+ "Did you know? A menu bar icon can watch your quota. Run: cdx tray install",
104
+ ))
105
+ elif not _autostart_on(env):
106
+ hints.append((
107
+ "tray_autostart_off",
108
+ "Did you know? The tray can start with your session. Run: cdx tray autostart on",
109
+ ))
110
+ if not _alerts_on(sessions):
111
+ hints.append((
112
+ "agent_alerts_off",
113
+ "Did you know? CDX can notify you when an agent finishes. Run: cdx set <name> --notify on",
114
+ ))
115
+ return hints
116
+
117
+
118
+ def next_hint(base_dir, env, sessions, now=None):
119
+ """The one hint to show, or None. Records the showing when it returns one."""
120
+ if (env.get("CDX_NO_HINTS") or "").strip():
121
+ return None
122
+
123
+ now = time.time() if now is None else now
124
+ state = _read_state(base_dir)
125
+ seen = state["hints"]
126
+
127
+ # "Never shown" is its own case, not a showing at the Unix epoch. Treating
128
+ # the two alike silences the very first hint, which is the one that matters.
129
+ shown_at = [entry["last"] for entry in seen.values() if isinstance(entry, dict) and entry.get("last")]
130
+ if shown_at and now - max(shown_at) < QUIET_SECONDS:
131
+ return None
132
+
133
+ for code, message in available_hints(base_dir, env, sessions):
134
+ entry = seen.get(code) if isinstance(seen.get(code), dict) else {}
135
+ if entry.get("shown", 0) >= MAX_SHOWINGS:
136
+ continue
137
+ seen[code] = {"shown": entry.get("shown", 0) + 1, "last": now}
138
+ _write_state(base_dir, state)
139
+ return message
140
+ return None
141
+
142
+
143
+ def write_hint(ctx):
144
+ """Print one hint after the command's own output. Never raises.
145
+
146
+ Yields to an update notice. Both want the last line, and one of them is
147
+ actionable news about the tool the user is running while the other is
148
+ optional discovery — so they never stack, and the hint waits for a quieter
149
+ run rather than pushing the notice up out of the eye's landing spot.
150
+ """
151
+ try:
152
+ if ctx.get("update_notices"):
153
+ return
154
+ env = ctx.get("env")
155
+ env = os.environ if env is None else env
156
+ service = ctx.get("service") or {}
157
+ base_dir = service.get("base_dir")
158
+ if not base_dir:
159
+ return
160
+ sessions = service["list_sessions"]()
161
+ message = next_hint(base_dir, env, sessions)
162
+ if not message:
163
+ return
164
+ from .cli_render import _dim
165
+ ctx["out"](f"{_dim(message, ctx.get('use_color', False))}\n")
166
+ except Exception: # noqa: BLE001 - deliberate: a hint must never break a command
167
+ pass
package/src/cli_render.py CHANGED
@@ -175,6 +175,7 @@ def _format_sessions(service, use_color=False):
175
175
  f" {_style('cdx configs', '36', use_color)}",
176
176
  f" {_style('cdx stats', '36', use_color)}",
177
177
  f" {_style('cdx ready', '36', use_color)}",
178
+ f" {_style('cdx set <name> --notify on', '36', use_color)}",
178
179
  f" {_style('cdx perm all default', '36', use_color)}",
179
180
  f" {_style('cdx handoff <source> <target>', '36', use_color)}",
180
181
  f" {_style('cdx history', '36', use_color)}",
@@ -3,6 +3,7 @@
3
3
  Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
+ from ..agent_notify import supports_agent_alerts
6
7
  from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
7
8
  from ..cli_helpers import _format_launch_config, _json_success, _write_json
8
9
  from ..cli_render import _success
@@ -40,6 +41,28 @@ def _format_bulk_launch_summary(sessions):
40
41
  return ", ".join(names)
41
42
  return ", ".join(names[:8]) + f", +{len(names) - 8} more"
42
43
 
44
+
45
+ def _notification_setting_notice(settings, sessions, action):
46
+ if "notify" in settings:
47
+ supported = [session for session in sessions if supports_agent_alerts(session["provider"])]
48
+ unsupported = [session for session in sessions if not supports_agent_alerts(session["provider"])]
49
+ notices = []
50
+ if supported:
51
+ names = _format_bulk_launch_summary(supported)
52
+ if action == "set" and settings["notify"]:
53
+ notices.append(f"Agent alerts enabled for {names} — hooks install on the next interactive launch.")
54
+ else:
55
+ notices.append(f"Agent alerts disabled for {names} — cdx removes its hooks on the next interactive launch.")
56
+ if unsupported:
57
+ names = _format_bulk_launch_summary(unsupported)
58
+ notices.append(f"Agent alerts updated for {names} — this provider does not support notification hooks.")
59
+ return "\n".join(notices)
60
+ if "notify_preview" in settings:
61
+ if action == "set" and settings["notify_preview"]:
62
+ return f"Response previews enabled for {names} — completion text may be visible on the lock screen."
63
+ return f"Response previews disabled for {names}."
64
+ return None
65
+
43
66
  def _apply_launch_settings(parsed, ctx, action="set"):
44
67
  targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
45
68
  sessions = [
@@ -63,6 +86,9 @@ def _apply_launch_settings(parsed, ctx, action="set"):
63
86
  _write_json(ctx, payload)
64
87
  return 0
65
88
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
89
+ notice = _notification_setting_notice(parsed["settings"], sessions, action)
90
+ if notice:
91
+ ctx["out"](f"{notice}\n")
66
92
  if len(sessions) == 1:
67
93
  ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
68
94
  return 0
@@ -90,6 +116,9 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
90
116
  _write_json(ctx, payload)
91
117
  return 0
92
118
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
119
+ notice = _notification_setting_notice({key: False for key in parsed["keys"]}, sessions, action)
120
+ if notice:
121
+ ctx["out"](f"{notice}\n")
93
122
  if len(sessions) == 1:
94
123
  ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
95
124
  return 0
@@ -35,6 +35,7 @@ from ..cli_helpers import (
35
35
  _write_json,
36
36
  _write_update_notice,
37
37
  )
38
+ from ..cli_hints import write_hint
38
39
  from ..cli_render import _dim, _info, _pad_table, _style, _warn
39
40
  from ..commands.launch import handle_launch
40
41
  from ..config import PROVIDER_CLAUDE
@@ -126,7 +127,7 @@ def _format_launch_configs(sessions, use_color=False):
126
127
  for key in ("fallback_model", "budget")
127
128
  )
128
129
  has_extra = any((session.get("launch") or {}).get("extra_args") for session in sessions)
129
- headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL"]
130
+ headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "ALERTS", "MODEL"]
130
131
  if has_headless:
131
132
  headers += ["FALLBACK", "BUDGET"]
132
133
  if has_extra:
@@ -143,6 +144,7 @@ def _format_launch_configs(sessions, use_color=False):
143
144
  _format_launch_setting_value(launch, "fast", use_color),
144
145
  _format_launch_setting_value(launch, "rtk", use_color),
145
146
  _format_launch_setting_value(launch, "logics", use_color),
147
+ _format_launch_setting_value(launch, "notify", use_color),
146
148
  _format_launch_setting_value(launch, "model", use_color),
147
149
  ]
148
150
  if has_headless:
@@ -599,6 +601,7 @@ def handle_status(rest, ctx):
599
601
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
600
602
  _write_refresh_warnings(refresh_errors, ctx, rows=[row])
601
603
  _write_update_notice(ctx)
604
+ write_hint(ctx)
602
605
  return 0
603
606
 
604
607
  rows = ctx["service"]["get_status_rows"](
@@ -618,6 +621,7 @@ def handle_status(rest, ctx):
618
621
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
619
622
  _write_refresh_warnings(refresh_errors, ctx, rows=rows)
620
623
  _write_update_notice(ctx)
624
+ write_hint(ctx)
621
625
  return 0
622
626
 
623
627
  def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):