plyrana-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,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: plyrana-cli
3
+ Version: 0.1.0
4
+ Summary: Plyrana CLI — manage any self-hosted Plyrana instance from your terminal. Auth, devices, variables, automations, system.
5
+ Author-email: Plyrana <dev@plyrana.com>
6
+ License: AGPL-3.0
7
+ Project-URL: Homepage, https://github.com/plyrana/core
8
+ Project-URL: Documentation, https://github.com/plyrana/core/tree/main/cli
9
+ Project-URL: Repository, https://github.com/plyrana/core
10
+ Project-URL: Issues, https://github.com/plyrana/core/issues
11
+ Keywords: plyrana,iot,cli,devops,automation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: httpx<0.29.0,>=0.23.0
25
+ Requires-Dist: typer>=0.9.0
26
+ Requires-Dist: rich>=13.0.0
27
+
28
+ # plyrana-cli — Terminal client for Plyrana
29
+
30
+ Thin command-line client for the Plyrana IoT Device Hub REST API. Great for CI/CD, scripting, batch operations.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install plyrana-cli
36
+ # dev install from source:
37
+ pip install -e ./cli
38
+ ```
39
+
40
+ ## First run
41
+
42
+ ```bash
43
+ # Point to your hub
44
+ plyrana config set-url https://hub.example.com
45
+
46
+ # Login (password prompt if omitted)
47
+ plyrana auth login --email admin@example.com
48
+
49
+ # Verify
50
+ plyrana auth status
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ ### Auth
56
+ ```bash
57
+ plyrana auth login --email <EMAIL>
58
+ plyrana auth logout
59
+ plyrana auth status
60
+ ```
61
+
62
+ ### Devices
63
+ ```bash
64
+ plyrana devices list [--limit 50] [--type hardware]
65
+ plyrana devices get <UID>
66
+ plyrana devices claim <UID>
67
+ plyrana devices unclaim <UID>
68
+ ```
69
+
70
+ ### Variables
71
+ ```bash
72
+ plyrana variables list [--device <UID>]
73
+ plyrana variables get <UID> <KEY>
74
+ plyrana variables set <UID> <KEY> <VALUE> # auto-cast: 22.5 / true / '{"x":1}'
75
+ plyrana variables history <UID> <KEY> [--limit 20]
76
+ ```
77
+
78
+ ### Automations
79
+ ```bash
80
+ plyrana automations list
81
+ plyrana automations run <ID>
82
+ plyrana automations toggle <ID> --on | --off
83
+ ```
84
+
85
+ ### System
86
+ ```bash
87
+ plyrana system health # public, no auth
88
+ plyrana system license # CE / Pro / Enterprise + limits
89
+ plyrana system integrations # MQTT / HA / Prometheus / Grafana status
90
+ plyrana system metrics # Prometheus text-format
91
+ ```
92
+
93
+ ### Config
94
+ ```bash
95
+ plyrana config show
96
+ plyrana config set-url <URL>
97
+ plyrana config clear
98
+ ```
99
+
100
+ ## Auth storage
101
+
102
+ Tokens are stored in `~/.plyrana/config.json` with `0600` perms (best-effort on Windows).
103
+
104
+ Override via env:
105
+ - `PLYRANA_URL=https://hub.example.com`
106
+ - `PLYRANA_TOKEN=eyJ...`
107
+
108
+ Env beats file — great for CI.
109
+
110
+ ## Example: CI smoke-test
111
+
112
+ ```bash
113
+ export PLYRANA_URL=https://hub.example.com
114
+ export PLYRANA_TOKEN=$PLYRANA_CI_TOKEN
115
+ plyrana system health || exit 1
116
+ plyrana devices list --limit 1 || exit 1
117
+ ```
118
+
119
+ ## License
120
+
121
+ AGPL-3.0 — see [LICENSE](../LICENSE) in the main repo.
@@ -0,0 +1,94 @@
1
+ # plyrana-cli — Terminal client for Plyrana
2
+
3
+ Thin command-line client for the Plyrana IoT Device Hub REST API. Great for CI/CD, scripting, batch operations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install plyrana-cli
9
+ # dev install from source:
10
+ pip install -e ./cli
11
+ ```
12
+
13
+ ## First run
14
+
15
+ ```bash
16
+ # Point to your hub
17
+ plyrana config set-url https://hub.example.com
18
+
19
+ # Login (password prompt if omitted)
20
+ plyrana auth login --email admin@example.com
21
+
22
+ # Verify
23
+ plyrana auth status
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ### Auth
29
+ ```bash
30
+ plyrana auth login --email <EMAIL>
31
+ plyrana auth logout
32
+ plyrana auth status
33
+ ```
34
+
35
+ ### Devices
36
+ ```bash
37
+ plyrana devices list [--limit 50] [--type hardware]
38
+ plyrana devices get <UID>
39
+ plyrana devices claim <UID>
40
+ plyrana devices unclaim <UID>
41
+ ```
42
+
43
+ ### Variables
44
+ ```bash
45
+ plyrana variables list [--device <UID>]
46
+ plyrana variables get <UID> <KEY>
47
+ plyrana variables set <UID> <KEY> <VALUE> # auto-cast: 22.5 / true / '{"x":1}'
48
+ plyrana variables history <UID> <KEY> [--limit 20]
49
+ ```
50
+
51
+ ### Automations
52
+ ```bash
53
+ plyrana automations list
54
+ plyrana automations run <ID>
55
+ plyrana automations toggle <ID> --on | --off
56
+ ```
57
+
58
+ ### System
59
+ ```bash
60
+ plyrana system health # public, no auth
61
+ plyrana system license # CE / Pro / Enterprise + limits
62
+ plyrana system integrations # MQTT / HA / Prometheus / Grafana status
63
+ plyrana system metrics # Prometheus text-format
64
+ ```
65
+
66
+ ### Config
67
+ ```bash
68
+ plyrana config show
69
+ plyrana config set-url <URL>
70
+ plyrana config clear
71
+ ```
72
+
73
+ ## Auth storage
74
+
75
+ Tokens are stored in `~/.plyrana/config.json` with `0600` perms (best-effort on Windows).
76
+
77
+ Override via env:
78
+ - `PLYRANA_URL=https://hub.example.com`
79
+ - `PLYRANA_TOKEN=eyJ...`
80
+
81
+ Env beats file — great for CI.
82
+
83
+ ## Example: CI smoke-test
84
+
85
+ ```bash
86
+ export PLYRANA_URL=https://hub.example.com
87
+ export PLYRANA_TOKEN=$PLYRANA_CI_TOKEN
88
+ plyrana system health || exit 1
89
+ plyrana devices list --limit 1 || exit 1
90
+ ```
91
+
92
+ ## License
93
+
94
+ AGPL-3.0 — see [LICENSE](../LICENSE) in the main repo.
@@ -0,0 +1,3 @@
1
+ """plyrana-cli — Terminal client for the Plyrana IoT Device Hub."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ """Thin httpx wrapper used by all CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from .config import Config
9
+
10
+
11
+ class PlyranaAPIError(Exception):
12
+ def __init__(self, status: int, detail: str) -> None:
13
+ super().__init__(f"HTTP {status}: {detail}")
14
+ self.status = status
15
+ self.detail = detail
16
+
17
+
18
+ def require_auth(cfg: Config) -> None:
19
+ if not cfg.is_authenticated:
20
+ typer.secho(
21
+ "Not authenticated. Run: plyrana auth login --email <you> --password <pw>",
22
+ fg=typer.colors.RED,
23
+ err=True,
24
+ )
25
+ raise typer.Exit(code=2)
26
+
27
+
28
+ def http(cfg: Config, method: str, path: str, **kwargs) -> httpx.Response:
29
+ """Perform an API call. Path is relative (e.g. '/api/v1/devices')."""
30
+ url = cfg.base_url + path
31
+ headers = kwargs.pop("headers", {}) or {}
32
+ if cfg.token:
33
+ headers["Authorization"] = f"Bearer {cfg.token}"
34
+ timeout = kwargs.pop("timeout", 30.0)
35
+ try:
36
+ response = httpx.request(method, url, headers=headers, timeout=timeout, **kwargs)
37
+ except httpx.RequestError as exc:
38
+ typer.secho(f"Network error: {exc}", fg=typer.colors.RED, err=True)
39
+ raise typer.Exit(code=3) from exc
40
+
41
+ if response.status_code >= 400:
42
+ try:
43
+ detail = response.json()
44
+ except ValueError:
45
+ detail = response.text
46
+ raise PlyranaAPIError(response.status_code, str(detail))
47
+
48
+ return response
49
+
50
+
51
+ def get(cfg: Config, path: str, **kwargs) -> httpx.Response:
52
+ return http(cfg, "GET", path, **kwargs)
53
+
54
+
55
+ def post(cfg: Config, path: str, json: dict | None = None, **kwargs) -> httpx.Response:
56
+ return http(cfg, "POST", path, json=json, **kwargs)
57
+
58
+
59
+ def put(cfg: Config, path: str, json: dict | None = None, **kwargs) -> httpx.Response:
60
+ return http(cfg, "PUT", path, json=json, **kwargs)
61
+
62
+
63
+ def delete(cfg: Config, path: str, **kwargs) -> httpx.Response:
64
+ return http(cfg, "DELETE", path, **kwargs)
@@ -0,0 +1,84 @@
1
+ """Auth subcommand: login, logout, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ..client import PlyranaAPIError, get, post
11
+ from ..config import Config
12
+
13
+
14
+ app = typer.Typer()
15
+ console = Console()
16
+
17
+
18
+ @app.command("login")
19
+ def login(
20
+ email: str = typer.Option(..., "--email", "-e", prompt=True, help="Your email address."),
21
+ password: str | None = typer.Option(
22
+ None,
23
+ "--password",
24
+ "-p",
25
+ help="Your password (will be prompted if omitted).",
26
+ ),
27
+ url: str | None = typer.Option(
28
+ None, "--url", help="Plyrana base URL (override ~/.plyrana/config.json)."
29
+ ),
30
+ ) -> None:
31
+ """Authenticate and save a JWT token to ~/.plyrana/config.json."""
32
+ cfg = Config.load()
33
+ if url:
34
+ cfg.base_url = url.rstrip("/")
35
+ if not password:
36
+ password = getpass.getpass("Password: ")
37
+
38
+ try:
39
+ response = post(
40
+ cfg,
41
+ "/api/v1/auth/login",
42
+ json={"email": email, "password": password},
43
+ )
44
+ except PlyranaAPIError as exc:
45
+ typer.secho(f"Login failed: {exc.detail}", fg=typer.colors.RED, err=True)
46
+ raise typer.Exit(code=1)
47
+
48
+ data = response.json()
49
+ cfg.token = data.get("access_token")
50
+ if not cfg.token:
51
+ typer.secho("Login response missing 'access_token'", fg=typer.colors.RED, err=True)
52
+ raise typer.Exit(code=1)
53
+ cfg.save()
54
+ console.print(f"[green]Logged in[/] as [bold]{email}[/] at [dim]{cfg.base_url}[/]")
55
+
56
+
57
+ @app.command("logout")
58
+ def logout() -> None:
59
+ """Remove the saved token."""
60
+ cfg = Config.load()
61
+ cfg.clear()
62
+ console.print("[green]Logged out[/] — ~/.plyrana/config.json removed.")
63
+
64
+
65
+ @app.command("status")
66
+ def status() -> None:
67
+ """Show current login status and identity."""
68
+ cfg = Config.load()
69
+ console.print(f"[bold]Base URL:[/] {cfg.base_url}")
70
+ if not cfg.is_authenticated:
71
+ console.print("[yellow]Not authenticated.[/]")
72
+ console.print("Run: [cyan]plyrana auth login --email <you>[/]")
73
+ raise typer.Exit(code=0)
74
+
75
+ try:
76
+ response = get(cfg, "/api/v1/users/me")
77
+ except PlyranaAPIError as exc:
78
+ typer.secho(f"Auth check failed: {exc.detail}", fg=typer.colors.RED, err=True)
79
+ raise typer.Exit(code=1)
80
+
81
+ me = response.json()
82
+ console.print(
83
+ f"[green]Authenticated[/] as [bold]{me.get('email')}[/] (role: {me.get('role')})"
84
+ )
@@ -0,0 +1,88 @@
1
+ """Automations subcommand: list / run / toggle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..client import PlyranaAPIError, get, post, put, require_auth
10
+ from ..config import Config
11
+
12
+
13
+ app = typer.Typer()
14
+ console = Console()
15
+
16
+
17
+ @app.command("list")
18
+ def list_automations() -> None:
19
+ """List all automations in your org."""
20
+ cfg = Config.load()
21
+ require_auth(cfg)
22
+ try:
23
+ response = get(cfg, "/api/v1/automations")
24
+ except PlyranaAPIError as exc:
25
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
26
+ raise typer.Exit(code=1)
27
+
28
+ data = response.json()
29
+ automations = data if isinstance(data, list) else data.get("items", [])
30
+
31
+ if not automations:
32
+ console.print("[dim]No automations found.[/]")
33
+ return
34
+
35
+ table = Table(title=f"Automations ({len(automations)})")
36
+ table.add_column("ID", style="cyan")
37
+ table.add_column("Name")
38
+ table.add_column("Trigger", style="magenta")
39
+ table.add_column("Enabled")
40
+ table.add_column("Last run", style="dim")
41
+ for auto in automations:
42
+ table.add_row(
43
+ str(auto.get("id", "-")),
44
+ auto.get("name") or "-",
45
+ auto.get("trigger_type", "-"),
46
+ "[green]yes[/]" if auto.get("enabled") else "[red]no[/]",
47
+ auto.get("last_triggered_at") or "-",
48
+ )
49
+ console.print(table)
50
+
51
+
52
+ @app.command("run")
53
+ def run_automation(
54
+ automation_id: int = typer.Argument(..., help="Automation ID."),
55
+ ) -> None:
56
+ """Manually trigger an automation (runs actions once, bypassing trigger)."""
57
+ cfg = Config.load()
58
+ require_auth(cfg)
59
+ try:
60
+ response = post(cfg, f"/api/v1/automations/{automation_id}/run")
61
+ except PlyranaAPIError as exc:
62
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
63
+ raise typer.Exit(code=1)
64
+ console.print(f"[green]Triggered[/] automation #{automation_id}")
65
+ console.print_json(data=response.json())
66
+
67
+
68
+ @app.command("toggle")
69
+ def toggle_automation(
70
+ automation_id: int = typer.Argument(..., help="Automation ID."),
71
+ enabled: bool = typer.Option(True, "--on/--off", help="Turn automation on or off."),
72
+ ) -> None:
73
+ """Enable or disable an automation."""
74
+ cfg = Config.load()
75
+ require_auth(cfg)
76
+ try:
77
+ response = put(
78
+ cfg,
79
+ f"/api/v1/automations/{automation_id}",
80
+ json={"enabled": enabled},
81
+ )
82
+ except PlyranaAPIError as exc:
83
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
84
+ raise typer.Exit(code=1)
85
+
86
+ state = "on" if enabled else "off"
87
+ console.print(f"[green]Automation #{automation_id} is now {state}[/]")
88
+ console.print_json(data=response.json())
@@ -0,0 +1,40 @@
1
+ """Config subcommand: show / set-url / clear."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from ..config import Config
9
+
10
+
11
+ app = typer.Typer()
12
+ console = Console()
13
+
14
+
15
+ @app.command("show")
16
+ def show() -> None:
17
+ """Show current CLI configuration."""
18
+ cfg = Config.load()
19
+ console.print(f"[bold]Base URL:[/] {cfg.base_url}")
20
+ if cfg.token:
21
+ console.print(f"[bold]Token:[/] {cfg.token[:20]}... [dim](stored)[/]")
22
+ else:
23
+ console.print("[yellow]No token[/] — run 'plyrana auth login'.")
24
+
25
+
26
+ @app.command("set-url")
27
+ def set_url(url: str = typer.Argument(..., help="New base URL.")) -> None:
28
+ """Change the Plyrana base URL."""
29
+ cfg = Config.load()
30
+ cfg.base_url = url.rstrip("/")
31
+ cfg.save()
32
+ console.print(f"[green]Base URL set to[/] {cfg.base_url}")
33
+
34
+
35
+ @app.command("clear")
36
+ def clear() -> None:
37
+ """Delete the config file (token + URL)."""
38
+ cfg = Config.load()
39
+ cfg.clear()
40
+ console.print("[green]Config cleared[/] — ~/.plyrana/config.json removed.")
@@ -0,0 +1,101 @@
1
+ """Devices subcommand: list / get / claim / unclaim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..client import PlyranaAPIError, delete, get, post, require_auth
10
+ from ..config import Config
11
+
12
+
13
+ app = typer.Typer()
14
+ console = Console()
15
+
16
+
17
+ @app.command("list")
18
+ def list_devices(
19
+ limit: int = typer.Option(50, "--limit", "-n", help="Max rows to show."),
20
+ device_type: str | None = typer.Option(None, "--type", help="Filter by device type."),
21
+ ) -> None:
22
+ """List all devices in your org."""
23
+ cfg = Config.load()
24
+ require_auth(cfg)
25
+
26
+ params = {"limit": limit}
27
+ if device_type:
28
+ params["device_type"] = device_type
29
+
30
+ try:
31
+ response = get(cfg, "/api/v1/devices", params=params)
32
+ except PlyranaAPIError as exc:
33
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
34
+ raise typer.Exit(code=1)
35
+
36
+ data = response.json()
37
+ devices = data if isinstance(data, list) else data.get("items", [])
38
+
39
+ if not devices:
40
+ console.print("[dim]No devices found.[/]")
41
+ return
42
+
43
+ table = Table(title=f"Devices ({len(devices)})")
44
+ table.add_column("UID", style="cyan", no_wrap=True)
45
+ table.add_column("Name")
46
+ table.add_column("Type", style="magenta")
47
+ table.add_column("Status")
48
+ table.add_column("Last seen", style="dim")
49
+
50
+ for dev in devices[:limit]:
51
+ table.add_row(
52
+ dev.get("uid", "-"),
53
+ dev.get("name") or "-",
54
+ dev.get("device_type", "-"),
55
+ "[green]online[/]" if dev.get("online") else "[red]offline[/]",
56
+ dev.get("last_seen_at") or "-",
57
+ )
58
+ console.print(table)
59
+
60
+
61
+ @app.command("get")
62
+ def get_device(uid: str = typer.Argument(..., help="Device UID.")) -> None:
63
+ """Show full details for a device."""
64
+ cfg = Config.load()
65
+ require_auth(cfg)
66
+ try:
67
+ response = get(cfg, f"/api/v1/devices/{uid}")
68
+ except PlyranaAPIError as exc:
69
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
70
+ raise typer.Exit(code=1)
71
+
72
+ console.print_json(data=response.json())
73
+
74
+
75
+ @app.command("claim")
76
+ def claim_device(uid: str = typer.Argument(..., help="Device UID to claim.")) -> None:
77
+ """Claim an unclaimed device for your organization."""
78
+ cfg = Config.load()
79
+ require_auth(cfg)
80
+ try:
81
+ response = post(cfg, f"/api/v1/devices/{uid}/claim")
82
+ except PlyranaAPIError as exc:
83
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
84
+ raise typer.Exit(code=1)
85
+ console.print(f"[green]Claimed[/] device [bold]{uid}[/]")
86
+ console.print_json(data=response.json())
87
+
88
+
89
+ @app.command("unclaim")
90
+ def unclaim_device(uid: str = typer.Argument(..., help="Device UID to unclaim.")) -> None:
91
+ """Release a device back to the pool."""
92
+ cfg = Config.load()
93
+ require_auth(cfg)
94
+ if not typer.confirm(f"Really unclaim device {uid}?"):
95
+ raise typer.Exit(code=0)
96
+ try:
97
+ post(cfg, f"/api/v1/devices/{uid}/unclaim")
98
+ except PlyranaAPIError as exc:
99
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
100
+ raise typer.Exit(code=1)
101
+ console.print(f"[green]Unclaimed[/] device [bold]{uid}[/]")
@@ -0,0 +1,82 @@
1
+ """System subcommand: health / license / integrations / metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..client import PlyranaAPIError, get, require_auth
10
+ from ..config import Config
11
+
12
+
13
+ app = typer.Typer()
14
+ console = Console()
15
+
16
+
17
+ @app.command("health")
18
+ def health() -> None:
19
+ """Public backend health check (no auth)."""
20
+ cfg = Config.load()
21
+ try:
22
+ response = get(cfg, "/health")
23
+ except PlyranaAPIError as exc:
24
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
25
+ raise typer.Exit(code=1)
26
+ data = response.json()
27
+ status = data.get("status", "unknown")
28
+ version = data.get("version", "?")
29
+ color = "green" if status == "ok" else "red"
30
+ console.print(f"[{color}]{status.upper()}[/] (version {version}) @ {cfg.base_url}")
31
+
32
+
33
+ @app.command("license")
34
+ def license_info() -> None:
35
+ """Show current license (CE / Pro / Enterprise)."""
36
+ cfg = Config.load()
37
+ require_auth(cfg)
38
+ try:
39
+ response = get(cfg, "/api/v1/license")
40
+ except PlyranaAPIError as exc:
41
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
42
+ raise typer.Exit(code=1)
43
+
44
+ lic = response.json()
45
+ table = Table(title="License")
46
+ table.add_column("Field", style="cyan")
47
+ table.add_column("Value")
48
+
49
+ for key in ("license_id", "issued_to", "edition", "expires_at", "signed", "valid"):
50
+ table.add_row(key, str(lic.get(key, "-")))
51
+
52
+ limits = lic.get("limits", {})
53
+ for key, value in limits.items():
54
+ display = "∞" if value == -1 else str(value)
55
+ table.add_row(f"limits.{key}", display)
56
+
57
+ console.print(table)
58
+
59
+
60
+ @app.command("integrations")
61
+ def integrations_status() -> None:
62
+ """Aggregated status of MQTT / HA / Prometheus / Grafana integrations."""
63
+ cfg = Config.load()
64
+ require_auth(cfg)
65
+ try:
66
+ response = get(cfg, "/api/v1/integrations/status")
67
+ except PlyranaAPIError as exc:
68
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
69
+ raise typer.Exit(code=1)
70
+ console.print_json(data=response.json())
71
+
72
+
73
+ @app.command("metrics")
74
+ def metrics() -> None:
75
+ """Fetch raw Prometheus metrics output (text/plain)."""
76
+ cfg = Config.load()
77
+ try:
78
+ response = get(cfg, "/metrics")
79
+ except PlyranaAPIError as exc:
80
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
81
+ raise typer.Exit(code=1)
82
+ console.print(response.text)
@@ -0,0 +1,147 @@
1
+ """Variables subcommand: list / get / set / history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as json_lib
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from ..client import PlyranaAPIError, get, post, put, require_auth
13
+ from ..config import Config
14
+
15
+
16
+ app = typer.Typer()
17
+ console = Console()
18
+
19
+
20
+ def _coerce_value(raw: str) -> Any:
21
+ """Coerce a CLI string to int/float/bool/json/string."""
22
+ lower = raw.lower()
23
+ if lower in {"true", "false"}:
24
+ return lower == "true"
25
+ try:
26
+ return int(raw)
27
+ except ValueError:
28
+ pass
29
+ try:
30
+ return float(raw)
31
+ except ValueError:
32
+ pass
33
+ if raw.startswith(("{", "[")):
34
+ try:
35
+ return json_lib.loads(raw)
36
+ except json_lib.JSONDecodeError:
37
+ pass
38
+ return raw
39
+
40
+
41
+ @app.command("list")
42
+ def list_variables(
43
+ device_uid: str | None = typer.Option(None, "--device", "-d", help="Filter by device UID."),
44
+ ) -> None:
45
+ """List variable definitions (optionally per device)."""
46
+ cfg = Config.load()
47
+ require_auth(cfg)
48
+ if device_uid:
49
+ path = f"/api/v1/variables/device/{device_uid}"
50
+ else:
51
+ path = "/api/v1/variables/defs"
52
+ try:
53
+ response = get(cfg, path)
54
+ except PlyranaAPIError as exc:
55
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
56
+ raise typer.Exit(code=1)
57
+
58
+ data = response.json()
59
+ variables = data if isinstance(data, list) else data.get("items", [])
60
+
61
+ if not variables:
62
+ console.print("[dim]No variables found.[/]")
63
+ return
64
+
65
+ table = Table(title=f"Variables ({len(variables)})")
66
+ table.add_column("Key", style="cyan")
67
+ table.add_column("Name")
68
+ table.add_column("Type", style="magenta")
69
+ table.add_column("Unit", style="dim")
70
+ for var in variables:
71
+ table.add_row(
72
+ str(var.get("key", "-")),
73
+ var.get("name") or "-",
74
+ var.get("var_type", "-"),
75
+ var.get("unit") or "-",
76
+ )
77
+ console.print(table)
78
+
79
+
80
+ @app.command("get")
81
+ def get_variable(
82
+ device_uid: str = typer.Argument(..., help="Device UID."),
83
+ key: str = typer.Argument(..., help="Variable key."),
84
+ ) -> None:
85
+ """Read the current value of a variable."""
86
+ cfg = Config.load()
87
+ require_auth(cfg)
88
+ try:
89
+ response = get(cfg, f"/api/v1/devices/{device_uid}/variables/{key}")
90
+ except PlyranaAPIError as exc:
91
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
92
+ raise typer.Exit(code=1)
93
+ console.print_json(data=response.json())
94
+
95
+
96
+ @app.command("set")
97
+ def set_variable(
98
+ device_uid: str = typer.Argument(..., help="Device UID."),
99
+ key: str = typer.Argument(..., help="Variable key."),
100
+ value: str = typer.Argument(..., help="Value (auto-cast bool/int/float/JSON)."),
101
+ ) -> None:
102
+ """Write a new value to a variable."""
103
+ cfg = Config.load()
104
+ require_auth(cfg)
105
+ payload = {"value": _coerce_value(value)}
106
+ try:
107
+ response = put(cfg, f"/api/v1/devices/{device_uid}/variables/{key}", json=payload)
108
+ except PlyranaAPIError as exc:
109
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
110
+ raise typer.Exit(code=1)
111
+ console.print(f"[green]Set[/] {device_uid}.{key} = [bold]{payload['value']!r}[/]")
112
+ console.print_json(data=response.json())
113
+
114
+
115
+ @app.command("history")
116
+ def history(
117
+ device_uid: str = typer.Argument(..., help="Device UID."),
118
+ key: str = typer.Argument(..., help="Variable key."),
119
+ limit: int = typer.Option(20, "--limit", "-n", help="Max samples."),
120
+ ) -> None:
121
+ """Show recent history for a variable."""
122
+ cfg = Config.load()
123
+ require_auth(cfg)
124
+ try:
125
+ response = get(
126
+ cfg,
127
+ f"/api/v1/devices/{device_uid}/variables/{key}/history",
128
+ params={"limit": limit},
129
+ )
130
+ except PlyranaAPIError as exc:
131
+ typer.secho(f"Error: {exc.detail}", fg=typer.colors.RED, err=True)
132
+ raise typer.Exit(code=1)
133
+
134
+ samples = response.json()
135
+ if not samples:
136
+ console.print("[dim]No history samples.[/]")
137
+ return
138
+
139
+ table = Table(title=f"{device_uid}.{key} history")
140
+ table.add_column("Timestamp", style="dim")
141
+ table.add_column("Value", style="cyan")
142
+ for sample in samples[:limit]:
143
+ table.add_row(
144
+ str(sample.get("timestamp", "-")),
145
+ str(sample.get("value", "-")),
146
+ )
147
+ console.print(table)
@@ -0,0 +1,52 @@
1
+ """Plyrana CLI config: load/save token + base URL from ~/.plyrana/config.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ DEFAULT_BASE_URL = "http://localhost:8000"
11
+ CONFIG_DIR = Path(os.path.expanduser("~")) / ".plyrana"
12
+ CONFIG_PATH = CONFIG_DIR / "config.json"
13
+
14
+
15
+ class Config:
16
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, token: str | None = None) -> None:
17
+ self.base_url = base_url.rstrip("/")
18
+ self.token = token
19
+
20
+ @classmethod
21
+ def load(cls) -> "Config":
22
+ """Load from file + env-var overrides. Env beats file."""
23
+ data: dict = {}
24
+ if CONFIG_PATH.exists():
25
+ try:
26
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError):
28
+ data = {}
29
+
30
+ base_url = os.environ.get("PLYRANA_URL") or data.get("base_url") or DEFAULT_BASE_URL
31
+ token = os.environ.get("PLYRANA_TOKEN") or data.get("token")
32
+ return cls(base_url=base_url, token=token)
33
+
34
+ def save(self) -> None:
35
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
36
+ CONFIG_PATH.write_text(
37
+ json.dumps({"base_url": self.base_url, "token": self.token}, indent=2),
38
+ encoding="utf-8",
39
+ )
40
+ # Best-effort: restrict to user only (0600). No-op on Windows without admin.
41
+ try:
42
+ os.chmod(CONFIG_PATH, 0o600)
43
+ except OSError:
44
+ pass
45
+
46
+ def clear(self) -> None:
47
+ if CONFIG_PATH.exists():
48
+ CONFIG_PATH.unlink()
49
+
50
+ @property
51
+ def is_authenticated(self) -> bool:
52
+ return bool(self.token)
@@ -0,0 +1,48 @@
1
+ """plyrana CLI entry point — `plyrana <subcommand>`.
2
+
3
+ Subcommands:
4
+ auth login / logout / status
5
+ devices list / get / claim / unclaim
6
+ variables list / get / set / history
7
+ automations list / run / toggle
8
+ system health / license / integrations / metrics
9
+ config show / set-url / clear
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typer
15
+
16
+ from . import __version__
17
+ from .commands import automations, auth, config_cmd, devices, system, variables
18
+
19
+
20
+ app = typer.Typer(
21
+ name="plyrana",
22
+ help="Plyrana CLI — manage a Plyrana IoT Device Hub from your terminal.",
23
+ no_args_is_help=True,
24
+ add_completion=True,
25
+ )
26
+
27
+ app.add_typer(auth.app, name="auth", help="Authentication: login / logout / status.")
28
+ app.add_typer(devices.app, name="devices", help="List, inspect, claim and unclaim devices.")
29
+ app.add_typer(variables.app, name="variables", help="Read and write device variables.")
30
+ app.add_typer(automations.app, name="automations", help="List, run, toggle automations.")
31
+ app.add_typer(system.app, name="system", help="Health, license and integration status.")
32
+ app.add_typer(config_cmd.app, name="config", help="CLI configuration (base URL, token).")
33
+
34
+
35
+ @app.callback(invoke_without_command=True)
36
+ def root(
37
+ ctx: typer.Context,
38
+ version: bool = typer.Option(False, "--version", "-V", help="Print version and exit."),
39
+ ) -> None:
40
+ if version:
41
+ typer.echo(f"plyrana {__version__}")
42
+ raise typer.Exit(code=0)
43
+ if ctx.invoked_subcommand is None:
44
+ typer.echo(ctx.get_help())
45
+
46
+
47
+ if __name__ == "__main__":
48
+ app()
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: plyrana-cli
3
+ Version: 0.1.0
4
+ Summary: Plyrana CLI — manage any self-hosted Plyrana instance from your terminal. Auth, devices, variables, automations, system.
5
+ Author-email: Plyrana <dev@plyrana.com>
6
+ License: AGPL-3.0
7
+ Project-URL: Homepage, https://github.com/plyrana/core
8
+ Project-URL: Documentation, https://github.com/plyrana/core/tree/main/cli
9
+ Project-URL: Repository, https://github.com/plyrana/core
10
+ Project-URL: Issues, https://github.com/plyrana/core/issues
11
+ Keywords: plyrana,iot,cli,devops,automation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: httpx<0.29.0,>=0.23.0
25
+ Requires-Dist: typer>=0.9.0
26
+ Requires-Dist: rich>=13.0.0
27
+
28
+ # plyrana-cli — Terminal client for Plyrana
29
+
30
+ Thin command-line client for the Plyrana IoT Device Hub REST API. Great for CI/CD, scripting, batch operations.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install plyrana-cli
36
+ # dev install from source:
37
+ pip install -e ./cli
38
+ ```
39
+
40
+ ## First run
41
+
42
+ ```bash
43
+ # Point to your hub
44
+ plyrana config set-url https://hub.example.com
45
+
46
+ # Login (password prompt if omitted)
47
+ plyrana auth login --email admin@example.com
48
+
49
+ # Verify
50
+ plyrana auth status
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ ### Auth
56
+ ```bash
57
+ plyrana auth login --email <EMAIL>
58
+ plyrana auth logout
59
+ plyrana auth status
60
+ ```
61
+
62
+ ### Devices
63
+ ```bash
64
+ plyrana devices list [--limit 50] [--type hardware]
65
+ plyrana devices get <UID>
66
+ plyrana devices claim <UID>
67
+ plyrana devices unclaim <UID>
68
+ ```
69
+
70
+ ### Variables
71
+ ```bash
72
+ plyrana variables list [--device <UID>]
73
+ plyrana variables get <UID> <KEY>
74
+ plyrana variables set <UID> <KEY> <VALUE> # auto-cast: 22.5 / true / '{"x":1}'
75
+ plyrana variables history <UID> <KEY> [--limit 20]
76
+ ```
77
+
78
+ ### Automations
79
+ ```bash
80
+ plyrana automations list
81
+ plyrana automations run <ID>
82
+ plyrana automations toggle <ID> --on | --off
83
+ ```
84
+
85
+ ### System
86
+ ```bash
87
+ plyrana system health # public, no auth
88
+ plyrana system license # CE / Pro / Enterprise + limits
89
+ plyrana system integrations # MQTT / HA / Prometheus / Grafana status
90
+ plyrana system metrics # Prometheus text-format
91
+ ```
92
+
93
+ ### Config
94
+ ```bash
95
+ plyrana config show
96
+ plyrana config set-url <URL>
97
+ plyrana config clear
98
+ ```
99
+
100
+ ## Auth storage
101
+
102
+ Tokens are stored in `~/.plyrana/config.json` with `0600` perms (best-effort on Windows).
103
+
104
+ Override via env:
105
+ - `PLYRANA_URL=https://hub.example.com`
106
+ - `PLYRANA_TOKEN=eyJ...`
107
+
108
+ Env beats file — great for CI.
109
+
110
+ ## Example: CI smoke-test
111
+
112
+ ```bash
113
+ export PLYRANA_URL=https://hub.example.com
114
+ export PLYRANA_TOKEN=$PLYRANA_CI_TOKEN
115
+ plyrana system health || exit 1
116
+ plyrana devices list --limit 1 || exit 1
117
+ ```
118
+
119
+ ## License
120
+
121
+ AGPL-3.0 — see [LICENSE](../LICENSE) in the main repo.
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ plyrana_cli/__init__.py
4
+ plyrana_cli/client.py
5
+ plyrana_cli/config.py
6
+ plyrana_cli/main.py
7
+ plyrana_cli.egg-info/PKG-INFO
8
+ plyrana_cli.egg-info/SOURCES.txt
9
+ plyrana_cli.egg-info/dependency_links.txt
10
+ plyrana_cli.egg-info/entry_points.txt
11
+ plyrana_cli.egg-info/requires.txt
12
+ plyrana_cli.egg-info/top_level.txt
13
+ plyrana_cli/commands/__init__.py
14
+ plyrana_cli/commands/auth.py
15
+ plyrana_cli/commands/automations.py
16
+ plyrana_cli/commands/config_cmd.py
17
+ plyrana_cli/commands/devices.py
18
+ plyrana_cli/commands/system.py
19
+ plyrana_cli/commands/variables.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plyrana = plyrana_cli.main:app
@@ -0,0 +1,3 @@
1
+ httpx<0.29.0,>=0.23.0
2
+ typer>=0.9.0
3
+ rich>=13.0.0
@@ -0,0 +1 @@
1
+ plyrana_cli
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "plyrana-cli"
3
+ version = "0.1.0"
4
+ description = "Plyrana CLI — manage any self-hosted Plyrana instance from your terminal. Auth, devices, variables, automations, system."
5
+ readme = "README.md"
6
+ license = { text = "AGPL-3.0" }
7
+ authors = [
8
+ { name = "Plyrana", email = "dev@plyrana.com" }
9
+ ]
10
+ keywords = ["plyrana", "iot", "cli", "devops", "automation"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: System Administrators",
16
+ "License :: OSI Approved :: GNU Affero General Public License v3",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: System :: Systems Administration",
22
+ ]
23
+ requires-python = ">=3.10"
24
+ dependencies = [
25
+ "httpx>=0.23.0,<0.29.0",
26
+ "typer>=0.9.0",
27
+ "rich>=13.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ plyrana = "plyrana_cli.main:app"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/plyrana/core"
35
+ Documentation = "https://github.com/plyrana/core/tree/main/cli"
36
+ Repository = "https://github.com/plyrana/core"
37
+ Issues = "https://github.com/plyrana/core/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["plyrana_cli*"]
41
+
42
+ [build-system]
43
+ requires = ["setuptools>=68", "wheel"]
44
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+