cdx-manager 0.17.1 → 0.18.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.
- package/README.md +50 -1
- package/changelogs/CHANGELOGS_0_18_0.md +82 -0
- package/changelogs/CHANGELOGS_0_18_1.md +50 -0
- package/checksums/release-archives.json +14 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +31 -2
- package/src/cli.py +8 -0
- package/src/cli_args.py +1 -0
- package/src/cli_commands.py +3 -0
- package/src/cli_hints.py +167 -0
- package/src/commands/status.py +3 -0
- package/src/commands/tray.py +445 -0
- package/src/notify.py +24 -4
- 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 +383 -0
- package/src/tray_instance.py +57 -0
- package/src/tray_shortcut.py +223 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
6
6
|
|
|
@@ -513,11 +513,60 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
513
513
|
| `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; `--cached` skips live provider probes and returns only stored status |
|
|
514
514
|
| `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
|
|
515
515
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
516
|
+
| `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
|
|
517
|
+
| `cdx tray launch [--json]` | Start the installed tray companion, detached. Reports `tray_companion_not_installed` when nothing is recorded, or `tray_companion_missing` when the recorded companion is gone. `CDX_TRAY_BIN` points it at a locally built one |
|
|
518
|
+
| `cdx tray install [--json]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, and record what was written. Refuses when no checksum is published for the asset |
|
|
519
|
+
| `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
|
|
520
|
+
| `cdx tray autostart [on\|off\|status] [--json]` | Start the companion at login, or stop doing so. Off until asked, idempotent, and the state is read back from the platform rather than from what CDX last intended |
|
|
521
|
+
| `cdx tray events [--json]` | List agent alerts a running companion has not shown yet. The companion reads these instead of the spool file, so a Windows tray can collect events from CDX in WSL over the same interop it already uses |
|
|
522
|
+
| `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
|
|
523
|
+
| `cdx tray heartbeat [--json]` | Record that a companion is alive. `cdx notify` publishes to the tray only while this is fresh, and delivers directly otherwise |
|
|
524
|
+
| `cdx tray doctor [--json]` | Report companion, executable, version, running instance, autostart, and the two capabilities that fail silently — whether this desktop can draw a tray icon at all, and on Windows whether the Start Menu shortcut toasts depend on exists. Reads only; it never repairs |
|
|
516
525
|
| `cdx --help` | Show usage |
|
|
517
526
|
| `cdx --version` | Show version |
|
|
518
527
|
|
|
519
528
|
---
|
|
520
529
|
|
|
530
|
+
### Tray snapshot
|
|
531
|
+
|
|
532
|
+
`cdx tray status --json` is the contract an optional desktop tray companion reads. It adds nothing to the quota pipeline: it interprets the status CDX already stores.
|
|
533
|
+
|
|
534
|
+
- **Cached by default.** A tray polls, and a live provider probe serializes on the Codex auth lock, which the launcher holds for a whole interactive session. Polling it would be locked out at best and race a rotating OAuth refresh token at worst. Only `--refresh`, a deliberate user gesture, probes.
|
|
535
|
+
- **Freshness is a value, not an error.** Each session reports `fresh`, `stale`, `auth_locked`, or `unknown`. `auth_locked` means a session is running and its quota cannot be refreshed until it exits — pressing refresh will not help, and the snapshot says so instead of ageing silently.
|
|
536
|
+
- **The icon leaks nothing.** `icon` holds a state, a reason, and a count. Session names, providers, and figures live in `sessions`, which a companion shows only once the user opens the menu.
|
|
537
|
+
- **Versions drift on purpose.** The payload carries `schema.major`. A reader meeting a newer major keeps every field it understands and surfaces one update hint, so upgrading CDX never breaks an installed companion.
|
|
538
|
+
|
|
539
|
+
```json
|
|
540
|
+
{
|
|
541
|
+
"schema": { "name": "cdx.tray.snapshot", "major": 1, "minor": 0 },
|
|
542
|
+
"icon": { "state": "low", "reason": "fresh", "session_count": 2 },
|
|
543
|
+
"sessions": [
|
|
544
|
+
{ "name": "work", "provider": "codex", "available_pct": 18, "state": "low", "freshness": "fresh", "reset_at": "..." }
|
|
545
|
+
],
|
|
546
|
+
"refreshable": true,
|
|
547
|
+
"actions": ["refresh", "open_terminal"]
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Icon states are `ok` at 25% remaining and above, `low` below 25%, `critical` below 5%, and `unknown` when nothing was ever reported. The closed icon shows the most urgent session, and a session that never reported never outranks one with real news.
|
|
552
|
+
|
|
553
|
+
### Setup hints
|
|
554
|
+
|
|
555
|
+
`cdx` and `cdx status` may end with one dim line pointing at an optional surface you have not set up yet — the tray companion, or agent alerts. They exist because neither is discoverable from the output of the commands you already run.
|
|
556
|
+
|
|
557
|
+
They are deliberately hard to become noise. One hint per invocation at most, not again for six hours, and each one retires for good after three showings: if you saw it three times and did not act, that is an answer. A hint never appears in `--json`, never appears alongside an update notice, and is answered entirely from local state — nothing here probes a provider or the network. `CDX_NO_HINTS=1` turns them off outright.
|
|
558
|
+
|
|
559
|
+
### Tray companion: what vouches for it
|
|
560
|
+
|
|
561
|
+
The companion is a native binary distributed as a release asset, not part of the pip package. Its trust model is worth stating plainly rather than leaving you to assume the usual one.
|
|
562
|
+
|
|
563
|
+
- **The checksum is the vouching, not Apple or Microsoft.** The macOS bundle is signed with a *self-signed* certificate. That signature exists because Apple Silicon refuses to execute unsigned arm64 code at all — it is not a claim that Apple reviewed anything. What actually vouches for the asset is the SHA-256 published in `checksums/release-archives.json`, verified before a single byte is unpacked. An asset with no published checksum is refused rather than installed hopefully.
|
|
564
|
+
- **Install through `cdx tray install`, and no download link is offered.** This is a security property, not a convenience. A file fetched by CDX carries no macOS quarantine attribute and no Windows Mark-of-the-Web, so nothing puts Gatekeeper or SmartScreen between you and a companion you asked for. The same file fetched through a browser would carry both. That is why no direct download URL is published: it would be a worse path wearing the same clothes.
|
|
565
|
+
- **Nothing is silently replaced.** An update downloads, verifies, and *proves the replacement starts* before the working companion is touched. A corrupt asset or one built for the wrong architecture costs an error message, not a tray you can no longer launch. An interrupted update is a named, recoverable state that `cdx tray doctor` reports.
|
|
566
|
+
- **Uninstall removes only what install recorded**, from a record CDX wrote itself. A damaged record reads as absent rather than being guessed at, because that record drives deletion.
|
|
567
|
+
- **Linux carries no C library.** The Linux asset is statically linked against musl, so there is no glibc version to match and no `gtk`, `libxdo`, or `appindicator` package to install. Tray support is resolved at runtime by looking for the `org.kde.StatusNotifierWatcher` D-Bus name — never by matching a distribution or desktop name. A session without it is told so, and nothing is registered to start at login.
|
|
568
|
+
- **Version drift between CDX and the companion is normal**, since pip upgrades one and `cdx tray install` the other. Whichever is older keeps serving every field it understands and shows one update hint.
|
|
569
|
+
|
|
521
570
|
## JSON Output
|
|
522
571
|
|
|
523
572
|
`cdx-manager` can be consumed by other apps through its CLI JSON contract.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# CDX Manager 0.18.0
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
### An optional desktop tray companion
|
|
6
|
+
|
|
7
|
+
A native menu bar companion now shows remaining quota across every enabled
|
|
8
|
+
session without a terminal open. It is optional, off until installed, and adds
|
|
9
|
+
nothing to the quota pipeline: it reads the status CDX already stores through
|
|
10
|
+
`cdx tray status --json`.
|
|
11
|
+
|
|
12
|
+
`cdx tray` gained `status`, `install`, `launch`, `uninstall`, `autostart`,
|
|
13
|
+
`doctor`, `events`, `ack`, and `heartbeat`.
|
|
14
|
+
|
|
15
|
+
The polling is cached by default and never probes a provider on a timer. A live
|
|
16
|
+
probe serializes on the Codex auth lock, which an interactive session holds for
|
|
17
|
+
its whole run, and races a rotating OAuth refresh token. Only an explicit
|
|
18
|
+
refresh — a user gesture from the menu — probes, and the menu says so when a
|
|
19
|
+
running session makes refreshing pointless.
|
|
20
|
+
|
|
21
|
+
Freshness is reported rather than hidden. Each session reads `fresh`, `stale`,
|
|
22
|
+
`auth_locked`, or `unknown`, and a figure is never invented for a state that
|
|
23
|
+
knows nothing.
|
|
24
|
+
|
|
25
|
+
### Agent alerts delivered through the tray
|
|
26
|
+
|
|
27
|
+
When a companion is running, an agent finishing a turn raises one notification
|
|
28
|
+
from the tray instead of two from the tray and the direct path. The tray keeps a
|
|
29
|
+
bounded list of recent alerts and marks new ones beside the icon for 45 seconds.
|
|
30
|
+
|
|
31
|
+
Delivery falls back to the previous direct path whenever a companion is absent,
|
|
32
|
+
stale, or speaks a schema this CDX does not, so upgrading one side never leaves
|
|
33
|
+
the other silent.
|
|
34
|
+
|
|
35
|
+
On macOS the alert is posted from the signed CDX bundle, so it carries the CDX
|
|
36
|
+
name and icon and can be turned off under CDX in System Settings. On Windows it
|
|
37
|
+
goes out under CDX's own AppUserModelID once `cdx tray install` has written the
|
|
38
|
+
Start Menu shortcut, and under PowerShell's identifier until then.
|
|
39
|
+
|
|
40
|
+
### Setup hints in `cdx` and `cdx status`
|
|
41
|
+
|
|
42
|
+
Both commands may end with one dim line pointing at an optional surface not yet
|
|
43
|
+
set up — the tray companion, or agent alerts. One hint per invocation, not again
|
|
44
|
+
for six hours, retired for good after three showings, never in `--json`, never
|
|
45
|
+
alongside an update notice, and `CDX_NO_HINTS=1` turns them off.
|
|
46
|
+
|
|
47
|
+
## Platform support
|
|
48
|
+
|
|
49
|
+
- **macOS**: signed bundle, menu bar item, native notifications, LaunchAgent
|
|
50
|
+
autostart.
|
|
51
|
+
- **Windows**: taskbar icon that picks its glyph tint from the taskbar theme,
|
|
52
|
+
promotes itself out of the notification overflow once, and reads CDX from WSL
|
|
53
|
+
through interop with no X server and no localhost.
|
|
54
|
+
- **Linux**: StatusNotifierItem through ksni, resolved at runtime by looking for
|
|
55
|
+
`org.kde.StatusNotifierWatcher` rather than by matching a distribution name. A
|
|
56
|
+
desktop without one is told so instead of being left with an invisible icon.
|
|
57
|
+
The asset is statically linked against musl, so there is no glibc version to
|
|
58
|
+
match and no gtk, libxdo, or appindicator package to install.
|
|
59
|
+
|
|
60
|
+
## Trust model
|
|
61
|
+
|
|
62
|
+
`cdx tray install` fetches a release asset and verifies its published SHA-256
|
|
63
|
+
before a single byte is unpacked. The macOS bundle is self-signed: that
|
|
64
|
+
signature exists because Apple Silicon refuses to execute unsigned arm64 code,
|
|
65
|
+
not because Apple reviewed anything, and the published checksum is what vouches
|
|
66
|
+
for the asset. Installing through CDX is also what keeps the file free of the
|
|
67
|
+
macOS quarantine attribute and the Windows Mark of the Web, which is why no
|
|
68
|
+
browser download link is published.
|
|
69
|
+
|
|
70
|
+
An update downloads, verifies, and proves the replacement starts before the
|
|
71
|
+
working companion is touched. A failure leaves the previous one running, and
|
|
72
|
+
`cdx tray doctor` names the interrupted state.
|
|
73
|
+
|
|
74
|
+
## Validation
|
|
75
|
+
|
|
76
|
+
- `npm run lint`
|
|
77
|
+
- `npm test` — 766 Python tests
|
|
78
|
+
- `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on
|
|
79
|
+
aarch64-apple-darwin, x86_64-pc-windows-msvc, and x86_64-unknown-linux-gnu
|
|
80
|
+
- `scripts/tray-smoke.sh` on three hosts: macOS arm64 native, Windows serving
|
|
81
|
+
CDX through Ubuntu WSL, and Linux natively in that same WSL
|
|
82
|
+
- `logics-manager lint` and `logics-manager audit`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# CDX Manager 0.18.1
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
### `cdx tray install` works against a published release
|
|
6
|
+
|
|
7
|
+
0.18.0 shipped a tray installer that refused every asset the release published.
|
|
8
|
+
Three separate causes, each of which only appeared when installing from a real
|
|
9
|
+
release rather than from a locally built archive.
|
|
10
|
+
|
|
11
|
+
**The archive shape was rejected.** Release assets are written with
|
|
12
|
+
`tar -C dir .`, so they carry a member for the directory itself. The guard that
|
|
13
|
+
refuses an archive escaping its install directory treated that member as an
|
|
14
|
+
escape, when it resolves to the destination and nothing else. The destination is
|
|
15
|
+
now accepted; an archive that genuinely writes outside its directory is still
|
|
16
|
+
refused, and a link member still is.
|
|
17
|
+
|
|
18
|
+
**Windows asked for an asset nobody builds.** CDX looked for
|
|
19
|
+
`x86_64-pc-windows-gnu` while releases are built with the MSVC toolchain, so no
|
|
20
|
+
published checksum ever matched the name it asked for. Every Windows install
|
|
21
|
+
refused for want of a checksum that was published under a different target.
|
|
22
|
+
|
|
23
|
+
**ARM Linux was offered and never built.** `aarch64-unknown-linux-musl` was a
|
|
24
|
+
target CDX would install and the release did not produce. It is now built and
|
|
25
|
+
published alongside the others.
|
|
26
|
+
|
|
27
|
+
A test now asserts that every target CDX offers is one the release actually
|
|
28
|
+
builds, so this class of mismatch fails in CI rather than on a user's machine.
|
|
29
|
+
|
|
30
|
+
### Windows toasts carry the CDX icon
|
|
31
|
+
|
|
32
|
+
The Start Menu shortcut CDX installs now points at an icon shipped with the
|
|
33
|
+
companion, so a notification shows the CDX logo rather than a generic one.
|
|
34
|
+
Uninstall removes the shortcut along with the companion.
|
|
35
|
+
|
|
36
|
+
## Release safety
|
|
37
|
+
|
|
38
|
+
Publishing to npm and PyPI now waits for the tray companion assets to build
|
|
39
|
+
successfully. All four workflows fire on the same release event, so previously
|
|
40
|
+
they ran beside each other: a failed asset build would have left both registries
|
|
41
|
+
carrying a version whose release had no companion to install.
|
|
42
|
+
|
|
43
|
+
## Validation
|
|
44
|
+
|
|
45
|
+
- `npm run lint`
|
|
46
|
+
- `npm test` — 771 Python tests
|
|
47
|
+
- `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on three
|
|
48
|
+
targets
|
|
49
|
+
- `cdx tray install`, `doctor`, `launch` and `uninstall` exercised against the
|
|
50
|
+
published v0.18.0 assets on macOS arm64, Windows, and Linux under WSL
|
|
@@ -188,6 +188,20 @@
|
|
|
188
188
|
"v0.17.0": {
|
|
189
189
|
"github_tarball_sha256": "6f321106aa426590ce809788fab4f26401b4cc499a6c518a56a4f24f8bfb6c2e",
|
|
190
190
|
"github_zip_sha256": "72665a0006df40bb27301140b0ed6600a0d91b456facbb5a2aad2a917357ae75"
|
|
191
|
+
},
|
|
192
|
+
"v0.17.1": {
|
|
193
|
+
"github_tarball_sha256": "f1cc36d55f17d8e5c8ad0b0ebc419e812b8788742c3e45ce1137303924114dd7",
|
|
194
|
+
"github_zip_sha256": "29acb306ad76535d0db701504a53b9887b8a6b0b95d968634139f85952a34ec3"
|
|
195
|
+
},
|
|
196
|
+
"v0.18.0": {
|
|
197
|
+
"github_tarball_sha256": "ae666d8ec38a74859e1edc5023101474a2fb647ecb3ba285fd746c7b84699854",
|
|
198
|
+
"github_zip_sha256": "72ecdd02b449c2256a74f77afec7eda8cd9b53b52bb62858e45d3d6000a1bd24",
|
|
199
|
+
"tray_assets": {
|
|
200
|
+
"aarch64-apple-darwin": "45aa98624bda29dea26637aeb22a86b22fe018a95fb176bfffd3504c772c9988",
|
|
201
|
+
"x86_64-apple-darwin": "d310852919cd4e2ddce2108044d0039e088b5bdc3dfa029be4d81ee0df1c420b",
|
|
202
|
+
"x86_64-pc-windows-msvc": "86e29cbdbcf070aec7fd34e5596343a43f2b5092e634ca094aba96abf1d49884",
|
|
203
|
+
"x86_64-unknown-linux-musl": "36ef879c0e75f3c367ed48f1f5a67e25698ec5a04c70958b48ae0885f87487d8"
|
|
204
|
+
}
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -15,6 +15,7 @@ import shutil
|
|
|
15
15
|
import subprocess
|
|
16
16
|
|
|
17
17
|
from .notify import notification_channel, send_desktop_notification
|
|
18
|
+
from .tray_events import publish
|
|
18
19
|
|
|
19
20
|
# Set on the provider process at launch; the hook runs as its child and inherits both.
|
|
20
21
|
SESSION_ENV = "CDX_SESSION_NAME"
|
|
@@ -116,9 +117,16 @@ def handle_notify(rest, ctx):
|
|
|
116
117
|
return 0
|
|
117
118
|
if reader is not None and not ctx.get("stdin_is_tty"):
|
|
118
119
|
stdin_text = reader.read()
|
|
119
|
-
|
|
120
|
+
payload = read_hook_payload(rest, stdin_text)
|
|
121
|
+
composed = compose_notification(payload, env, ctx.get("cwd"))
|
|
120
122
|
if composed:
|
|
121
|
-
|
|
123
|
+
# A live tray becomes the sole owner of this alert, so the user sees
|
|
124
|
+
# one notification rather than two. Publication happens below the
|
|
125
|
+
# composition boundary on purpose: the tray receives the same
|
|
126
|
+
# already-sanitized title and message the direct path would send,
|
|
127
|
+
# and cannot be handed anything the privacy rules removed.
|
|
128
|
+
if not _published_to_tray(ctx, composed, payload):
|
|
129
|
+
send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
|
|
122
130
|
except Exception: # noqa: BLE001 - deliberate: the caller is an agent turn
|
|
123
131
|
pass
|
|
124
132
|
return 0
|
|
@@ -357,3 +365,24 @@ def _write_json(path, document):
|
|
|
357
365
|
# An unwritable home is a skipped provisioning step, not a failed launch.
|
|
358
366
|
return False
|
|
359
367
|
return True
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _published_to_tray(ctx, composed, payload):
|
|
371
|
+
"""Hand the alert to a live tray, or say we did not.
|
|
372
|
+
|
|
373
|
+
False is the safe answer to everything: no base directory, no heartbeat, a
|
|
374
|
+
stale one, a schema mismatch, or an unwritable spool all mean the direct
|
|
375
|
+
path still runs. The only way to lose a notification here would be to
|
|
376
|
+
return True without having written it.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
base_dir = (ctx.get("service") or {}).get("base_dir")
|
|
380
|
+
if not base_dir:
|
|
381
|
+
return False
|
|
382
|
+
event = str(
|
|
383
|
+
payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or ""
|
|
384
|
+
).lower().replace("_", "").replace("-", "")
|
|
385
|
+
kind = "attention" if event in _WAITING_EVENTS else "complete"
|
|
386
|
+
return publish(base_dir, composed[0], composed[1], kind=kind)
|
|
387
|
+
except Exception: # noqa: BLE001 - the caller is an agent turn
|
|
388
|
+
return False
|
package/src/cli.py
CHANGED
|
@@ -51,6 +51,7 @@ from .cli_commands import (
|
|
|
51
51
|
handle_set,
|
|
52
52
|
handle_stats,
|
|
53
53
|
handle_status,
|
|
54
|
+
handle_tray,
|
|
54
55
|
handle_unset,
|
|
55
56
|
handle_update,
|
|
56
57
|
handle_view,
|
|
@@ -59,6 +60,7 @@ from .cli_commands import (
|
|
|
59
60
|
_format_bytes as _format_disk_bytes,
|
|
60
61
|
)
|
|
61
62
|
from .cli_helpers import _update_notice_warnings
|
|
63
|
+
from .cli_hints import write_hint
|
|
62
64
|
from .cli_render import (
|
|
63
65
|
_format_sessions,
|
|
64
66
|
_pad_table,
|
|
@@ -171,6 +173,7 @@ _COMMAND_HANDLERS = {
|
|
|
171
173
|
"set": handle_set,
|
|
172
174
|
"stats": handle_stats,
|
|
173
175
|
"status": handle_status,
|
|
176
|
+
"tray": handle_tray,
|
|
174
177
|
"unset": handle_unset,
|
|
175
178
|
"update": handle_update,
|
|
176
179
|
"view": handle_view,
|
|
@@ -257,6 +260,9 @@ def _print_help(use_color=False):
|
|
|
257
260
|
f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]', '36', use_color)}",
|
|
258
261
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
259
262
|
f" {_style('cdx view [--json]', '36', use_color)}",
|
|
263
|
+
f" {_style('cdx tray status [--json] [--refresh]', '36', use_color)}",
|
|
264
|
+
f" {_style('cdx tray install|launch|uninstall|doctor [--json]', '36', use_color)}",
|
|
265
|
+
f" {_style('cdx tray autostart [on|off|status] [--json]', '36', use_color)}",
|
|
260
266
|
f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
261
267
|
f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
|
|
262
268
|
"",
|
|
@@ -551,6 +557,8 @@ def main(argv, options=None):
|
|
|
551
557
|
warning_text = _update_warning_text(notices)
|
|
552
558
|
if warning_text:
|
|
553
559
|
out(f"{_style(warning_text, '33', use_color)}\n")
|
|
560
|
+
# Last line, after anything the user actually asked for.
|
|
561
|
+
write_hint({"env": env, "out": out, "service": service, "use_color": use_color, "update_notices": notices})
|
|
554
562
|
return 0
|
|
555
563
|
|
|
556
564
|
command, *rest = argv
|
package/src/cli_args.py
CHANGED
|
@@ -63,6 +63,7 @@ RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
|
|
|
63
63
|
RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
|
|
64
64
|
RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
|
|
65
65
|
SCHEMA_USAGE = "Usage: cdx schema --json"
|
|
66
|
+
TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install|launch|uninstall|doctor [--json] | cdx tray autostart [on|off|status] [--json] | cdx tray events [--json] | cdx tray ack <id>... [--json] | cdx tray heartbeat [--json]"
|
|
66
67
|
|
|
67
68
|
RUN_TAIL_DEFAULT_LINES = 50
|
|
68
69
|
RUN_TAIL_MAX_LINES = 2000
|
package/src/cli_commands.py
CHANGED
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/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
|
|
@@ -600,6 +601,7 @@ def handle_status(rest, ctx):
|
|
|
600
601
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
601
602
|
_write_refresh_warnings(refresh_errors, ctx, rows=[row])
|
|
602
603
|
_write_update_notice(ctx)
|
|
604
|
+
write_hint(ctx)
|
|
603
605
|
return 0
|
|
604
606
|
|
|
605
607
|
rows = ctx["service"]["get_status_rows"](
|
|
@@ -619,6 +621,7 @@ def handle_status(rest, ctx):
|
|
|
619
621
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
|
|
620
622
|
_write_refresh_warnings(refresh_errors, ctx, rows=rows)
|
|
621
623
|
_write_update_notice(ctx)
|
|
624
|
+
write_hint(ctx)
|
|
622
625
|
return 0
|
|
623
626
|
|
|
624
627
|
def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
|