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.
- cursor_agent_beacon/__init__.py +3 -0
- cursor_agent_beacon/bridge/__init__.py +7 -0
- cursor_agent_beacon/bridge/config.py +54 -0
- cursor_agent_beacon/bridge/serial_writer.py +88 -0
- cursor_agent_beacon/bridge/server.py +137 -0
- cursor_agent_beacon/bridge/service.py +88 -0
- cursor_agent_beacon/cli.py +289 -0
- cursor_agent_beacon/compat.py +13 -0
- cursor_agent_beacon/config.py +60 -0
- cursor_agent_beacon/doctor.py +353 -0
- cursor_agent_beacon/handler.py +71 -0
- cursor_agent_beacon/hooks.py +29 -0
- cursor_agent_beacon/install.py +258 -0
- cursor_agent_beacon/mapper.py +307 -0
- cursor_agent_beacon/models.py +91 -0
- cursor_agent_beacon/paths.py +60 -0
- cursor_agent_beacon/protocol.py +57 -0
- cursor_agent_beacon/response.py +31 -0
- cursor_agent_beacon/session_registry.py +401 -0
- cursor_agent_beacon/setup.py +107 -0
- cursor_agent_beacon/sinks/__init__.py +60 -0
- cursor_agent_beacon/sinks/base.py +14 -0
- cursor_agent_beacon/sinks/file.py +29 -0
- cursor_agent_beacon/sinks/http.py +74 -0
- cursor_agent_beacon/sinks/log.py +27 -0
- cursor_agent_beacon/theme.py +90 -0
- cursor_agent_beacon/themes/README.md +60 -0
- cursor_agent_beacon/themes/custom/README.md +58 -0
- cursor_agent_beacon/themes/custom/example/manifest.json +55 -0
- cursor_agent_beacon/themes/standard/ASSETS.md +84 -0
- cursor_agent_beacon/themes/standard/ATTRIBUTION.md +10 -0
- cursor_agent_beacon/themes/standard/assets/error.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/running_mcp.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/running_shell.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/sleeping.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/stop.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/success.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/thinking.gif +0 -0
- cursor_agent_beacon/themes/standard/assets/waiting.gif +0 -0
- cursor_agent_beacon/themes/standard/manifest.json +58 -0
- cursor_agent_beacon/themes/standard/pixel-faces.json +118 -0
- cursor_agent_beacon/vendor/gnome-extension/extension.js +683 -0
- cursor_agent_beacon/vendor/gnome-extension/icons/error-symbolic.svg +5 -0
- cursor_agent_beacon/vendor/gnome-extension/icons/idle-symbolic.svg +4 -0
- cursor_agent_beacon/vendor/gnome-extension/icons/mcp-symbolic.svg +6 -0
- cursor_agent_beacon/vendor/gnome-extension/icons/terminal-symbolic.svg +5 -0
- cursor_agent_beacon/vendor/gnome-extension/icons/thinking-symbolic.svg +4 -0
- cursor_agent_beacon/vendor/gnome-extension/metadata.json +9 -0
- cursor_agent_beacon/vendor/gnome-extension/schemas/org.gnome.shell.extensions.cursor-status-panel.gschema.xml +20 -0
- cursor_agent_beacon/vendor/gnome-extension/stylesheet.css +133 -0
- cursor_agent_beacon-0.3.1.dist-info/METADATA +179 -0
- cursor_agent_beacon-0.3.1.dist-info/RECORD +55 -0
- cursor_agent_beacon-0.3.1.dist-info/WHEEL +4 -0
- cursor_agent_beacon-0.3.1.dist-info/entry_points.txt +2 -0
- cursor_agent_beacon-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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."""
|