qapu-cli 0.1.0__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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: qapu-cli
3
+ Version: 0.1.0
4
+ Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
5
+ Author: OVOO Technology
6
+ Classifier: Programming Language :: Python :: 3
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer<1.0,>=0.12
10
+ Requires-Dist: httpx<1.0,>=0.27
11
+ Requires-Dist: rich<16.0,>=13.0
12
+
13
+ # Qapu CLI
14
+
15
+ A thin command-line client for the Qapu API (`api.ovoo.com.tr`), built for the Hermes agent (runs outside this Swarm, in a separate datacenter, and only ever talks to Qapu through this public API) - but usable by anything else that needs to script against Qapu from outside the private network.
16
+
17
+ **Status: early development.** Gated by a temporary shared-secret header, not real auth yet - see "Auth (current placeholder)" below before using this against production.
18
+
19
+ ## Why a separate `tools/` project, not a `services/`
20
+
21
+ This isn't a deployed backend service - it's a distributable client tool, installed wherever Hermes (or anyone else) runs. Kept in the same monorepo (per `CLAUDE.md`'s ADR-0001 - one repo, low context-switching for a 2-person team) rather than its own repo, since it's small and needs to stay in sync with the API it calls.
22
+
23
+ ## Install
24
+
25
+ **Anyone with GitHub access to this (private) repo - no local clone needed**, `pip` installs straight from the `tools/cli` subdirectory over git:
26
+
27
+ ```bash
28
+ pip install "git+ssh://git@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"
29
+ ```
30
+
31
+ (needs an SSH key already authorized on your GitHub account for this repo - the usual case for anyone on the team. No SSH key set up? Use an HTTPS Personal Access Token instead: `pip install "git+https://<PAT>@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"`.)
32
+
33
+ **Working on the CLI itself** (this repo already checked out) - editable install so local edits take effect immediately:
34
+
35
+ ```bash
36
+ cd tools/cli
37
+ pip install -e .
38
+ ```
39
+
40
+ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`). This package has no dependency on the rest of the monorepo (`qapu_common` etc.) - it only ever talks to Qapu over HTTP, never imports it directly - which is exactly what makes the git-subdirectory install above work without cloning anything else.
41
+
42
+ ## Configuration
43
+
44
+ | Env var | Purpose |
45
+ |---|---|
46
+ | `QAPU_API_URL` | Base URL. Defaults to `https://api.ovoo.com.tr`. Point at `http://localhost:8000` or an internal IP for local/dev testing. |
47
+ | `QAPU_HERMES_KEY` | Shared-secret value for the `X-Hermes-Key` header - see "Auth" below. Required for every command except `qapu health`. |
48
+
49
+ ## Commands
50
+
51
+ ```bash
52
+ qapu health # GET /health - no auth, quick connectivity check
53
+ qapu device list # GET /hermes/devices - every device, bulk, as a table
54
+ qapu device list --json # same data, raw JSON
55
+ qapu device list --status online # only devices whose Update_Time moved in the last 30 min (see ONLINE_THRESHOLD_MINUTES in main.py - there's no real online/offline field, this is a heuristic)
56
+ qapu device list --status offline
57
+ qapu device list --model B107AA_R5 # case-insensitive substring match on Hardware.Model.Name
58
+ qapu device list --limit 100
59
+ qapu device get <device_id> # GET /hermes/devices/{device_id} - one device, human-readable summary
60
+ qapu device get <device_id> --json # same data, raw JSON
61
+ ```
62
+
63
+ Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
64
+
65
+ ## Auth (current placeholder - read this before pointing at production)
66
+
67
+ `api.ovoo.com.tr` is genuinely public on the internet. The Hermes endpoints (`services/api/src/routers/hermes.py`) are gated by `require_hermes_key` (`services/api/src/dependencies.py`) - a single shared-secret string compared against the `X-Hermes-Key` header, checked via the `HERMES_SHARED_SECRET` env var on the API side. This is **deliberately temporary**: it exists only so the CLI/API plumbing could be built and tested end-to-end before the real auth design was ready, not because a shared secret is considered good enough long-term.
68
+
69
+ **Real plan** (not built yet - phase 2, along with the score/comment table Hermes will eventually write to): an admin-role `hermes-qapu` account in the `users` table, with the CLI gaining a `qapu login` command that authenticates through the existing `JWT_Auth` flow every other Qapu client already uses, storing a short-lived token instead of a static shared secret. `client.py` is written so only it needs to change when that lands - nothing in `main.py` should need to know how auth works under the hood.
70
+
71
+ Until then: `HERMES_SHARED_SECRET` fails closed (unset = every Hermes request rejected, never silently open), but a leaked shared-secret string is a much blunter credential than a scoped, revocable JWT - don't treat this as production-grade access control.
72
+
73
+ ## Running locally
74
+
75
+ ```bash
76
+ QAPU_API_URL=http://localhost:8000 QAPU_HERMES_KEY=dev-secret python -m qapu_cli.main device list
77
+ ```
78
+
79
+ (or, once installed via `pip install -e .`: just `qapu devices list` with the same env vars set.)
@@ -0,0 +1,67 @@
1
+ # Qapu CLI
2
+
3
+ A thin command-line client for the Qapu API (`api.ovoo.com.tr`), built for the Hermes agent (runs outside this Swarm, in a separate datacenter, and only ever talks to Qapu through this public API) - but usable by anything else that needs to script against Qapu from outside the private network.
4
+
5
+ **Status: early development.** Gated by a temporary shared-secret header, not real auth yet - see "Auth (current placeholder)" below before using this against production.
6
+
7
+ ## Why a separate `tools/` project, not a `services/`
8
+
9
+ This isn't a deployed backend service - it's a distributable client tool, installed wherever Hermes (or anyone else) runs. Kept in the same monorepo (per `CLAUDE.md`'s ADR-0001 - one repo, low context-switching for a 2-person team) rather than its own repo, since it's small and needs to stay in sync with the API it calls.
10
+
11
+ ## Install
12
+
13
+ **Anyone with GitHub access to this (private) repo - no local clone needed**, `pip` installs straight from the `tools/cli` subdirectory over git:
14
+
15
+ ```bash
16
+ pip install "git+ssh://git@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"
17
+ ```
18
+
19
+ (needs an SSH key already authorized on your GitHub account for this repo - the usual case for anyone on the team. No SSH key set up? Use an HTTPS Personal Access Token instead: `pip install "git+https://<PAT>@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"`.)
20
+
21
+ **Working on the CLI itself** (this repo already checked out) - editable install so local edits take effect immediately:
22
+
23
+ ```bash
24
+ cd tools/cli
25
+ pip install -e .
26
+ ```
27
+
28
+ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`). This package has no dependency on the rest of the monorepo (`qapu_common` etc.) - it only ever talks to Qapu over HTTP, never imports it directly - which is exactly what makes the git-subdirectory install above work without cloning anything else.
29
+
30
+ ## Configuration
31
+
32
+ | Env var | Purpose |
33
+ |---|---|
34
+ | `QAPU_API_URL` | Base URL. Defaults to `https://api.ovoo.com.tr`. Point at `http://localhost:8000` or an internal IP for local/dev testing. |
35
+ | `QAPU_HERMES_KEY` | Shared-secret value for the `X-Hermes-Key` header - see "Auth" below. Required for every command except `qapu health`. |
36
+
37
+ ## Commands
38
+
39
+ ```bash
40
+ qapu health # GET /health - no auth, quick connectivity check
41
+ qapu device list # GET /hermes/devices - every device, bulk, as a table
42
+ qapu device list --json # same data, raw JSON
43
+ qapu device list --status online # only devices whose Update_Time moved in the last 30 min (see ONLINE_THRESHOLD_MINUTES in main.py - there's no real online/offline field, this is a heuristic)
44
+ qapu device list --status offline
45
+ qapu device list --model B107AA_R5 # case-insensitive substring match on Hardware.Model.Name
46
+ qapu device list --limit 100
47
+ qapu device get <device_id> # GET /hermes/devices/{device_id} - one device, human-readable summary
48
+ qapu device get <device_id> --json # same data, raw JSON
49
+ ```
50
+
51
+ Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
52
+
53
+ ## Auth (current placeholder - read this before pointing at production)
54
+
55
+ `api.ovoo.com.tr` is genuinely public on the internet. The Hermes endpoints (`services/api/src/routers/hermes.py`) are gated by `require_hermes_key` (`services/api/src/dependencies.py`) - a single shared-secret string compared against the `X-Hermes-Key` header, checked via the `HERMES_SHARED_SECRET` env var on the API side. This is **deliberately temporary**: it exists only so the CLI/API plumbing could be built and tested end-to-end before the real auth design was ready, not because a shared secret is considered good enough long-term.
56
+
57
+ **Real plan** (not built yet - phase 2, along with the score/comment table Hermes will eventually write to): an admin-role `hermes-qapu` account in the `users` table, with the CLI gaining a `qapu login` command that authenticates through the existing `JWT_Auth` flow every other Qapu client already uses, storing a short-lived token instead of a static shared secret. `client.py` is written so only it needs to change when that lands - nothing in `main.py` should need to know how auth works under the hood.
58
+
59
+ Until then: `HERMES_SHARED_SECRET` fails closed (unset = every Hermes request rejected, never silently open), but a leaked shared-secret string is a much blunter credential than a scoped, revocable JWT - don't treat this as production-grade access control.
60
+
61
+ ## Running locally
62
+
63
+ ```bash
64
+ QAPU_API_URL=http://localhost:8000 QAPU_HERMES_KEY=dev-secret python -m qapu_cli.main device list
65
+ ```
66
+
67
+ (or, once installed via `pip install -e .`: just `qapu devices list` with the same env vars set.)
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "qapu-cli"
3
+ version = "0.1.0"
4
+ description = "CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [
8
+ { name = "OVOO Technology" },
9
+ ]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ ]
13
+ dependencies = [
14
+ "typer>=0.12,<1.0",
15
+ "httpx>=0.27,<1.0",
16
+ "rich>=13.0,<16.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ qapu = "qapu_cli.main:app"
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=68"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["qapu_cli*"]
File without changes
@@ -0,0 +1,71 @@
1
+ # Thin HTTP client wrapping calls to the Qapu API (api.ovoo.com.tr) -
2
+ # every command in main.py goes through this, so there's exactly one place
3
+ # that knows about base URLs, headers, and error handling.
4
+ #
5
+ # Auth is a placeholder for now (X-Hermes-Key shared-secret header, see
6
+ # services/api/src/dependencies.py's require_hermes_key) - this file is
7
+ # where a real login/JWT flow will go once the "hermes-qapu" admin account
8
+ # exists. Callers elsewhere in this package shouldn't need to change when
9
+ # that happens, only this file.
10
+
11
+ import os
12
+ import sys
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ # Config - env vars, no config file yet (keep it simple until there's a
18
+ # real reason not to: multiple environments, saved credentials, etc.)
19
+ DEFAULT_BASE_URL = "https://api.ovoo.com.tr"
20
+
21
+
22
+ def _base_url() -> str:
23
+
24
+ # Resolve Base URL from Environment, Falling Back to Production
25
+ return os.getenv("QAPU_API_URL", DEFAULT_BASE_URL).rstrip("/")
26
+
27
+
28
+ def _headers() -> dict[str, str]:
29
+
30
+ # Resolve the Shared-Secret Header - Required for Every Hermes Endpoint
31
+ key = os.getenv("QAPU_HERMES_KEY")
32
+
33
+ # Fail Clearly, Not with a Confusing 401 from the Server
34
+ if not key:
35
+ print("Error: QAPU_HERMES_KEY environment variable is not set.", file=sys.stderr)
36
+ raise SystemExit(1)
37
+
38
+ # Return Headers
39
+ return {"X-Hermes-Key": key}
40
+
41
+
42
+ def get(path: str, params: dict[str, Any] | None = None) -> Any:
43
+
44
+ # Build Full URL
45
+ url = f"{_base_url()}{path}"
46
+
47
+ # Try the Request
48
+ try:
49
+
50
+ # Issue the GET Request
51
+ response = httpx.get(url, headers=_headers(), params=params, timeout=30)
52
+
53
+ # Raise on a Non-2xx Status
54
+ response.raise_for_status()
55
+
56
+ # Return Decoded JSON
57
+ return response.json()
58
+
59
+ # Catch and Re-Report HTTP Errors Clearly
60
+ except httpx.HTTPStatusError as e:
61
+
62
+ # Print a Clear Error and Exit
63
+ print(f"Error: {e.response.status_code} from {url} - {e.response.text}", file=sys.stderr)
64
+ raise SystemExit(1)
65
+
66
+ # Catch and Re-Report Connection Errors Clearly
67
+ except httpx.RequestError as e:
68
+
69
+ # Print a Clear Error and Exit
70
+ print(f"Error: could not reach {url} - {e}", file=sys.stderr)
71
+ raise SystemExit(1)
@@ -0,0 +1,296 @@
1
+ # Qapu CLI - built for the Hermes agent's hourly per-device pull, but
2
+ # usable by anyone who needs to talk to the Qapu API (api.ovoo.com.tr)
3
+ # from outside the Swarm. See client.py for the HTTP layer, README.md for
4
+ # setup and the auth placeholder this currently relies on.
5
+
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Optional
9
+
10
+ import httpx
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from qapu_cli import client
16
+
17
+ app = typer.Typer(no_args_is_help=True, help="Qapu API client CLI.")
18
+ device_app = typer.Typer(no_args_is_help=True, help="Device-related commands.")
19
+ app.add_typer(device_app, name="device")
20
+
21
+ console = Console()
22
+
23
+ # There Is No Real "Online/Offline" Field on a Device - Derived Here from
24
+ # How Recently `Update_Time` Moved, Since `data`'s process_message() Calls
25
+ # Device.update() on Every Packet a Device Sends (Confirmed via
26
+ # database/models.py's `onupdate=func.now()` on that column). A device
27
+ # whose metadata was hand-edited recently would also count as "Online" by
28
+ # this measure - an accepted, minor false positive, not a real signal.
29
+ ONLINE_THRESHOLD_MINUTES = 30
30
+
31
+
32
+ # Quick, No-Auth Connectivity Check - Confirms the CLI can Reach the API
33
+ # at all, Independent of the Hermes Key, Before Debugging Anything Else
34
+ @app.command()
35
+ def health():
36
+ """Check that the configured Qapu API is reachable (GET /health, no auth required)."""
37
+
38
+ # Build the Health URL Directly - This One Endpoint Genuinely Has no Auth
39
+ url = f"{client._base_url()}/health"
40
+
41
+ # Try the Request
42
+ try:
43
+
44
+ # Issue the GET Request
45
+ response = httpx.get(url, timeout=10)
46
+
47
+ # Raise on a Non-2xx Status
48
+ response.raise_for_status()
49
+
50
+ # Print the Result
51
+ typer.echo(json.dumps(response.json(), indent=2))
52
+
53
+ # Catch and Report Any Failure
54
+ except httpx.HTTPError as e:
55
+
56
+ # Print a Clear Error and Exit
57
+ typer.echo(f"Error: {e}", err=True)
58
+ raise typer.Exit(code=1)
59
+
60
+
61
+ # Resolve Whether a Device Counts as "Online" by the Update_Time Heuristic
62
+ def _is_online(device: dict[str, Any]) -> bool:
63
+
64
+ # Resolve the Raw Timestamp String
65
+ update_time_raw = device.get("Update_Time")
66
+
67
+ # No Timestamp at all - Treat as Offline
68
+ if not update_time_raw:
69
+ return False
70
+
71
+ # Try to Parse and Compare
72
+ try:
73
+
74
+ # Parse ISO Timestamp
75
+ update_time = datetime.fromisoformat(update_time_raw)
76
+
77
+ # Compare Against the Threshold
78
+ age_minutes = (datetime.now(timezone.utc) - update_time.astimezone(timezone.utc)).total_seconds() / 60
79
+ return age_minutes <= ONLINE_THRESHOLD_MINUTES
80
+
81
+ # Unparseable Timestamp - Treat as Offline Rather than Guessing
82
+ except (ValueError, TypeError):
83
+ return False
84
+
85
+
86
+ # Render Update_Time as a Human-Friendly Relative String ("Last Connection")
87
+ def _relative_time(raw: Optional[str]) -> str:
88
+
89
+ # No Timestamp at all
90
+ if not raw:
91
+ return "-"
92
+
93
+ # Try to Parse and Format
94
+ try:
95
+
96
+ # Parse ISO Timestamp
97
+ update_time = datetime.fromisoformat(raw).astimezone(timezone.utc)
98
+
99
+ # Compute Elapsed Seconds
100
+ elapsed = (datetime.now(timezone.utc) - update_time).total_seconds()
101
+
102
+ # Format the Largest Sensible Unit - Always Minutes-or-Coarser, No
103
+ # "Just Now" Bucket (a Fresh Connection Shows as "0 dk önce")
104
+ if elapsed < 3600:
105
+ return f"{int(elapsed // 60)} dk önce"
106
+ if elapsed < 86400:
107
+ return f"{int(elapsed // 3600)} sa önce"
108
+ return f"{int(elapsed // 86400)} gün önce"
109
+
110
+ # Unparseable Timestamp
111
+ except (ValueError, TypeError):
112
+ return "-"
113
+
114
+
115
+ # List Every Device (Bulk, No Ownership Scoping) - the Endpoint Hermes'
116
+ # Hourly Pull Actually Uses
117
+ # Default Page Size for the Interactive Table View - Only Applies When the
118
+ # Caller Didn't Pass an Explicit --limit (an Explicit Limit Means They
119
+ # Already Know How Many They Want, no Need to Also Paginate That)
120
+ DEFAULT_PAGE_SIZE = 20
121
+
122
+
123
+ # Render One Page of Devices as a Rich Table
124
+ def _build_device_table(devices: list[dict[str, Any]]) -> Table:
125
+
126
+ # Build the Table
127
+ table = Table(show_header=True, header_style="bold")
128
+ table.add_column("DEVICE ID")
129
+ table.add_column("NAME")
130
+ table.add_column("STATUS")
131
+ table.add_column("FIRMWARE")
132
+ table.add_column("LAST CONNECTION")
133
+
134
+ # Populate Rows
135
+ for d in devices:
136
+
137
+ # Resolve Each Column, Defensively - Any of These Can Be Missing
138
+ device_id = d.get("ID") or "-"
139
+ name = d.get("Name") or "-"
140
+ online = _is_online(d)
141
+ status_label = "[green]Online[/green]" if online else "[red]Offline[/red]"
142
+ firmware = d.get("Version") or "-"
143
+ last_connection = _relative_time(d.get("Update_Time"))
144
+
145
+ # Add the Row
146
+ table.add_row(device_id, name, status_label, firmware, last_connection)
147
+
148
+ # Return the Table
149
+ return table
150
+
151
+
152
+ @device_app.command("list")
153
+ def device_list(
154
+ as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of a table. Always prints everything at once, no pagination - meant for scripting/Hermes, not a human terminal."),
155
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by connectivity: 'online' or 'offline' (see ONLINE_THRESHOLD_MINUTES)."),
156
+ model: Optional[str] = typer.Option(None, "--model", help="Filter by hardware model name (case-insensitive substring match)."),
157
+ limit: Optional[int] = typer.Option(None, "--limit", help="Cap the number of devices shown. Given explicitly, this replaces the default interactive pagination - you get exactly this many, once, no prompt."),
158
+ ):
159
+ """List every device Qapu knows about (GET /hermes/devices)."""
160
+
161
+ # Fetch the Full Device List
162
+ devices: list[dict[str, Any]] = client.get("/hermes/devices")
163
+
164
+ # Apply the Status Filter, if Any
165
+ if status:
166
+
167
+ # Normalize the Requested Value
168
+ wanted_online = status.strip().lower() == "online"
169
+
170
+ # Filter the List
171
+ devices = [d for d in devices if _is_online(d) == wanted_online]
172
+
173
+ # Apply the Model Filter, if Any
174
+ if model:
175
+
176
+ # Filter by Case-Insensitive Substring Match on the Model Name
177
+ needle = model.strip().lower()
178
+ devices = [d for d in devices if needle in ((d.get("Hardware") or {}).get("Model") or {}).get("Name", "").lower()]
179
+
180
+ # Apply an Explicit Limit, if Any - Caps the List Outright, no Pagination
181
+ if limit is not None:
182
+ devices = devices[:limit]
183
+
184
+ # JSON Output - Everything at Once, Never Paginated (Scripting/Hermes Use)
185
+ if as_json:
186
+
187
+ # Print Raw JSON
188
+ typer.echo(json.dumps(devices, indent=2))
189
+ return
190
+
191
+ # An Explicit --limit Already Capped the List Above - Print it in One Go
192
+ if limit is not None:
193
+
194
+ # Print the Table
195
+ console.print(_build_device_table(devices))
196
+
197
+ # Print the Summary Line
198
+ console.print(f"\nToplam: {len(devices)} cihaz")
199
+ return
200
+
201
+ # No Explicit Limit - Paginate Interactively, DEFAULT_PAGE_SIZE at a Time
202
+ total = len(devices)
203
+ offset = 0
204
+
205
+ # Loop Until Every Device Has Been Shown or the User Quits
206
+ while offset < total:
207
+
208
+ # Slice Out This Page
209
+ page = devices[offset:offset + DEFAULT_PAGE_SIZE]
210
+ offset += len(page)
211
+
212
+ # Print This Page's Table
213
+ console.print(_build_device_table(page))
214
+
215
+ # Print Where We Are
216
+ console.print(f"\n{offset}/{total} cihaz gösterildi.")
217
+
218
+ # Stop if That Was the Last Page - no Prompt Needed
219
+ if offset >= total:
220
+ break
221
+
222
+ # Ask Whether to Continue
223
+ answer = typer.prompt("Devam etmek için Enter'a bas (çıkmak için q)", default="", show_default=False)
224
+
225
+ # Stop on 'q'/'quit'/'exit'
226
+ if answer.strip().lower() in ("q", "quit", "exit"):
227
+ break
228
+
229
+
230
+ # Get One Device's Full Detail
231
+ @device_app.command("get")
232
+ def device_get(
233
+ device_id: str = typer.Argument(..., help="Device ID."),
234
+ as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of a human-readable summary."),
235
+ ):
236
+ """Get one device's detail (GET /hermes/devices/{device_id})."""
237
+
238
+ # Fetch the Device Detail
239
+ d: dict[str, Any] = client.get(f"/hermes/devices/{device_id}")
240
+
241
+ # JSON Output - Nothing Further to Format
242
+ if as_json:
243
+
244
+ # Print Raw JSON
245
+ typer.echo(json.dumps(d, indent=2))
246
+ return
247
+
248
+ # Resolve Nested Fields Defensively - Any of These Can Be Missing
249
+ owner = d.get("Owner") or {}
250
+ project = d.get("Project") or {}
251
+ device_status = d.get("Status") or {}
252
+ hardware = d.get("Hardware") or {}
253
+ manufacturer = hardware.get("Manufacturer") or {}
254
+ model = hardware.get("Model") or {}
255
+ modem = hardware.get("Modem") or {}
256
+ sim = d.get("SIM") or {}
257
+ register = d.get("Register") or {}
258
+ online = _is_online(d)
259
+ status_label = "[green]Online[/green]" if online else "[red]Offline[/red]"
260
+
261
+ # Build a Two-Column FIELD/VALUE Table - Cleaner for a Single Nested
262
+ # Object than Trying to Force it into the List Table's Row Shape
263
+ table = Table(show_header=False, box=None)
264
+ table.add_column("FIELD", style="bold")
265
+ table.add_column("VALUE")
266
+
267
+ rows = [
268
+ ("Device ID", d.get("ID") or "-"),
269
+ ("Name", d.get("Name") or "-"),
270
+ ("Description", d.get("Description") or "-"),
271
+ ("Status", status_label),
272
+ ("Firmware", d.get("Version") or "-"),
273
+ ("Last Connection", _relative_time(d.get("Update_Time"))),
274
+ ("Owner", owner.get("Name") or (str(owner.get("ID")) if owner.get("ID") is not None else "-")),
275
+ ("Project", project.get("Name") or "-"),
276
+ ("Device Status", device_status.get("Name") or "-"),
277
+ ("Manufacturer", manufacturer.get("Name") or "-"),
278
+ ("Model", model.get("Name") or "-"),
279
+ ("IMEI", modem.get("IMEI") or "-"),
280
+ ("SIM ICCID", sim.get("ICCID") or "-"),
281
+ ("Register (Status/Stop/Publish)", f"{register.get('Status', '-')} / {register.get('Stop', '-')} / {register.get('Publish', '-')}"),
282
+ ("Created", d.get("Creation_Time") or "-"),
283
+ ]
284
+
285
+ # Populate Rows
286
+ for field, value in rows:
287
+ table.add_row(field, str(value))
288
+
289
+ # Print the Table
290
+ console.print(table)
291
+
292
+
293
+ if __name__ == "__main__":
294
+
295
+ # Run the Typer App
296
+ app()
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: qapu-cli
3
+ Version: 0.1.0
4
+ Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
5
+ Author: OVOO Technology
6
+ Classifier: Programming Language :: Python :: 3
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer<1.0,>=0.12
10
+ Requires-Dist: httpx<1.0,>=0.27
11
+ Requires-Dist: rich<16.0,>=13.0
12
+
13
+ # Qapu CLI
14
+
15
+ A thin command-line client for the Qapu API (`api.ovoo.com.tr`), built for the Hermes agent (runs outside this Swarm, in a separate datacenter, and only ever talks to Qapu through this public API) - but usable by anything else that needs to script against Qapu from outside the private network.
16
+
17
+ **Status: early development.** Gated by a temporary shared-secret header, not real auth yet - see "Auth (current placeholder)" below before using this against production.
18
+
19
+ ## Why a separate `tools/` project, not a `services/`
20
+
21
+ This isn't a deployed backend service - it's a distributable client tool, installed wherever Hermes (or anyone else) runs. Kept in the same monorepo (per `CLAUDE.md`'s ADR-0001 - one repo, low context-switching for a 2-person team) rather than its own repo, since it's small and needs to stay in sync with the API it calls.
22
+
23
+ ## Install
24
+
25
+ **Anyone with GitHub access to this (private) repo - no local clone needed**, `pip` installs straight from the `tools/cli` subdirectory over git:
26
+
27
+ ```bash
28
+ pip install "git+ssh://git@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"
29
+ ```
30
+
31
+ (needs an SSH key already authorized on your GitHub account for this repo - the usual case for anyone on the team. No SSH key set up? Use an HTTPS Personal Access Token instead: `pip install "git+https://<PAT>@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"`.)
32
+
33
+ **Working on the CLI itself** (this repo already checked out) - editable install so local edits take effect immediately:
34
+
35
+ ```bash
36
+ cd tools/cli
37
+ pip install -e .
38
+ ```
39
+
40
+ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`). This package has no dependency on the rest of the monorepo (`qapu_common` etc.) - it only ever talks to Qapu over HTTP, never imports it directly - which is exactly what makes the git-subdirectory install above work without cloning anything else.
41
+
42
+ ## Configuration
43
+
44
+ | Env var | Purpose |
45
+ |---|---|
46
+ | `QAPU_API_URL` | Base URL. Defaults to `https://api.ovoo.com.tr`. Point at `http://localhost:8000` or an internal IP for local/dev testing. |
47
+ | `QAPU_HERMES_KEY` | Shared-secret value for the `X-Hermes-Key` header - see "Auth" below. Required for every command except `qapu health`. |
48
+
49
+ ## Commands
50
+
51
+ ```bash
52
+ qapu health # GET /health - no auth, quick connectivity check
53
+ qapu device list # GET /hermes/devices - every device, bulk, as a table
54
+ qapu device list --json # same data, raw JSON
55
+ qapu device list --status online # only devices whose Update_Time moved in the last 30 min (see ONLINE_THRESHOLD_MINUTES in main.py - there's no real online/offline field, this is a heuristic)
56
+ qapu device list --status offline
57
+ qapu device list --model B107AA_R5 # case-insensitive substring match on Hardware.Model.Name
58
+ qapu device list --limit 100
59
+ qapu device get <device_id> # GET /hermes/devices/{device_id} - one device, human-readable summary
60
+ qapu device get <device_id> --json # same data, raw JSON
61
+ ```
62
+
63
+ Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
64
+
65
+ ## Auth (current placeholder - read this before pointing at production)
66
+
67
+ `api.ovoo.com.tr` is genuinely public on the internet. The Hermes endpoints (`services/api/src/routers/hermes.py`) are gated by `require_hermes_key` (`services/api/src/dependencies.py`) - a single shared-secret string compared against the `X-Hermes-Key` header, checked via the `HERMES_SHARED_SECRET` env var on the API side. This is **deliberately temporary**: it exists only so the CLI/API plumbing could be built and tested end-to-end before the real auth design was ready, not because a shared secret is considered good enough long-term.
68
+
69
+ **Real plan** (not built yet - phase 2, along with the score/comment table Hermes will eventually write to): an admin-role `hermes-qapu` account in the `users` table, with the CLI gaining a `qapu login` command that authenticates through the existing `JWT_Auth` flow every other Qapu client already uses, storing a short-lived token instead of a static shared secret. `client.py` is written so only it needs to change when that lands - nothing in `main.py` should need to know how auth works under the hood.
70
+
71
+ Until then: `HERMES_SHARED_SECRET` fails closed (unset = every Hermes request rejected, never silently open), but a leaked shared-secret string is a much blunter credential than a scoped, revocable JWT - don't treat this as production-grade access control.
72
+
73
+ ## Running locally
74
+
75
+ ```bash
76
+ QAPU_API_URL=http://localhost:8000 QAPU_HERMES_KEY=dev-secret python -m qapu_cli.main device list
77
+ ```
78
+
79
+ (or, once installed via `pip install -e .`: just `qapu devices list` with the same env vars set.)
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ qapu_cli/__init__.py
4
+ qapu_cli/client.py
5
+ qapu_cli/main.py
6
+ qapu_cli.egg-info/PKG-INFO
7
+ qapu_cli.egg-info/SOURCES.txt
8
+ qapu_cli.egg-info/dependency_links.txt
9
+ qapu_cli.egg-info/entry_points.txt
10
+ qapu_cli.egg-info/requires.txt
11
+ qapu_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qapu = qapu_cli.main:app
@@ -0,0 +1,3 @@
1
+ typer<1.0,>=0.12
2
+ httpx<1.0,>=0.27
3
+ rich<16.0,>=13.0
@@ -0,0 +1 @@
1
+ qapu_cli
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+