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.
- qapu_cli-0.1.0/PKG-INFO +79 -0
- qapu_cli-0.1.0/README.md +67 -0
- qapu_cli-0.1.0/pyproject.toml +27 -0
- qapu_cli-0.1.0/qapu_cli/__init__.py +0 -0
- qapu_cli-0.1.0/qapu_cli/client.py +71 -0
- qapu_cli-0.1.0/qapu_cli/main.py +296 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/PKG-INFO +79 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/SOURCES.txt +11 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/dependency_links.txt +1 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/entry_points.txt +2 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/requires.txt +3 -0
- qapu_cli-0.1.0/qapu_cli.egg-info/top_level.txt +1 -0
- qapu_cli-0.1.0/setup.cfg +4 -0
qapu_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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.)
|
qapu_cli-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qapu_cli
|
qapu_cli-0.1.0/setup.cfg
ADDED