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.
- package/README.md +64 -4
- package/changelogs/CHANGELOGS_0_17_1.md +29 -0
- package/changelogs/CHANGELOGS_0_18_0.md +82 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +71 -7
- package/src/cli.py +14 -3
- package/src/cli_args.py +5 -2
- package/src/cli_commands.py +3 -0
- package/src/cli_helpers.py +5 -4
- package/src/cli_hints.py +167 -0
- package/src/cli_render.py +1 -0
- package/src/commands/settings.py +29 -0
- package/src/commands/status.py +5 -1
- package/src/commands/tray.py +445 -0
- package/src/notify.py +24 -4
- package/src/session_service.py +13 -1
- package/src/tray_autostart.py +108 -0
- package/src/tray_capability.py +120 -0
- package/src/tray_contract.py +229 -0
- package/src/tray_events.py +210 -0
- package/src/tray_install.py +370 -0
- package/src/tray_instance.py +57 -0
- package/src/tray_shortcut.py +223 -0
package/src/cli_hints.py
ADDED
|
@@ -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)}",
|
package/src/commands/settings.py
CHANGED
|
@@ -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
|
package/src/commands/status.py
CHANGED
|
@@ -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):
|