cursor-agent-beacon 0.3.1__py3-none-any.whl

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.
Files changed (55) hide show
  1. cursor_agent_beacon/__init__.py +3 -0
  2. cursor_agent_beacon/bridge/__init__.py +7 -0
  3. cursor_agent_beacon/bridge/config.py +54 -0
  4. cursor_agent_beacon/bridge/serial_writer.py +88 -0
  5. cursor_agent_beacon/bridge/server.py +137 -0
  6. cursor_agent_beacon/bridge/service.py +88 -0
  7. cursor_agent_beacon/cli.py +289 -0
  8. cursor_agent_beacon/compat.py +13 -0
  9. cursor_agent_beacon/config.py +60 -0
  10. cursor_agent_beacon/doctor.py +353 -0
  11. cursor_agent_beacon/handler.py +71 -0
  12. cursor_agent_beacon/hooks.py +29 -0
  13. cursor_agent_beacon/install.py +258 -0
  14. cursor_agent_beacon/mapper.py +307 -0
  15. cursor_agent_beacon/models.py +91 -0
  16. cursor_agent_beacon/paths.py +60 -0
  17. cursor_agent_beacon/protocol.py +57 -0
  18. cursor_agent_beacon/response.py +31 -0
  19. cursor_agent_beacon/session_registry.py +401 -0
  20. cursor_agent_beacon/setup.py +107 -0
  21. cursor_agent_beacon/sinks/__init__.py +60 -0
  22. cursor_agent_beacon/sinks/base.py +14 -0
  23. cursor_agent_beacon/sinks/file.py +29 -0
  24. cursor_agent_beacon/sinks/http.py +74 -0
  25. cursor_agent_beacon/sinks/log.py +27 -0
  26. cursor_agent_beacon/theme.py +90 -0
  27. cursor_agent_beacon/themes/README.md +60 -0
  28. cursor_agent_beacon/themes/custom/README.md +58 -0
  29. cursor_agent_beacon/themes/custom/example/manifest.json +55 -0
  30. cursor_agent_beacon/themes/standard/ASSETS.md +84 -0
  31. cursor_agent_beacon/themes/standard/ATTRIBUTION.md +10 -0
  32. cursor_agent_beacon/themes/standard/assets/error.gif +0 -0
  33. cursor_agent_beacon/themes/standard/assets/running_mcp.gif +0 -0
  34. cursor_agent_beacon/themes/standard/assets/running_shell.gif +0 -0
  35. cursor_agent_beacon/themes/standard/assets/sleeping.gif +0 -0
  36. cursor_agent_beacon/themes/standard/assets/stop.gif +0 -0
  37. cursor_agent_beacon/themes/standard/assets/success.gif +0 -0
  38. cursor_agent_beacon/themes/standard/assets/thinking.gif +0 -0
  39. cursor_agent_beacon/themes/standard/assets/waiting.gif +0 -0
  40. cursor_agent_beacon/themes/standard/manifest.json +58 -0
  41. cursor_agent_beacon/themes/standard/pixel-faces.json +118 -0
  42. cursor_agent_beacon/vendor/gnome-extension/extension.js +683 -0
  43. cursor_agent_beacon/vendor/gnome-extension/icons/error-symbolic.svg +5 -0
  44. cursor_agent_beacon/vendor/gnome-extension/icons/idle-symbolic.svg +4 -0
  45. cursor_agent_beacon/vendor/gnome-extension/icons/mcp-symbolic.svg +6 -0
  46. cursor_agent_beacon/vendor/gnome-extension/icons/terminal-symbolic.svg +5 -0
  47. cursor_agent_beacon/vendor/gnome-extension/icons/thinking-symbolic.svg +4 -0
  48. cursor_agent_beacon/vendor/gnome-extension/metadata.json +9 -0
  49. cursor_agent_beacon/vendor/gnome-extension/schemas/org.gnome.shell.extensions.cursor-status-panel.gschema.xml +20 -0
  50. cursor_agent_beacon/vendor/gnome-extension/stylesheet.css +133 -0
  51. cursor_agent_beacon-0.3.1.dist-info/METADATA +179 -0
  52. cursor_agent_beacon-0.3.1.dist-info/RECORD +55 -0
  53. cursor_agent_beacon-0.3.1.dist-info/WHEEL +4 -0
  54. cursor_agent_beacon-0.3.1.dist-info/entry_points.txt +2 -0
  55. cursor_agent_beacon-0.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Cursor Agent Beacon — deterministic agent status via Cursor hooks."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,7 @@
1
+ """Local bridge service: HTTP status intake and serial output for ESP32."""
2
+
3
+ from cursor_agent_beacon.bridge.config import BridgeConfig
4
+ from cursor_agent_beacon.bridge.server import run_bridge_server
5
+ from cursor_agent_beacon.bridge.service import BridgeService
6
+
7
+ __all__ = ["BridgeConfig", "BridgeService", "run_bridge_server"]
@@ -0,0 +1,54 @@
1
+ """Environment-driven configuration for the local bridge service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from cursor_agent_beacon.paths import default_themes_dir
10
+
11
+
12
+ def default_bridge_url(host: str = "127.0.0.1", port: int = 8765) -> str:
13
+ return f"http://{host}:{port}/status"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class BridgeConfig:
18
+ """Runtime configuration for the bridge HTTP + serial service."""
19
+
20
+ host: str = "127.0.0.1"
21
+ port: int = 8765
22
+ serial_port: str | None = None
23
+ serial_baud: int = 115200
24
+ theme_id: str = "standard"
25
+ themes_dir: Path = Path("themes")
26
+
27
+ @property
28
+ def status_url(self) -> str:
29
+ return default_bridge_url(self.host, self.port)
30
+
31
+ @classmethod
32
+ def from_env(cls) -> BridgeConfig:
33
+ port_raw = os.environ.get("CURSOR_AGENT_BEACON_BRIDGE_PORT", "8765")
34
+ baud_raw = os.environ.get("CURSOR_AGENT_BEACON_SERIAL_BAUD", "115200")
35
+ serial_port = os.environ.get("CURSOR_AGENT_BEACON_SERIAL_PORT") or None
36
+
37
+ try:
38
+ port = int(port_raw)
39
+ except ValueError:
40
+ port = 8765
41
+
42
+ try:
43
+ baud = int(baud_raw)
44
+ except ValueError:
45
+ baud = 115200
46
+
47
+ return cls(
48
+ host=os.environ.get("CURSOR_AGENT_BEACON_BRIDGE_HOST", "127.0.0.1"),
49
+ port=port,
50
+ serial_port=serial_port,
51
+ serial_baud=baud,
52
+ theme_id=os.environ.get("CURSOR_AGENT_BEACON_THEME", "standard"),
53
+ themes_dir=default_themes_dir(),
54
+ )
@@ -0,0 +1,88 @@
1
+ """Single-owner serial output for ESP32 commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import sys
7
+ import threading
8
+ from typing import Protocol
9
+
10
+
11
+ class SerialWriter(Protocol):
12
+ def write_line(self, line: str) -> None: ...
13
+
14
+ def close(self) -> None: ...
15
+
16
+
17
+ class DryRunSerialWriter:
18
+ """Log serial lines to stderr when no hardware port is configured."""
19
+
20
+ def write_line(self, line: str) -> None:
21
+ print(
22
+ f"[cursor-agent-beacon-bridge] serial: {line}",
23
+ file=sys.stderr,
24
+ flush=True,
25
+ )
26
+
27
+ def close(self) -> None:
28
+ return
29
+
30
+
31
+ class QueuedSerialWriter:
32
+ """Background thread that owns the USB serial port and drains a write queue."""
33
+
34
+ def __init__(self, port: str, baud: int) -> None:
35
+ try:
36
+ import serial
37
+ except ImportError as exc:
38
+ raise RuntimeError(
39
+ "pyserial is required for serial output. "
40
+ 'Install with: pip install -e ".[bridge]"'
41
+ ) from exc
42
+
43
+ self._queue: queue.Queue[str | None] = queue.Queue()
44
+ self._serial = serial.Serial(port, baud, timeout=0.1)
45
+ self._closed = False
46
+ self._thread = threading.Thread(
47
+ target=self._run,
48
+ name="cursor-agent-beacon-serial",
49
+ daemon=True,
50
+ )
51
+ self._thread.start()
52
+
53
+ def write_line(self, line: str) -> None:
54
+ if self._closed:
55
+ return
56
+ self._queue.put(line)
57
+
58
+ def close(self) -> None:
59
+ if self._closed:
60
+ return
61
+ self._closed = True
62
+ self._queue.put(None)
63
+ self._thread.join(timeout=2.0)
64
+ self._serial.close()
65
+
66
+ def _run(self) -> None:
67
+ while True:
68
+ line = self._queue.get()
69
+ if line is None:
70
+ return
71
+ try:
72
+ self._serial.write(f"{line}\n".encode())
73
+ self._serial.flush()
74
+ except OSError as exc:
75
+ print(
76
+ f"[cursor-agent-beacon-bridge] serial write failed: {exc}",
77
+ file=sys.stderr,
78
+ flush=True,
79
+ )
80
+
81
+
82
+ def build_serial_writer(
83
+ serial_port: str | None,
84
+ baud: int,
85
+ ) -> SerialWriter:
86
+ if serial_port:
87
+ return QueuedSerialWriter(serial_port, baud)
88
+ return DryRunSerialWriter()
@@ -0,0 +1,137 @@
1
+ """HTTP server exposing POST /status for the hook handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from cursor_agent_beacon.bridge.service import BridgeService
12
+
13
+ _MAX_BODY_BYTES = 64 * 1024
14
+
15
+
16
+ def run_bridge_server(service: BridgeService, host: str, port: int) -> None:
17
+ handler = _make_handler(service)
18
+ server = ThreadingHTTPServer((host, port), handler)
19
+ server.daemon_threads = True
20
+
21
+ print(
22
+ f"[cursor-agent-beacon-bridge] listening on http://{host}:{port}",
23
+ file=sys.stderr,
24
+ flush=True,
25
+ )
26
+ print(
27
+ f"[cursor-agent-beacon-bridge] POST status to {service.config.status_url}",
28
+ file=sys.stderr,
29
+ flush=True,
30
+ )
31
+ if service.config.serial_port:
32
+ print(
33
+ f"[cursor-agent-beacon-bridge] serial port {service.config.serial_port} "
34
+ f"@ {service.config.serial_baud} baud",
35
+ file=sys.stderr,
36
+ flush=True,
37
+ )
38
+ else:
39
+ print(
40
+ "[cursor-agent-beacon-bridge] serial dry-run (set "
41
+ "CURSOR_AGENT_BEACON_SERIAL_PORT to enable hardware)",
42
+ file=sys.stderr,
43
+ flush=True,
44
+ )
45
+
46
+ try:
47
+ server.serve_forever()
48
+ except KeyboardInterrupt:
49
+ print(
50
+ "\n[cursor-agent-beacon-bridge] shutting down",
51
+ file=sys.stderr,
52
+ flush=True,
53
+ )
54
+ finally:
55
+ server.shutdown()
56
+ server.server_close()
57
+ service.close()
58
+
59
+
60
+ def _make_handler(service: BridgeService) -> type[BaseHTTPRequestHandler]:
61
+ class BridgeHTTPRequestHandler(BaseHTTPRequestHandler):
62
+ server_version = "CursorAgentBeaconBridge/0.2"
63
+
64
+ def log_message(self, format: str, *args) -> None: # noqa: A003
65
+ print(
66
+ f"[cursor-agent-beacon-bridge] {self.address_string()} - "
67
+ f"{format % args}",
68
+ file=sys.stderr,
69
+ flush=True,
70
+ )
71
+
72
+ def do_GET(self) -> None: # noqa: N802
73
+ if self.path == "/health":
74
+ self._json_response(200, service.health())
75
+ return
76
+ if self.path == "/status":
77
+ latest = service.latest_status
78
+ if latest is None:
79
+ self._json_response(404, {"ok": False, "error": "no status yet"})
80
+ return
81
+ self._json_response(200, {"ok": True, "status": latest.to_dict()})
82
+ return
83
+ self._json_response(404, {"ok": False, "error": "not found"})
84
+
85
+ def do_POST(self) -> None: # noqa: N802
86
+ if self.path != "/status":
87
+ self._json_response(404, {"ok": False, "error": "not found"})
88
+ return
89
+
90
+ length_raw = self.headers.get("Content-Length", "0")
91
+ try:
92
+ length = int(length_raw)
93
+ except ValueError:
94
+ self._json_response(
95
+ 400,
96
+ {"ok": False, "error": "invalid Content-Length"},
97
+ )
98
+ return
99
+
100
+ if length > _MAX_BODY_BYTES:
101
+ self._json_response(
102
+ 413,
103
+ {"ok": False, "error": "request body too large"},
104
+ )
105
+ return
106
+
107
+ raw = self.rfile.read(length)
108
+ try:
109
+ payload = json.loads(raw.decode("utf-8") or "{}")
110
+ except json.JSONDecodeError:
111
+ self._json_response(400, {"ok": False, "error": "invalid JSON"})
112
+ return
113
+
114
+ if "state" not in payload:
115
+ self._json_response(400, {"ok": False, "error": "missing state"})
116
+ return
117
+
118
+ try:
119
+ result = service.handle_status(payload)
120
+ except ValueError as exc:
121
+ self._json_response(400, {"ok": False, "error": str(exc)})
122
+ return
123
+ except Exception as exc: # noqa: BLE001
124
+ self._json_response(500, {"ok": False, "error": str(exc)})
125
+ return
126
+
127
+ self._json_response(200, result)
128
+
129
+ def _json_response(self, code: int, payload: dict) -> None:
130
+ body = json.dumps(payload).encode("utf-8")
131
+ self.send_response(code)
132
+ self.send_header("Content-Type", "application/json")
133
+ self.send_header("Content-Length", str(len(body)))
134
+ self.end_headers()
135
+ self.wfile.write(body)
136
+
137
+ return BridgeHTTPRequestHandler
@@ -0,0 +1,88 @@
1
+ """Bridge business logic: theme resolution and serial command emission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Any
7
+
8
+ from cursor_agent_beacon.bridge.config import BridgeConfig
9
+ from cursor_agent_beacon.bridge.serial_writer import SerialWriter, build_serial_writer
10
+ from cursor_agent_beacon.models import AgentStatus
11
+ from cursor_agent_beacon.theme import ThemePack, load_theme
12
+
13
+
14
+ class BridgeService:
15
+ """Resolve theme GIFs and forward normalized status to the serial writer."""
16
+
17
+ def __init__(
18
+ self,
19
+ config: BridgeConfig,
20
+ theme: ThemePack | None = None,
21
+ serial_writer: SerialWriter | None = None,
22
+ ) -> None:
23
+ self._config = config
24
+ self._theme = theme or load_theme(config.theme_id, config.themes_dir)
25
+ self._serial = serial_writer or build_serial_writer(
26
+ config.serial_port,
27
+ config.serial_baud,
28
+ )
29
+ self._lock = threading.Lock()
30
+ self._latest: AgentStatus | None = None
31
+ self._serial.write_line(f"THEME|{self._theme.theme_id}")
32
+
33
+ @property
34
+ def config(self) -> BridgeConfig:
35
+ return self._config
36
+
37
+ @property
38
+ def theme(self) -> ThemePack:
39
+ return self._theme
40
+
41
+ @property
42
+ def latest_status(self) -> AgentStatus | None:
43
+ return self._latest
44
+
45
+ def close(self) -> None:
46
+ self._serial.close()
47
+
48
+ def handle_status(self, payload: dict[str, Any]) -> dict[str, Any]:
49
+ status = AgentStatus.from_dict(payload)
50
+ animation = self._theme.animation_for(
51
+ status.state.value,
52
+ hook_event_name=status.hook_event_name,
53
+ )
54
+
55
+ serial_line = status.serial_line()
56
+ self._serial.write_line(serial_line)
57
+
58
+ with self._lock:
59
+ self._latest = status
60
+
61
+ gif_path: str | None = None
62
+ if animation is not None:
63
+ try:
64
+ gif_path = str(animation.path.relative_to(self._theme.root))
65
+ except ValueError:
66
+ gif_path = str(animation.path)
67
+
68
+ return {
69
+ "ok": True,
70
+ "serial": serial_line,
71
+ "theme_id": self._theme.theme_id,
72
+ "state": status.state.value,
73
+ "message": status.message,
74
+ "gif": gif_path,
75
+ "caption": animation.caption if animation else None,
76
+ "loop": animation.loop if animation else None,
77
+ }
78
+
79
+ def health(self) -> dict[str, Any]:
80
+ latest = self._latest
81
+ return {
82
+ "ok": True,
83
+ "theme_id": self._theme.theme_id,
84
+ "serial_port": self._config.serial_port,
85
+ "serial_baud": self._config.serial_baud,
86
+ "status_url": self._config.status_url,
87
+ "latest": latest.to_dict() if latest else None,
88
+ }
@@ -0,0 +1,289 @@
1
+ """CLI utilities for local testing and installation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from cursor_agent_beacon.handler import (
11
+ handle_hook_event,
12
+ parse_hook_input,
13
+ run_hook_handler,
14
+ )
15
+ from cursor_agent_beacon.mapper import map_hook_event
16
+ from cursor_agent_beacon.models import AgentStatus
17
+
18
+
19
+ def main() -> int:
20
+ parser = argparse.ArgumentParser(
21
+ description="Cursor Agent Beacon hook utilities",
22
+ )
23
+ subparsers = parser.add_subparsers(dest="command", required=True)
24
+
25
+ run_parser = subparsers.add_parser(
26
+ "run",
27
+ help="Run the hook handler (reads JSON from stdin)",
28
+ )
29
+ run_parser.set_defaults(func=_run_handler)
30
+
31
+ map_parser = subparsers.add_parser(
32
+ "map",
33
+ help="Map a sample hook JSON file to agent status",
34
+ )
35
+ map_parser.add_argument("event_file", type=Path)
36
+ map_parser.set_defaults(func=_map_event)
37
+
38
+ bridge_parser = subparsers.add_parser(
39
+ "bridge",
40
+ help="Run the local HTTP + serial bridge service",
41
+ )
42
+ bridge_parser.set_defaults(func=_run_bridge)
43
+
44
+ install_hooks = subparsers.add_parser(
45
+ "install-hooks",
46
+ help="Install merge-safe user-level Cursor hooks",
47
+ )
48
+ install_hooks.set_defaults(func=_install_hooks)
49
+
50
+ install_gnome = subparsers.add_parser(
51
+ "install-gnome",
52
+ help="Install the GNOME Shell status panel extension",
53
+ )
54
+ install_gnome.set_defaults(func=_install_gnome)
55
+
56
+ install_desktop = subparsers.add_parser(
57
+ "install-desktop",
58
+ help="Install user hooks and GNOME panel",
59
+ )
60
+ install_desktop.set_defaults(func=_install_desktop)
61
+
62
+ setup_parser = subparsers.add_parser(
63
+ "setup",
64
+ help="Install user hooks and GNOME panel (after pip install)",
65
+ )
66
+ setup_parser.add_argument(
67
+ "--no-gnome",
68
+ action="store_true",
69
+ help="Skip GNOME Shell extension",
70
+ )
71
+ setup_parser.add_argument(
72
+ "--hooks-only",
73
+ action="store_true",
74
+ help="Install user hooks only",
75
+ )
76
+ setup_parser.add_argument(
77
+ "--beacon-bin",
78
+ type=Path,
79
+ default=None,
80
+ help="Path to cursor-agent-beacon executable for the hook wrapper",
81
+ )
82
+ setup_parser.set_defaults(func=_setup)
83
+
84
+ doctor_parser = subparsers.add_parser(
85
+ "doctor",
86
+ help="Verify hooks, wrapper, and status directory",
87
+ )
88
+ doctor_parser.add_argument(
89
+ "--probe",
90
+ action="store_true",
91
+ help="Run a sample hook event and verify status.json updates",
92
+ )
93
+ doctor_parser.set_defaults(func=_doctor)
94
+
95
+ status_parser = subparsers.add_parser(
96
+ "status",
97
+ help="Show the latest agent status snapshot",
98
+ )
99
+ status_parser.add_argument(
100
+ "--file",
101
+ type=Path,
102
+ default=None,
103
+ help="Status JSON path (default: user status file)",
104
+ )
105
+ status_parser.set_defaults(func=_status)
106
+
107
+ uninstall_parser = subparsers.add_parser(
108
+ "uninstall",
109
+ help="Remove user hooks and optional GNOME panel",
110
+ )
111
+ uninstall_parser.add_argument(
112
+ "--hooks-only",
113
+ action="store_true",
114
+ help="Remove hooks only (skip GNOME extension)",
115
+ )
116
+ uninstall_parser.add_argument(
117
+ "--no-gnome",
118
+ action="store_true",
119
+ help="Skip GNOME extension removal",
120
+ )
121
+ uninstall_parser.add_argument(
122
+ "--purge-status",
123
+ action="store_true",
124
+ help="Delete ~/.local/share/cursor-agent-beacon/",
125
+ )
126
+ uninstall_parser.set_defaults(func=_uninstall)
127
+
128
+ args = parser.parse_args()
129
+ return int(args.func(args))
130
+
131
+
132
+ def _run_handler(_args: argparse.Namespace) -> int:
133
+ return run_hook_handler()
134
+
135
+
136
+ def _map_event(args: argparse.Namespace) -> int:
137
+ payload = json.loads(args.event_file.read_text(encoding="utf-8"))
138
+ event = parse_hook_input(json.dumps(payload))
139
+ status = map_hook_event(event)
140
+ response = handle_hook_event(event, sink=_NullSink())
141
+
142
+ if status is None:
143
+ print("No status mapping for this hook.", file=sys.stderr)
144
+ return 1
145
+
146
+ print(json.dumps(status.to_dict(), indent=2))
147
+ print(json.dumps(response, indent=2))
148
+ return 0
149
+
150
+
151
+ def _run_bridge(_args: argparse.Namespace) -> int:
152
+ from cursor_agent_beacon.bridge.config import BridgeConfig
153
+ from cursor_agent_beacon.bridge.server import run_bridge_server
154
+ from cursor_agent_beacon.bridge.service import BridgeService
155
+
156
+ config = BridgeConfig.from_env()
157
+ service = BridgeService(config)
158
+ run_bridge_server(service, config.host, config.port)
159
+ return 0
160
+
161
+
162
+ def _install_hooks(_args: argparse.Namespace) -> int:
163
+ from cursor_agent_beacon.install import verify_package_installed, write_user_hooks
164
+
165
+ try:
166
+ verify_package_installed()
167
+ hooks_path = write_user_hooks()
168
+ except (RuntimeError, FileNotFoundError) as exc:
169
+ print(f"[cursor-agent-beacon] install-hooks failed: {exc}", file=sys.stderr)
170
+ return 1
171
+
172
+ print(f"Hooks: {hooks_path}")
173
+ print("Restart Cursor after install.")
174
+ return 0
175
+
176
+
177
+ def _install_gnome(_args: argparse.Namespace) -> int:
178
+ from cursor_agent_beacon.install import install_gnome_extension
179
+
180
+ try:
181
+ dest = install_gnome_extension()
182
+ except FileNotFoundError as exc:
183
+ print(f"[cursor-agent-beacon] install-gnome failed: {exc}", file=sys.stderr)
184
+ return 1
185
+
186
+ print(f"GNOME extension: {dest}")
187
+ print("Reload GNOME Shell if the panel does not update.")
188
+ return 0
189
+
190
+
191
+ def _install_desktop(_args: argparse.Namespace) -> int:
192
+ from cursor_agent_beacon.setup import format_next_steps, run_setup
193
+
194
+ try:
195
+ result = run_setup()
196
+ except (RuntimeError, FileNotFoundError) as exc:
197
+ print(f"[cursor-agent-beacon] install-desktop failed: {exc}", file=sys.stderr)
198
+ return 1
199
+
200
+ print(format_next_steps(result))
201
+ return 0
202
+
203
+
204
+ def _setup(args: argparse.Namespace) -> int:
205
+ from cursor_agent_beacon.setup import format_next_steps, run_setup
206
+
207
+ try:
208
+ result = run_setup(
209
+ skip_gnome=args.no_gnome,
210
+ hooks_only=args.hooks_only,
211
+ beacon_bin=args.beacon_bin,
212
+ )
213
+ except (RuntimeError, FileNotFoundError) as exc:
214
+ print(f"[cursor-agent-beacon] setup failed: {exc}", file=sys.stderr)
215
+ return 1
216
+
217
+ print(format_next_steps(result))
218
+ return 0
219
+
220
+
221
+ def _doctor(args: argparse.Namespace) -> int:
222
+ from cursor_agent_beacon.doctor import (
223
+ doctor_exit_code,
224
+ format_doctor_report,
225
+ run_doctor,
226
+ )
227
+
228
+ results = run_doctor(probe=args.probe)
229
+ print(format_doctor_report(results))
230
+ return doctor_exit_code(results)
231
+
232
+
233
+ def _status(args: argparse.Namespace) -> int:
234
+ from cursor_agent_beacon.doctor import format_status_report, read_status_payload
235
+ from cursor_agent_beacon.install import DEFAULT_STATUS_FILE
236
+ from cursor_agent_beacon.session_registry import SessionRegistry
237
+
238
+ path = args.file or DEFAULT_STATUS_FILE
239
+ SessionRegistry(path.parent).reconcile()
240
+ try:
241
+ payload = read_status_payload(path)
242
+ except FileNotFoundError:
243
+ print(
244
+ f"[cursor-agent-beacon] no status at {path}\n"
245
+ "Run an Agent chat in Cursor, or: cursor-agent-beacon doctor --probe",
246
+ file=sys.stderr,
247
+ )
248
+ return 1
249
+ except json.JSONDecodeError as exc:
250
+ print(f"[cursor-agent-beacon] invalid JSON in {path}: {exc}", file=sys.stderr)
251
+ return 1
252
+
253
+ print(format_status_report(payload, status_file=path))
254
+ return 0
255
+
256
+
257
+ def _uninstall(args: argparse.Namespace) -> int:
258
+ from cursor_agent_beacon.install import uninstall_desktop
259
+
260
+ result = uninstall_desktop(
261
+ hooks_only=args.hooks_only,
262
+ skip_gnome=args.no_gnome,
263
+ keep_status=not args.purge_status,
264
+ )
265
+
266
+ lines = ["Uninstall complete.", ""]
267
+ if result["hooks_path"] is not None:
268
+ lines.append(f"Removed hooks from: {result['hooks_path']}")
269
+ else:
270
+ lines.append("Hooks: no beacon entries found")
271
+ if result["gnome_path"] is not None:
272
+ lines.append(f"Removed GNOME panel: {result['gnome_path']}")
273
+ elif not args.hooks_only and not args.no_gnome:
274
+ lines.append("GNOME panel: not installed")
275
+ if result["status_dir"] is not None:
276
+ lines.append(f"Removed status data: {result['status_dir']}")
277
+ lines.append("")
278
+ lines.append("Restart Cursor to stop loading beacon hooks.")
279
+ print("\n".join(lines))
280
+ return 0
281
+
282
+
283
+ class _NullSink:
284
+ def publish(self, status: AgentStatus) -> None:
285
+ return
286
+
287
+
288
+ if __name__ == "__main__":
289
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Python version compatibility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from enum import Enum
7
+
8
+ if sys.version_info >= (3, 11):
9
+ from enum import StrEnum
10
+ else:
11
+
12
+ class StrEnum(str, Enum):
13
+ """Backport of enum.StrEnum for Python 3.10."""