plexus-python 0.2.0__tar.gz → 0.4.1__tar.gz

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 (37) hide show
  1. {plexus_python-0.2.0 → plexus_python-0.4.1}/AGENTS.md +5 -20
  2. {plexus_python-0.2.0 → plexus_python-0.4.1}/API.md +16 -14
  3. plexus_python-0.4.1/CHANGELOG.md +110 -0
  4. {plexus_python-0.2.0 → plexus_python-0.4.1}/PKG-INFO +53 -2
  5. {plexus_python-0.2.0 → plexus_python-0.4.1}/README.md +50 -1
  6. {plexus_python-0.2.0 → plexus_python-0.4.1}/SECURITY.md +1 -1
  7. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/basic.py +1 -1
  8. {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/__init__.py +3 -2
  9. plexus_python-0.4.1/plexus/cli.py +275 -0
  10. {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/client.py +85 -5
  11. {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/config.py +48 -0
  12. plexus_python-0.4.1/plexus/ws.py +373 -0
  13. {plexus_python-0.2.0 → plexus_python-0.4.1}/pyproject.toml +6 -2
  14. {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/setup.sh +44 -12
  15. {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_retry.py +18 -14
  16. plexus_python-0.4.1/tests/test_ws.py +334 -0
  17. plexus_python-0.2.0/CHANGELOG.md +0 -32
  18. {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/workflows/ci.yml +0 -0
  22. {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/workflows/publish.yml +0 -0
  23. {plexus_python-0.2.0 → plexus_python-0.4.1}/.gitignore +0 -0
  24. {plexus_python-0.2.0 → plexus_python-0.4.1}/CODE_OF_CONDUCT.md +0 -0
  25. {plexus_python-0.2.0 → plexus_python-0.4.1}/CONTRIBUTING.md +0 -0
  26. {plexus_python-0.2.0 → plexus_python-0.4.1}/LICENSE +0 -0
  27. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/README.md +0 -0
  28. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/can.py +0 -0
  29. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/i2c_bme280.py +0 -0
  30. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/mavlink.py +0 -0
  31. {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/mqtt.py +0 -0
  32. {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/buffer.py +0 -0
  33. {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/plexus.service +0 -0
  34. {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/scan_buses.py +0 -0
  35. {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_basic.py +0 -0
  36. {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_buffer.py +0 -0
  37. {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_config.py +0 -0
@@ -4,11 +4,11 @@ Machine-readable interface for AI assistants and automation scripts.
4
4
 
5
5
  ## Environment Variables
6
6
 
7
- | Variable | Description | Default |
8
- | ----------------------- | -------------------------------------------- | -------------------------------- |
9
- | `PLEXUS_API_KEY` | API key for authentication (required) | none |
10
- | `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://plexus-gateway.fly.dev` |
11
- | `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://plexus-gateway.fly.dev` |
7
+ | Variable | Description | Default |
8
+ | ----------------------- | ------------------------------------- | -------------------------------- |
9
+ | `PLEXUS_API_KEY` | API key for authentication (required) | none |
10
+ | `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://plexus-gateway.fly.dev` |
11
+ | `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://plexus-gateway.fly.dev` |
12
12
 
13
13
  ## CLI Commands
14
14
 
@@ -48,21 +48,6 @@ px.send_batch([
48
48
  px = Plexus(api_key="plx_xxxxx", persistent_buffer=True)
49
49
  ```
50
50
 
51
- ## Project Structure
52
-
53
- ```
54
- plexus/
55
- ├── cli.py # CLI entry point (click commands)
56
- ├── tui.py # Live terminal dashboard (Rich)
57
- ├── client.py # Plexus HTTP client (thin SDK)
58
- ├── connector.py # WebSocket daemon
59
- ├── config.py # Config file + env var management
60
- ├── detect.py # Hardware auto-detection
61
- ├── deps.py # Dependency helpers
62
- ├── sensors/ # I2C sensor drivers + SensorHub
63
- └── adapters/ # Protocol adapters: CAN, MAVLink, MQTT, Modbus, OPC-UA, BLE, Serial
64
- ```
65
-
66
51
  ## Key Conventions
67
52
 
68
53
  - Config lives in `~/.plexus/config.json`
@@ -152,34 +152,36 @@ For real-time UI-controlled streaming, devices connect via WebSocket.
152
152
 
153
153
  ### Device Authentication
154
154
 
155
- Devices authenticate using an API key:
155
+ Devices authenticate using an API key. The `source_id` in the request is the device's *desired* name; the server may return a different, auto-suffixed name in the `authenticated` frame if the desired name is already claimed by another device (see [Device identity](../README.md#device-identity) in the README).
156
156
 
157
157
  ```json
158
158
  // Device → Server
159
159
  {
160
160
  "type": "device_auth",
161
161
  "api_key": "plx_xxxxx",
162
- "source_id": "my-device-001",
163
- "platform": "Linux",
164
- "sensors": [
165
- {
166
- "name": "MPU6050",
167
- "description": "6-axis IMU",
168
- "metrics": ["accel_x", "accel_y", "accel_z", "gyro_x", "gyro_y", "gyro_z"],
169
- "sample_rate": 100,
170
- "prefix": "",
171
- "available": true
172
- }
173
- ]
162
+ "source_id": "drone-01",
163
+ "install_id": "c9f2e0b46f4a4f6a8c3e1d5b0a2e7f91",
164
+ "platform": "python-sdk",
165
+ "agent_version": "0.3.1"
174
166
  }
175
167
 
176
168
  // Server → Device
177
169
  {
178
170
  "type": "authenticated",
179
- "source_id": "my-device-001"
171
+ "source_id": "drone-01"
172
+ }
173
+
174
+ // Server → Device (collision case)
175
+ {
176
+ "type": "authenticated",
177
+ "source_id": "drone-01_2"
180
178
  }
181
179
  ```
182
180
 
181
+ The SDK **adopts** whatever `source_id` the server returns and uses it for all subsequent frames, heartbeats, and reconnects. It also persists the assigned name locally so reconnects go straight to the claimed slot.
182
+
183
+ `install_id` is a stable per-installation UUID, generated on the device's first run and saved to `~/.plexus/config.json`. It lets the server distinguish a rebooting device from a new device trying to claim an existing name. Legacy SDKs that omit `install_id` continue to work as before (the server passes the declared `source_id` through unchanged).
184
+
183
185
  ### Message Types (Dashboard → Device)
184
186
 
185
187
  | Type | Description |
@@ -0,0 +1,110 @@
1
+ # Changelog
2
+
3
+ ## [0.4.1] - 2026-04-27 - CI fixes for 0.4.0
4
+
5
+ ### Fixed
6
+
7
+ - `plexus/cli.py` — drop a stray `f` prefix on a non-interpolated string
8
+ that ruff (`F541`) caught in CI.
9
+ - `tests/test_retry.py::test_concurrent_sends` — move `patch.object` out
10
+ of the per-thread closure. `mock.patch.object` mutates instance
11
+ attributes and is not thread-safe; under 20 concurrent threads the
12
+ state would leak and surface as a spurious `AttributeError` on Python
13
+ 3.8.
14
+
15
+ ## [0.4.0] - 2026-04-27 - Stable device identity + CLI
16
+
17
+ The gateway is now authoritative for a device's `source_id`. The SDK sends a
18
+ locally-generated `install_id` in the auth frame; the gateway atomically
19
+ claims `(org, source_id)` and, if the desired name is already owned by a
20
+ different install, returns an auto-suffixed name (`drone-01` → `drone-01_2`
21
+ → `drone-01_3`…) in the `authenticated` frame. The SDK adopts and persists
22
+ the assigned name so subsequent reconnects are stable.
23
+
24
+ This fixes the silent stream-merging that happened when cloned SD-card
25
+ images shared a hostname or when two operators picked the same name.
26
+
27
+ ### Added
28
+
29
+ - `plexus init` (alias `plexus login`) — fly.io / vercel-style browser auth
30
+ flow. Spins up a localhost listener, opens `${PLEXUS_ENDPOINT}/auth/cli`
31
+ with a state-protected callback, and persists the issued key to
32
+ `~/.plexus/config.json`. Console script registered in `pyproject.toml`
33
+ (`plexus = "plexus.cli:main"`); stdlib-only, no new runtime deps.
34
+ - `plexus.config.get_install_id()` — lazy per-installation UUID, persisted
35
+ to `~/.plexus/config.json`. **Not** written by `setup.sh`: it's minted by
36
+ the SDK on first run so pre-baked images get distinct IDs per boot.
37
+ - `PLEXUS_INSTALL_ID` env var — override for `get_install_id()` so
38
+ ephemeral containers (Fly machines, k8s pods, CI runners) can pin a
39
+ stable identity across restarts when the config filesystem is ephemeral.
40
+ Without this, every redeploy gets a fresh UUID and the gateway
41
+ auto-suffixes the source_id.
42
+ - `plexus.config.set_source_id()` — persist the gateway-assigned name after
43
+ auto-suffix resolution.
44
+ - `WebSocketTransport(install_id=..., on_source_id_assigned=...)` — the
45
+ transport sends `install_id` in the `device_auth` frame and invokes the
46
+ callback whenever the gateway returns a different `source_id` than
47
+ requested.
48
+
49
+ ### Changed
50
+
51
+ - `WebSocketTransport` now reads the `source_id` back from the
52
+ `authenticated` frame and updates `self.source_id` in place if the gateway
53
+ auto-suffixed. The rename is logged at INFO level on first occurrence.
54
+ - `Plexus` wires `install_id` into the transport and persists the assigned
55
+ `source_id` to config on rename.
56
+ - `scripts/setup.sh` — `--name` is **required**. The hostname fallback is
57
+ removed (it was the main source of cloned-image collisions). In a TTY the
58
+ script prompts interactively; in non-TTY it exits with an error. Names are
59
+ validated against `^[a-z0-9][a-z0-9_-]{1,62}$`. Stale `plexus start` /
60
+ `plexus reset` hints were dropped.
61
+
62
+ ### Wire-protocol (compatible)
63
+
64
+ - `device_auth` frame gains an optional `install_id` field. The gateway
65
+ treats a missing `install_id` as legacy pass-through, so older SDKs and
66
+ the C SDK continue to work unchanged.
67
+
68
+ ## [0.3.0] - WebSocket transport
69
+
70
+ Adds a wire-compatible WebSocket transport matching the `plexus-c` SDK. WS is now the default; failed sends transparently fall back to `POST /ingest`.
71
+
72
+ ### Added
73
+
74
+ - `plexus.WebSocketTransport` — connects to `/ws/device` on the gateway. Exchanges the same `device_auth` / `authenticated` / `telemetry` / `heartbeat` / `typed_command` / `command_result` frames as `plexus-c`.
75
+ - `Plexus(transport="ws" | "http")` — defaults to `"ws"`.
76
+ - `Plexus.on_command(name, handler, description=..., params=...)` — register command handlers; automatic `ack`, handler return becomes `result`, exceptions become `error`.
77
+ - `Plexus.close()` — stops the WebSocket thread.
78
+ - Runtime dep: `websocket-client>=1.7`.
79
+ - Tests: `tests/test_ws.py` (auth handshake, telemetry, command roundtrip, error paths).
80
+
81
+ ## [0.2.0] - Thin SDK rewrite
82
+
83
+ Breaking. `plexus-python` is now just the thin client — no agent, adapters, sensors, CLI, or TUI. The package is 886 lines with one runtime dependency (`requests`). Protocol integrations (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, I2C sensors) now live as standalone recipes in `examples/`, using the upstream library directly (`pymavlink`, `python-can`, `paho-mqtt`, etc.) plus `px.send()`.
84
+
85
+ ### Added
86
+
87
+ - 5 runnable example scripts: `basic.py`, `mavlink.py`, `can.py`, `mqtt.py`, `i2c_bme280.py`
88
+
89
+ ### Removed
90
+
91
+ - `plexus/adapters/` (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, Serial — use the upstream lib directly)
92
+ - `plexus/sensors/` (I2C drivers + auto-detect — use Adafruit CircuitPython or smbus2 directly)
93
+ - `plexus/cameras/` (frame upload — out of scope)
94
+ - `plexus/cli.py`, `plexus/connector.py`, `plexus/streaming.py`, `plexus/detect.py`, `plexus/tui.py`, `plexus/deps.py`
95
+ - `plexus` console script, `python -m plexus`
96
+ - Extras: `[sensors]`, `[system]`, `[tui]`, `[mqtt]`, `[can]`, `[mavlink]`, `[modbus]`, `[opcua]`, `[ble]`, `[serial]`, `[ros]`, `[camera]`, `[picamera]`, `[all]`
97
+ - Runtime deps: `click`, `websockets`
98
+
99
+ ### Changed
100
+
101
+ - Default ingest endpoint points directly at the Plexus gateway (`https://plexus-gateway.fly.dev/ingest`), not the Next.js app proxy
102
+ - Client raises `ValueError` clearly when no API key is available, instead of invoking a login flow
103
+
104
+ ## [0.1.0] - Initial release
105
+
106
+ - `Plexus` thin client for HTTP ingest
107
+ - `plexus start` daemon with WebSocket streaming
108
+ - Protocol adapters: MAVLink, CAN, MQTT, Modbus, OPC-UA, Serial, BLE
109
+ - I2C sensor auto-detection and drivers
110
+ - Store-and-forward buffering (SQLite)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.2.0
3
+ Version: 0.4.1
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -24,10 +24,12 @@ Classifier: Topic :: Scientific/Engineering
24
24
  Classifier: Topic :: System :: Hardware
25
25
  Requires-Python: >=3.8
26
26
  Requires-Dist: requests>=2.28.0
27
+ Requires-Dist: websocket-client>=1.7
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest; extra == 'dev'
29
30
  Requires-Dist: pytest-cov; extra == 'dev'
30
31
  Requires-Dist: ruff; extra == 'dev'
32
+ Requires-Dist: websockets>=12; extra == 'dev'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # plexus-python
@@ -52,6 +54,21 @@ px.send("temperature", 72.5)
52
54
 
53
55
  Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
54
56
 
57
+ ## Device identity
58
+
59
+ Every device needs a unique `source_id`. The recommended way to set one on a real host is the bootstrap script, which requires a device name up front:
60
+
61
+ ```bash
62
+ curl -sL https://app.plexus.company/setup | bash -s -- \
63
+ --key plx_xxx --name drone-01
64
+ ```
65
+
66
+ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
67
+
68
+ **If two devices end up requesting the same name**, the gateway auto-suffixes: the first connection gets `drone-01`, the second gets `drone-01_2`, the third `drone-01_3`, and so on. The SDK logs the rename at INFO and persists the assigned name to `~/.plexus/config.json` so the device keeps its identity across reboots. Under the hood, a per-installation UUID (`install_id`, lazily generated on first run) is what lets the gateway tell "same device reconnecting" from "different device claiming the same name."
69
+
70
+ In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
71
+
55
72
  ## Usage
56
73
 
57
74
  ```python
@@ -119,13 +136,47 @@ px.buffer_size()
119
136
  px.flush_buffer()
120
137
  ```
121
138
 
139
+ ## Transport
140
+
141
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
142
+
143
+ - lower-latency streaming of telemetry,
144
+ - live command delivery from the UI / API to the device.
145
+
146
+ If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
147
+
148
+ ```python
149
+ # default — ws with http fallback
150
+ px = Plexus()
151
+
152
+ # force http (legacy)
153
+ px = Plexus(transport="http")
154
+ ```
155
+
156
+ ### Handling commands
157
+
158
+ Register a handler before the first `send()` so the command is advertised in the auth frame:
159
+
160
+ ```python
161
+ def reboot(name, params):
162
+ delay = params.get("delay_s", 0)
163
+ # ... reboot logic ...
164
+ return {"ok": True, "delay": delay}
165
+
166
+ px = Plexus()
167
+ px.on_command("reboot", reboot, description="reboot the device")
168
+ px.send("temperature", 72.5) # opens the socket, waits for auth
169
+ ```
170
+
171
+ The SDK sends an `ack` frame before invoking the handler, then a `result` frame with whatever the handler returns (or an `error` frame if it raises).
172
+
122
173
  ## Environment Variables
123
174
 
124
175
  | Variable | Description | Default |
125
176
  | ----------------------- | ---------------------------- | -------------------------------- |
126
177
  | `PLEXUS_API_KEY` | API key (required) | none |
127
178
  | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
128
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL (unused in SDK, for compatibility) | `wss://plexus-gateway.fly.dev` |
179
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
129
180
 
130
181
  ## Architecture
131
182
 
@@ -20,6 +20,21 @@ px.send("temperature", 72.5)
20
20
 
21
21
  Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
22
22
 
23
+ ## Device identity
24
+
25
+ Every device needs a unique `source_id`. The recommended way to set one on a real host is the bootstrap script, which requires a device name up front:
26
+
27
+ ```bash
28
+ curl -sL https://app.plexus.company/setup | bash -s -- \
29
+ --key plx_xxx --name drone-01
30
+ ```
31
+
32
+ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
33
+
34
+ **If two devices end up requesting the same name**, the gateway auto-suffixes: the first connection gets `drone-01`, the second gets `drone-01_2`, the third `drone-01_3`, and so on. The SDK logs the rename at INFO and persists the assigned name to `~/.plexus/config.json` so the device keeps its identity across reboots. Under the hood, a per-installation UUID (`install_id`, lazily generated on first run) is what lets the gateway tell "same device reconnecting" from "different device claiming the same name."
35
+
36
+ In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
37
+
23
38
  ## Usage
24
39
 
25
40
  ```python
@@ -87,13 +102,47 @@ px.buffer_size()
87
102
  px.flush_buffer()
88
103
  ```
89
104
 
105
+ ## Transport
106
+
107
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
108
+
109
+ - lower-latency streaming of telemetry,
110
+ - live command delivery from the UI / API to the device.
111
+
112
+ If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
113
+
114
+ ```python
115
+ # default — ws with http fallback
116
+ px = Plexus()
117
+
118
+ # force http (legacy)
119
+ px = Plexus(transport="http")
120
+ ```
121
+
122
+ ### Handling commands
123
+
124
+ Register a handler before the first `send()` so the command is advertised in the auth frame:
125
+
126
+ ```python
127
+ def reboot(name, params):
128
+ delay = params.get("delay_s", 0)
129
+ # ... reboot logic ...
130
+ return {"ok": True, "delay": delay}
131
+
132
+ px = Plexus()
133
+ px.on_command("reboot", reboot, description="reboot the device")
134
+ px.send("temperature", 72.5) # opens the socket, waits for auth
135
+ ```
136
+
137
+ The SDK sends an `ack` frame before invoking the handler, then a `result` frame with whatever the handler returns (or an `error` frame if it raises).
138
+
90
139
  ## Environment Variables
91
140
 
92
141
  | Variable | Description | Default |
93
142
  | ----------------------- | ---------------------------- | -------------------------------- |
94
143
  | `PLEXUS_API_KEY` | API key (required) | none |
95
144
  | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
96
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL (unused in SDK, for compatibility) | `wss://plexus-gateway.fly.dev` |
145
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
97
146
 
98
147
  ## Architecture
99
148
 
@@ -22,7 +22,7 @@ Instead, email **support@plexus.company** with:
22
22
  ## Supported Versions
23
23
 
24
24
  | Version | Supported |
25
- |---------|-----------|
25
+ | ------- | --------- |
26
26
  | 0.9.x | Yes |
27
27
  | < 0.9 | No |
28
28
 
@@ -11,7 +11,7 @@ import time
11
11
 
12
12
  from plexus import Plexus
13
13
 
14
- px = Plexus(source_id="demo-device")
14
+ px = Plexus(api_key="plx_123...", source_id="demo-device")
15
15
 
16
16
  while True:
17
17
  px.send("temperature", 20 + random.random() * 5)
@@ -8,6 +8,7 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
8
8
  """
9
9
 
10
10
  from plexus.client import Plexus
11
+ from plexus.ws import WebSocketTransport
11
12
 
12
- __version__ = "0.2.0"
13
- __all__ = ["Plexus"]
13
+ __version__ = "0.4.1"
14
+ __all__ = ["Plexus", "WebSocketTransport"]
@@ -0,0 +1,275 @@
1
+ """
2
+ Plexus CLI — `plexus init` style auth, plus a few sibling commands.
3
+
4
+ Designed to feel like fly.io / vercel CLIs:
5
+ $ pip install plexus
6
+ $ plexus init
7
+ Opening browser to https://app.plexus.company/auth/cli...
8
+ ✓ Saved API key as cli-<host>. You're set up.
9
+
10
+ Implementation:
11
+ - Spin up a local HTTP listener on a random free port.
12
+ - Open the browser to /auth/cli with the callback URL embedded.
13
+ - Block until the browser POSTs (well — redirects with key) to /callback.
14
+ - Verify the `state` parameter matches what we generated.
15
+ - Persist the key via plexus.config.save_config; the SDK already reads
16
+ `~/.plexus/config.json` for `PLEXUS_API_KEY`.
17
+
18
+ Stdlib only — keep dependency footprint minimal.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import http.server
25
+ import secrets
26
+ import socket
27
+ import socketserver
28
+ import sys
29
+ import threading
30
+ import urllib.parse
31
+ import webbrowser
32
+ from typing import Optional
33
+
34
+ from . import config
35
+
36
+
37
+ DEFAULT_TIMEOUT_SECONDS = 300
38
+ SUCCESS_HTML = """<!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <title>Plexus CLI</title>
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <style>
45
+ :root { color-scheme: light dark; }
46
+ body {
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
48
+ display: flex; align-items: center; justify-content: center;
49
+ min-height: 100vh; margin: 0; background: Canvas; color: CanvasText;
50
+ }
51
+ .card {
52
+ max-width: 360px; padding: 32px; border: 1px solid #8884;
53
+ border-radius: 12px; text-align: center;
54
+ }
55
+ h1 { margin: 0 0 8px; font-size: 18px; }
56
+ p { margin: 0; color: #888; font-size: 14px; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="card">
61
+ <h1>You're all set</h1>
62
+ <p>Return to your terminal &mdash; the CLI has your key.</p>
63
+ </div>
64
+ </body>
65
+ </html>""".encode("utf-8")
66
+
67
+ ERROR_HTML = """<!doctype html>
68
+ <html><head><meta charset="utf-8" /><title>Plexus CLI</title></head>
69
+ <body><pre style="font-family:ui-monospace,monospace;padding:24px">
70
+ Plexus CLI authorization failed. Return to your terminal for details.
71
+ </pre></body></html>""".encode("utf-8")
72
+
73
+
74
+ class _CallbackResult:
75
+ key: Optional[str] = None
76
+ state: Optional[str] = None
77
+ error: Optional[str] = None
78
+
79
+
80
+ def _pick_free_port() -> int:
81
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
82
+ s.bind(("127.0.0.1", 0))
83
+ return s.getsockname()[1]
84
+
85
+
86
+ def _make_handler(result: _CallbackResult, expected_state: str, done: threading.Event):
87
+ class Handler(http.server.BaseHTTPRequestHandler):
88
+ # Silence the default request log — we don't want CLI noise.
89
+ def log_message(self, *_args, **_kwargs): # type: ignore[override]
90
+ return
91
+
92
+ def do_GET(self): # type: ignore[override]
93
+ parsed = urllib.parse.urlparse(self.path)
94
+ if parsed.path != "/callback":
95
+ self.send_response(404)
96
+ self.end_headers()
97
+ return
98
+
99
+ params = urllib.parse.parse_qs(parsed.query)
100
+ got_state = (params.get("state") or [""])[0]
101
+ got_key = (params.get("key") or [""])[0]
102
+
103
+ if got_state != expected_state:
104
+ result.error = "state mismatch"
105
+ self.send_response(400)
106
+ self.send_header("Content-Type", "text/html; charset=utf-8")
107
+ self.end_headers()
108
+ self.wfile.write(ERROR_HTML)
109
+ done.set()
110
+ return
111
+
112
+ if not got_key:
113
+ result.error = "no key in callback"
114
+ self.send_response(400)
115
+ self.send_header("Content-Type", "text/html; charset=utf-8")
116
+ self.end_headers()
117
+ self.wfile.write(ERROR_HTML)
118
+ done.set()
119
+ return
120
+
121
+ result.key = got_key
122
+ result.state = got_state
123
+ self.send_response(200)
124
+ self.send_header("Content-Type", "text/html; charset=utf-8")
125
+ self.end_headers()
126
+ self.wfile.write(SUCCESS_HTML)
127
+ done.set()
128
+
129
+ return Handler
130
+
131
+
132
+ def _hostname_label() -> str:
133
+ try:
134
+ host = socket.gethostname() or "device"
135
+ except Exception:
136
+ host = "device"
137
+ # Strip the trailing .local etc. and clean it for display.
138
+ safe = host.split(".")[0].lower().replace(" ", "-")
139
+ return f"cli-{safe}" if safe else "cli"
140
+
141
+
142
+ def cmd_init(args: argparse.Namespace) -> int:
143
+ """Open the browser, capture an API key, save it locally."""
144
+ existing = config.get_api_key()
145
+ if existing and not args.force:
146
+ print(
147
+ "An API key is already configured. "
148
+ "Re-run with --force to replace it.",
149
+ file=sys.stderr,
150
+ )
151
+ return 1
152
+
153
+ endpoint = config.get_endpoint().rstrip("/")
154
+ state = secrets.token_urlsafe(24)
155
+ name = args.name or _hostname_label()
156
+ port = _pick_free_port()
157
+ callback = f"http://127.0.0.1:{port}/callback"
158
+
159
+ auth_url = (
160
+ f"{endpoint}/auth/cli"
161
+ f"?state={urllib.parse.quote(state)}"
162
+ f"&callback={urllib.parse.quote(callback)}"
163
+ f"&name={urllib.parse.quote(name)}"
164
+ )
165
+
166
+ result = _CallbackResult()
167
+ done = threading.Event()
168
+ handler = _make_handler(result, state, done)
169
+
170
+ server = socketserver.TCPServer(("127.0.0.1", port), handler)
171
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
172
+ thread.start()
173
+
174
+ try:
175
+ print(f"Opening {auth_url}")
176
+ try:
177
+ webbrowser.open(auth_url, new=1, autoraise=True)
178
+ except Exception:
179
+ pass # User can copy the URL manually.
180
+
181
+ print("Waiting for browser confirmation...", flush=True)
182
+ finished = done.wait(timeout=args.timeout)
183
+ if not finished:
184
+ print(
185
+ f"Timed out after {args.timeout}s. Re-run `plexus init`.",
186
+ file=sys.stderr,
187
+ )
188
+ return 2
189
+ finally:
190
+ server.shutdown()
191
+ server.server_close()
192
+
193
+ if result.error or not result.key:
194
+ print(
195
+ f"Authorization failed: {result.error or 'no key returned'}",
196
+ file=sys.stderr,
197
+ )
198
+ return 3
199
+
200
+ cfg = config.load_config()
201
+ cfg["api_key"] = result.key
202
+ config.save_config(cfg)
203
+ print(f"✓ Saved API key as {name}.")
204
+ print(" ~/.plexus/config.json")
205
+ return 0
206
+
207
+
208
+ def cmd_logout(_args: argparse.Namespace) -> int:
209
+ """Forget the locally stored API key."""
210
+ cfg = config.load_config()
211
+ if not cfg.get("api_key"):
212
+ print("Nothing to do — no key on file.")
213
+ return 0
214
+ cfg["api_key"] = None
215
+ config.save_config(cfg)
216
+ print("✓ Cleared local API key.")
217
+ return 0
218
+
219
+
220
+ def cmd_whoami(_args: argparse.Namespace) -> int:
221
+ """Print the prefix of the locally stored key + the configured endpoint."""
222
+ key = config.get_api_key()
223
+ endpoint = config.get_endpoint()
224
+ if not key:
225
+ print("Not signed in. Run `plexus init` to authorize this machine.")
226
+ return 1
227
+ masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
228
+ print(f"key: {masked}")
229
+ print(f"endpoint: {endpoint}")
230
+ return 0
231
+
232
+
233
+ def build_parser() -> argparse.ArgumentParser:
234
+ parser = argparse.ArgumentParser(
235
+ prog="plexus",
236
+ description="Plexus CLI — auth, send, query telemetry from your terminal.",
237
+ )
238
+ sub = parser.add_subparsers(dest="command", required=True)
239
+
240
+ init = sub.add_parser(
241
+ "init",
242
+ help="Authorize this machine and save an API key locally.",
243
+ aliases=["login"],
244
+ )
245
+ init.add_argument("--name", help="Label for the issued key (default: cli-<hostname>).")
246
+ init.add_argument(
247
+ "--timeout",
248
+ type=int,
249
+ default=DEFAULT_TIMEOUT_SECONDS,
250
+ help="Seconds to wait for the browser callback.",
251
+ )
252
+ init.add_argument(
253
+ "--force",
254
+ action="store_true",
255
+ help="Overwrite an existing local key.",
256
+ )
257
+ init.set_defaults(func=cmd_init)
258
+
259
+ logout = sub.add_parser("logout", help="Forget the local API key.")
260
+ logout.set_defaults(func=cmd_logout)
261
+
262
+ whoami = sub.add_parser("whoami", help="Show the local credential summary.")
263
+ whoami.set_defaults(func=cmd_whoami)
264
+
265
+ return parser
266
+
267
+
268
+ def main(argv: Optional[list] = None) -> int:
269
+ parser = build_parser()
270
+ args = parser.parse_args(argv)
271
+ return args.func(args)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ sys.exit(main())