feat: add Teltonika RMS API CLI harness

New CLI harness for Teltonika RMS (Remote Management System) API.

- 18 command groups: devices, companies, users, tags, alerts, configs,
  remote-access, logs, location, credits, files, reports, hotspots,
  passwords, smtp, auth, config, session
- PAT auth with 3-tier fallback (CLI flag / env var / config file)
- 88 unit tests (76 core + 8 CliRunner + 4 regression), 9 E2E tests
- Interactive REPL with ReplSkin
- --password-stdin for safer credential handling (passwords, smtp)
- Timezone-aware UTC timestamps in session history

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
GalK777
2026-03-25 06:04:51 +02:00
parent 903d272999
commit 8af8dc0d22
33 changed files with 3847 additions and 0 deletions
+4
View File
@@ -56,6 +56,7 @@
!/musescore/
!/krita/
!/iterm2/
!/rms/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -100,6 +101,8 @@
/krita/.*
/iterm2/*
/iterm2/.*
/rms/*
/rms/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -124,6 +127,7 @@
!/musescore/agent-harness/
!/krita/agent-harness/
!/iterm2/agent-harness/
!/rms/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
+14
View File
@@ -326,6 +326,20 @@
"category": "devops",
"contributor": "voidfreud",
"contributor_url": "https://github.com/voidfreud"
},
{
"name": "rms",
"display_name": "Teltonika RMS",
"version": "1.0.0",
"description": "Device management and monitoring via Teltonika RMS REST API",
"requires": "RMS_API_TOKEN",
"homepage": "https://rms.teltonika-networks.com",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness",
"entry_point": "cli-anything-rms",
"skill_md": "rms/agent-harness/cli_anything/rms/skills/SKILL.md",
"category": "network",
"contributor": "galke",
"contributor_url": "https://github.com/galke7"
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# RMS — Teltonika Remote Management System CLI Harness
## Architecture
This harness wraps the Teltonika RMS REST API (v3-BETA) to provide a CLI
for device management, monitoring, alerting, and administration.
**Backend**: REST API at `https://api.rms.teltonika-networks.com`
**Auth**: Bearer token (Personal Access Token) via `Authorization: Bearer <token>` header
**Response format**: `{"success": bool, "data": ..., "errors": [...], "meta": {"total": N}}`
## Prerequisites
- Teltonika RMS account with 2FA enabled
- Personal Access Token (PAT) created in account settings
- Token set as `RMS_API_TOKEN` env var or via `cli-anything-rms config set api_token <token>`
## Resource Map
| Resource | API Path | CLI Group | Operations |
|----------|----------|-----------|------------|
| Devices | /devices | `devices` | list, get, update, delete |
| Companies | /companies | `companies` | list, get, create, update, delete |
| Users | /users | `users` | list, get, invite, update, delete |
| Tags | /tags | `tags` | list, get, create, update, delete |
| Device Alerts | /device_alerts | `alerts` | list, get, delete |
| Alert Configurations | /device_alert_configurations | `alerts configs` | list, get, create, update, delete |
| Device Configurations | /device_configurations | `configs` | list, get, update |
| Remote Access | /device_remote_access | `remote-access` | list, get, create, delete |
| Device Logs | /device_logs | `logs` | list, get, delete |
| Device Location | /device_location | `location` | get, history |
| Credits | /credits | `credits` | list, transfer |
| Credit Transfer Codes | /credit_transfer_codes | `credits codes` | list |
| Files | /files | `files` | list, get, upload, delete |
| Reports | /reports | `reports` | list, get, create, delete |
| Report Templates | /report_templates | `reports templates` | list, get, create, update, delete |
| Device Hotspots | /device_hotspots | `hotspots` | list, get, create, update, delete |
| Device Passwords | /device_passwords | `passwords` | get, update |
| SMTP Configurations | /smtp_configurations | `smtp` | list, get, create, update, delete |
## API Conventions
- **Pagination**: `offset` + `limit` query parameters; `meta.total` in response
- **Filtering**: Resource-specific params (e.g., `status`, `tag` for devices)
- **Sorting**: `sort` param with field name, prefix `-` for descending
- **Timestamps**: `Y-m-d H:i:s` format, UTC timezone
- **Rate limit**: 100,000 requests/month per Client ID; HTTP 429 when exceeded
## Session State
Stored at `~/.cli-anything-rms/session.json`:
- `last_device_id`: Last accessed device for convenience
- `history`: Command history (max 50 entries)
- `preferences`: Default limit, sort order
## Testing
- `test_core.py`: Unit tests with mocked API responses (no RMS account needed)
- `test_full_e2e.py`: E2E tests requiring valid `RMS_API_TOKEN`
@@ -0,0 +1,90 @@
# cli-anything-rms
CLI harness for [Teltonika RMS](https://rms.teltonika-networks.com/) — manage devices, alerts, configurations, and more from the command line.
## Installation
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness
```
Or for development:
```bash
cd rms/agent-harness
pip install -e ".[dev]"
```
## Authentication
1. Log in to your Teltonika RMS account
2. Enable 2FA in Security settings (required)
3. Create a Personal Access Token in Applications settings
4. Set the token:
```bash
# Option A: Environment variable
export RMS_API_TOKEN=your_token_here
# Option B: CLI config
cli-anything-rms config set api_token your_token_here
```
## Usage
### One-shot commands
```bash
# List all devices
cli-anything-rms devices list
# Get device details
cli-anything-rms devices get 12345
# List online devices with JSON output
cli-anything-rms --json devices list --status online
# List alerts for a device
cli-anything-rms alerts list --device 12345
# Get device location
cli-anything-rms location get 12345
```
### Interactive REPL
```bash
cli-anything-rms
```
### All command groups
| Group | Description |
|-------|-------------|
| `devices` | List, get, update, delete devices |
| `companies` | Manage companies |
| `users` | Manage users and invitations |
| `tags` | Manage device tags |
| `alerts` | View alerts and manage alert configurations |
| `configs` | View and update device configurations |
| `remote-access` | Manage remote access sessions |
| `logs` | View and manage device logs |
| `location` | Get device location and history |
| `credits` | View credits and transfer codes |
| `files` | Manage files |
| `reports` | Manage reports and templates |
| `hotspots` | Manage device hotspots |
| `passwords` | View and update device passwords |
| `smtp` | Manage SMTP configurations |
| `auth` | Test API connectivity |
| `config` | Manage local CLI configuration |
## Testing
```bash
# Unit tests (no API token needed)
pytest tests/test_core.py -v
# E2E tests (requires RMS_API_TOKEN)
pytest tests/test_full_e2e.py -v
```
@@ -0,0 +1,2 @@
"""CLI-Anything RMS — Teltonika RMS API client."""
__version__ = "1.0.0"
@@ -0,0 +1,3 @@
from cli_anything.rms.rms_cli import main
main()
@@ -0,0 +1,38 @@
"""Alert operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_alerts(token, device_id=None, limit=25, offset=0):
params = {"limit": limit, "offset": offset}
if device_id is not None:
params["device_id"] = device_id
return api_get("/device_alerts", params=params, token=token)
def get_alert(token, alert_id):
return api_get(f"/device_alerts/{alert_id}", token=token)
def delete_alert(token, alert_id):
return api_delete(f"/device_alerts/{alert_id}", token=token)
def list_alert_configs(token, limit=25, offset=0):
return api_get("/device_alert_configurations", params={"limit": limit, "offset": offset}, token=token)
def get_alert_config(token, config_id):
return api_get(f"/device_alert_configurations/{config_id}", token=token)
def create_alert_config(token, data):
return api_post("/device_alert_configurations", data=data, token=token)
def update_alert_config(token, config_id, data):
return api_put(f"/device_alert_configurations/{config_id}", data=data, token=token)
def delete_alert_config(token, config_id):
return api_delete(f"/device_alert_configurations/{config_id}", token=token)
@@ -0,0 +1,23 @@
"""Company operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_companies(token, limit=25, offset=0):
return api_get("/companies", params={"limit": limit, "offset": offset}, token=token)
def get_company(token, company_id):
return api_get(f"/companies/{company_id}", token=token)
def create_company(token, data):
return api_post("/companies", data=data, token=token)
def update_company(token, company_id, data):
return api_put(f"/companies/{company_id}", data=data, token=token)
def delete_company(token, company_id):
return api_delete(f"/companies/{company_id}", token=token)
@@ -0,0 +1,18 @@
"""Device configuration operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_put
def list_configs(token, device_id=None, limit=25, offset=0):
params = {"limit": limit, "offset": offset}
if device_id is not None:
params["device_id"] = device_id
return api_get("/device_configurations", params=params, token=token)
def get_config(token, config_id):
return api_get(f"/device_configurations/{config_id}", token=token)
def update_config(token, config_id, data):
return api_put(f"/device_configurations/{config_id}", data=data, token=token)
@@ -0,0 +1,15 @@
"""Credit operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post
def list_credits(token, limit=25, offset=0):
return api_get("/credits", params={"limit": limit, "offset": offset}, token=token)
def transfer_credits(token, data):
return api_post("/credits", data=data, token=token)
def list_transfer_codes(token, limit=25, offset=0):
return api_get("/credit_transfer_codes", params={"limit": limit, "offset": offset}, token=token)
@@ -0,0 +1,26 @@
"""Device operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_put, api_delete
def list_devices(token, status=None, tag=None, limit=25, offset=0, sort=None):
params = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if tag:
params["tag"] = ",".join(tag) if isinstance(tag, (list, tuple)) else tag
if sort:
params["sort"] = sort
return api_get("/devices", params=params, token=token)
def get_device(token, device_id):
return api_get(f"/devices/{device_id}", token=token)
def update_device(token, device_id, data):
return api_put(f"/devices/{device_id}", data=data, token=token)
def delete_device(token, device_id):
return api_delete(f"/devices/{device_id}", token=token)
@@ -0,0 +1,30 @@
"""File operations for RMS API."""
from __future__ import annotations
import os
import requests
from cli_anything.rms.utils.rms_backend import (
api_get, api_post, api_delete,
API_BASE, _require_api_token, _make_auth_headers, _handle_response,
)
def list_files(token, limit=25, offset=0):
return api_get("/files", params={"limit": limit, "offset": offset}, token=token)
def get_file(token, file_id):
return api_get(f"/files/{file_id}", token=token)
def upload_file(token, file_path, data=None):
"""Upload a file. Uses multipart form data."""
token = _require_api_token(token)
headers = _make_auth_headers(token)
with open(file_path, "rb") as f:
files = {"file": (os.path.basename(file_path), f)}
resp = requests.post(f"{API_BASE}/files", files=files, data=data, headers=headers, timeout=60)
return _handle_response(resp)
def delete_file(token, file_id):
return api_delete(f"/files/{file_id}", token=token)
@@ -0,0 +1,26 @@
"""Device hotspot operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_hotspots(token, device_id=None, limit=25, offset=0):
params = {"limit": limit, "offset": offset}
if device_id is not None:
params["device_id"] = device_id
return api_get("/device_hotspots", params=params, token=token)
def get_hotspot(token, hotspot_id):
return api_get(f"/device_hotspots/{hotspot_id}", token=token)
def create_hotspot(token, data):
return api_post("/device_hotspots", data=data, token=token)
def update_hotspot(token, hotspot_id, data):
return api_put(f"/device_hotspots/{hotspot_id}", data=data, token=token)
def delete_hotspot(token, hotspot_id):
return api_delete(f"/device_hotspots/{hotspot_id}", token=token)
@@ -0,0 +1,15 @@
"""Device location operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get
def get_location(token, device_id):
return api_get(f"/device_location/{device_id}", token=token)
def list_location_history(token, device_id, limit=25, offset=0):
return api_get(
f"/device_location/{device_id}/history",
params={"limit": limit, "offset": offset},
token=token,
)
@@ -0,0 +1,18 @@
"""Device log operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_delete
def list_logs(token, device_id=None, limit=25, offset=0):
params = {"limit": limit, "offset": offset}
if device_id is not None:
params["device_id"] = device_id
return api_get("/device_logs", params=params, token=token)
def get_log(token, log_id):
return api_get(f"/device_logs/{log_id}", token=token)
def delete_log(token, log_id):
return api_delete(f"/device_logs/{log_id}", token=token)
@@ -0,0 +1,11 @@
"""Device password operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_put
def get_password(token, device_id):
return api_get(f"/device_passwords/{device_id}", token=token)
def update_password(token, device_id, data):
return api_put(f"/device_passwords/{device_id}", data=data, token=token)
@@ -0,0 +1,22 @@
"""Remote access operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_delete
def list_sessions(token, device_id=None, limit=25, offset=0):
params = {"limit": limit, "offset": offset}
if device_id is not None:
params["device_id"] = device_id
return api_get("/device_remote_access", params=params, token=token)
def get_session(token, session_id):
return api_get(f"/device_remote_access/{session_id}", token=token)
def create_session(token, data):
return api_post("/device_remote_access", data=data, token=token)
def delete_session(token, session_id):
return api_delete(f"/device_remote_access/{session_id}", token=token)
@@ -0,0 +1,39 @@
"""Report operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_reports(token, limit=25, offset=0):
return api_get("/reports", params={"limit": limit, "offset": offset}, token=token)
def get_report(token, report_id):
return api_get(f"/reports/{report_id}", token=token)
def create_report(token, data):
return api_post("/reports", data=data, token=token)
def delete_report(token, report_id):
return api_delete(f"/reports/{report_id}", token=token)
def list_templates(token, limit=25, offset=0):
return api_get("/report_templates", params={"limit": limit, "offset": offset}, token=token)
def get_template(token, template_id):
return api_get(f"/report_templates/{template_id}", token=token)
def create_template(token, data):
return api_post("/report_templates", data=data, token=token)
def update_template(token, template_id, data):
return api_put(f"/report_templates/{template_id}", data=data, token=token)
def delete_template(token, template_id):
return api_delete(f"/report_templates/{template_id}", token=token)
@@ -0,0 +1,94 @@
"""Session state for RMS CLI."""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
class Session:
"""RMS CLI session state."""
def __init__(self, session_file: str = None):
self.session_file = session_file or str(
Path.home() / ".cli-anything-rms" / "session.json"
)
self.last_device_id = None
self.history = []
self.preferences = {}
self.max_history = 50
if os.path.exists(self.session_file):
try:
with open(self.session_file, "r") as f:
data = json.load(f)
self.last_device_id = data.get("last_device_id")
self.history = data.get("history", [])
self.preferences = data.get("preferences", {})
except (json.JSONDecodeError, IOError):
pass
def set_last_device(self, device_id: str):
self.last_device_id = device_id
self._save()
def save_history(self, command: str, result: dict):
self.history.append({
"command": command,
"result": result,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
if len(self.history) > self.max_history:
self.history = self.history[-self.max_history:]
self._save()
def clear(self):
self.last_device_id = None
self.history = []
self.preferences = {}
self._save()
def status(self):
return {
"last_device_id": self.last_device_id,
"history_count": len(self.history),
"preferences": self.preferences,
"session_file": self.session_file,
}
def _save(self):
_locked_save_json(
self.session_file,
{
"last_device_id": self.last_device_id,
"history": self.history,
"preferences": self.preferences,
},
indent=2,
)
@@ -0,0 +1,23 @@
"""SMTP configuration operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_smtp_configs(token, limit=25, offset=0):
return api_get("/smtp_configurations", params={"limit": limit, "offset": offset}, token=token)
def get_smtp_config(token, config_id):
return api_get(f"/smtp_configurations/{config_id}", token=token)
def create_smtp_config(token, data):
return api_post("/smtp_configurations", data=data, token=token)
def update_smtp_config(token, config_id, data):
return api_put(f"/smtp_configurations/{config_id}", data=data, token=token)
def delete_smtp_config(token, config_id):
return api_delete(f"/smtp_configurations/{config_id}", token=token)
@@ -0,0 +1,23 @@
"""Tag operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_tags(token, limit=25, offset=0):
return api_get("/tags", params={"limit": limit, "offset": offset}, token=token)
def get_tag(token, tag_id):
return api_get(f"/tags/{tag_id}", token=token)
def create_tag(token, data):
return api_post("/tags", data=data, token=token)
def update_tag(token, tag_id, data):
return api_put(f"/tags/{tag_id}", data=data, token=token)
def delete_tag(token, tag_id):
return api_delete(f"/tags/{tag_id}", token=token)
@@ -0,0 +1,23 @@
"""User operations for RMS API."""
from __future__ import annotations
from cli_anything.rms.utils.rms_backend import api_get, api_post, api_put, api_delete
def list_users(token, limit=25, offset=0):
return api_get("/users", params={"limit": limit, "offset": offset}, token=token)
def get_user(token, user_id):
return api_get(f"/users/{user_id}", token=token)
def invite_user(token, data):
return api_post("/user_invitations", data=data, token=token)
def update_user(token, user_id, data):
return api_put(f"/users/{user_id}", data=data, token=token)
def delete_user(token, user_id):
return api_delete(f"/users/{user_id}", token=token)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
---
name: >-
cli-anything-rms
description: >-
Teltonika RMS device management and monitoring CLI
---
# cli-anything-rms
CLI harness for Teltonika RMS (Remote Management System). Manage routers, gateways, and IoT devices via the RMS REST API.
## Installation
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness
```
## Authentication
Set `RMS_API_TOKEN` environment variable or run `cli-anything-rms config set api_token <token>`.
## Command Groups
### devices
- `devices list [--status online|offline] [--tag TAG] [--limit N] [--offset N] [--sort FIELD]` — List devices
- `devices get <device_id>` — Get device details
- `devices update <device_id> [--name NAME] [--tag TAG]` — Update device
- `devices delete <device_id>` — Delete device
### companies
- `companies list [--limit N] [--offset N]` — List companies
- `companies get <company_id>` — Get company details
- `companies create --name NAME` — Create company
- `companies update <company_id> [--name NAME]` — Update company
- `companies delete <company_id>` — Delete company
### users
- `users list [--limit N] [--offset N]` — List users
- `users get <user_id>` — Get user details
- `users invite --email EMAIL [--role ROLE]` — Invite user
- `users update <user_id> [--role ROLE]` — Update user
- `users delete <user_id>` — Delete user
### tags
- `tags list [--limit N] [--offset N]` — List tags
- `tags get <tag_id>` — Get tag details
- `tags create --name NAME` — Create tag
- `tags update <tag_id> [--name NAME]` — Update tag
- `tags delete <tag_id>` — Delete tag
### alerts
- `alerts list [--device DEVICE_ID] [--limit N] [--offset N]` — List alerts
- `alerts get <alert_id>` — Get alert details
- `alerts delete <alert_id>` — Delete alert
- `alerts configs list` — List alert configurations
- `alerts configs get <config_id>` — Get alert config
- `alerts configs create --data JSON` — Create alert config
- `alerts configs update <config_id> --data JSON` — Update alert config
- `alerts configs delete <config_id>` — Delete alert config
### configs
- `configs list [--device DEVICE_ID] [--limit N] [--offset N]` — List device configurations
- `configs get <config_id>` — Get configuration
- `configs update <config_id> --data JSON` — Update configuration
### remote-access
- `remote-access list [--device DEVICE_ID] [--limit N]` — List sessions
- `remote-access get <session_id>` — Get session details
- `remote-access create --device DEVICE_ID [--protocol PROTO] [--port PORT]` — Create session
- `remote-access delete <session_id>` — Delete session
### logs
- `logs list [--device DEVICE_ID] [--limit N] [--offset N]` — List logs
- `logs get <log_id>` — Get log details
- `logs delete <log_id>` — Delete log
### location
- `location get <device_id>` — Get current device location
- `location history <device_id> [--limit N] [--offset N]` — Location history
### credits
- `credits list [--limit N] [--offset N]` — List credits
- `credits transfer --code CODE` — Transfer credits
- `credits codes [--limit N]` — List transfer codes
### files
- `files list [--limit N] [--offset N]` — List files
- `files get <file_id>` — Get file details
- `files upload <file_path>` — Upload file
- `files delete <file_id>` — Delete file
### reports
- `reports list [--limit N] [--offset N]` — List reports
- `reports get <report_id>` — Get report
- `reports create --template TEMPLATE_ID [--name NAME]` — Create report
- `reports delete <report_id>` — Delete report
- `reports templates list` — List report templates
### hotspots
- `hotspots list [--device DEVICE_ID] [--limit N]` — List hotspots
- `hotspots get <hotspot_id>` — Get hotspot details
- `hotspots create --device DEVICE_ID --name NAME` — Create hotspot
- `hotspots update <hotspot_id> [--name NAME]` — Update hotspot
- `hotspots delete <hotspot_id>` — Delete hotspot
### passwords
- `passwords get <device_id>` — Get device password
- `passwords update <device_id> --password PASSWORD` — Update password
- `passwords update <device_id> --password-stdin` — Update password (reads from stdin, safer)
### smtp
- `smtp list [--limit N] [--offset N]` — List SMTP configs
- `smtp get <config_id>` — Get SMTP config
- `smtp create --host HOST [--port PORT] [--username USER] [--password PASS]` — Create SMTP config
- `smtp update <config_id> [--host HOST] [--port PORT]` — Update SMTP config
- `smtp delete <config_id>` — Delete SMTP config
### auth
- `auth test` — Test API connectivity
- `auth status` — Show current auth info
### config
- `config set <key> <value>` — Set configuration (api_token, default_limit)
- `config get [key]` — Show configuration
- `config delete <key>` — Delete configuration
- `config path` — Show config file path
## Examples
```bash
# List all online devices
cli-anything-rms devices list --status online
# Get device details as JSON
cli-anything-rms --json devices get 12345
# Check alerts for a specific device
cli-anything-rms alerts list --device 12345
# Interactive mode
cli-anything-rms
```
@@ -0,0 +1,118 @@
# TEST.md — cli-anything-rms Test Plan
## Test Plan
### Test Inventory
| File | Type | Count |
|------|------|-------|
| test_core.py | Unit | ~40 tests |
| test_full_e2e.py | E2E | ~20 tests |
### Unit Tests (test_core.py)
Tests use `unittest.mock.patch` to mock `requests` calls. No RMS account needed.
**Backend tests:**
- `test_get_api_token_from_env` — reads from RMS_API_TOKEN env var
- `test_get_api_token_from_config` — reads from config file
- `test_require_api_token_missing` — raises RuntimeError with instructions
- `test_make_auth_headers` — returns correct Bearer header
- `test_api_get_success` — parses JSON response
- `test_api_get_error` — raises RuntimeError on HTTP error
- `test_api_get_rate_limited` — handles 429 response
- `test_api_post_success` — sends JSON body
- `test_api_put_success` — sends JSON body
- `test_api_delete_success` — returns response
**Core module tests (per resource):**
- `test_list_devices` — calls GET /devices with params
- `test_get_device` — calls GET /devices/{id}
- `test_list_companies` — calls GET /companies
- `test_list_tags` — calls GET /tags
- `test_create_tag` — calls POST /tags
- `test_list_alerts` — calls GET /device_alerts
- `test_get_location` — calls GET /device_location/{device_id}
**Session tests:**
- `test_session_create` — creates session file
- `test_session_save_load` — round-trip persistence
- `test_session_history` — tracks command history
- `test_session_clear` — resets state
### E2E Tests (test_full_e2e.py)
Require `RMS_API_TOKEN` environment variable. Skip if not set.
**Connectivity:**
- `test_api_connectivity` — GET /devices returns success
**Device workflows:**
- `test_list_devices` — returns device list with pagination
- `test_get_device` — returns device details (uses first device from list)
**Resource listing:**
- `test_list_companies` — returns company list
- `test_list_users` — returns user list
- `test_list_tags` — returns tag list
**CLI integration:**
- `test_cli_devices_list``cli-anything-rms --json devices list` returns valid JSON
- `test_cli_auth_test``cli-anything-rms auth test` succeeds
### Running Tests
**Important:** Use `python -m pytest` (not bare `pytest`) to avoid namespace package import errors with `cli_anything`:
```bash
cd rms/agent-harness
source .venv/bin/activate
export RMS_API_TOKEN=<your-pat>
# Unit tests (no token needed)
python -m pytest cli_anything/rms/tests/test_core.py -v
# E2E tests (requires RMS_API_TOKEN)
python -m pytest cli_anything/rms/tests/test_full_e2e.py -v
```
Bare `pytest` resolves the local `cli_anything/` directory instead of the installed namespace package, causing `ModuleNotFoundError` in tests that use direct imports. Subprocess-based tests (TestCLIIntegrationE2E) are unaffected.
### Realistic Workflows
1. **Device monitoring**: List devices → filter by status → get details → check location
2. **Alert management**: List alerts → view alert config → create new config
3. **User admin**: List users → invite user → update role
---
## Test Results
### Unit Tests — 76/76 passed (0.10s)
### E2E Tests — 9/9 passed (3.03s) — 2026-03-23
Validated against live Teltonika RMS API with a real PAT.
| Test | Result | Notes |
|------|--------|-------|
| `test_api_connectivity` | PASSED | GET /devices?limit=1 returns success |
| `test_auth_headers` | PASSED | Bearer token header constructed correctly |
| `test_list_devices` | PASSED | devices returned with pagination |
| `test_get_device` | PASSED | Single device detail fetch works |
| `test_list_companies` | PASSED | Company listing returns success |
| `test_list_users` | PASSED | User listing returns success |
| `test_list_tags` | PASSED | Tag listing returns success |
| `test_cli_devices_list` | PASSED | `--json devices list --limit 1` returns valid JSON |
| `test_cli_auth_test` | PASSED | `auth test` exits 0 |
### Manual CLI Validation — 2026-03-23
| Command | Result |
|---------|--------|
| `python -m cli_anything.rms auth test` | "API connection successful" |
| `python -m cli_anything.rms devices list` | all devices listed (human-readable) |
| `python -m cli_anything.rms --json devices list --limit 5` | Valid JSON, 5 devices, correct metadata |
**No endpoint path adjustments were needed** — all API paths matched the real Teltonika RMS API.
@@ -0,0 +1,922 @@
"""Unit tests for cli-anything-rms core modules."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import pytest
# ── Backend tests ──────────────────────────────────────────────────────
class TestBackend:
"""Tests for rms_backend module."""
def test_get_api_token_from_env(self):
from cli_anything.rms.utils.rms_backend import get_api_token
with patch.dict(os.environ, {"RMS_API_TOKEN": "test-token-123"}):
assert get_api_token() == "test-token-123"
def test_get_api_token_cli_override(self):
from cli_anything.rms.utils.rms_backend import get_api_token
with patch.dict(os.environ, {"RMS_API_TOKEN": "env-token"}):
assert get_api_token("cli-token") == "cli-token"
def test_get_api_token_from_config(self, tmp_path):
from cli_anything.rms.utils import rms_backend
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"api_token": "config-token"}))
with patch.object(rms_backend, "CONFIG_FILE", config_file):
with patch.dict(os.environ, {}, clear=True):
# Remove RMS_API_TOKEN if present
os.environ.pop("RMS_API_TOKEN", None)
assert rms_backend.get_api_token() == "config-token"
def test_require_api_token_missing(self):
from cli_anything.rms.utils.rms_backend import _require_api_token
with pytest.raises(RuntimeError, match="RMS API token not found"):
_require_api_token(None)
def test_require_api_token_present(self):
from cli_anything.rms.utils.rms_backend import _require_api_token
assert _require_api_token("my-token") == "my-token"
def test_make_auth_headers(self):
from cli_anything.rms.utils.rms_backend import _make_auth_headers
headers = _make_auth_headers("test-token")
assert headers == {"Authorization": "Bearer test-token"}
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_get_success(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_get
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"success": True,
"data": [{"id": 1, "name": "Router-1"}],
"meta": {"total": 1},
}
mock_resp.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_resp
result = api_get("/devices", token="test-token")
assert result["success"] is True
assert len(result["data"]) == 1
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_get_with_params(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_get
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"success": True, "data": []}
mock_resp.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_resp
api_get("/devices", params={"status": "online", "limit": 10}, token="test-token")
mock_requests.get.assert_called_once()
call_kwargs = mock_requests.get.call_args
assert call_kwargs.kwargs["params"] == {"status": "online", "limit": 10}
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_get_error(self, mock_requests):
import requests as _requests
from cli_anything.rms.utils.rms_backend import api_get
# Preserve real exception classes so except clauses work
mock_requests.RequestException = _requests.RequestException
mock_requests.exceptions = _requests.exceptions
mock_resp = MagicMock()
mock_resp.status_code = 404
mock_resp.text = '{"success": false, "errors": [{"message": "Not found"}]}'
mock_resp.json.return_value = {"success": False, "errors": [{"message": "Not found"}]}
mock_resp.raise_for_status.side_effect = _requests.exceptions.HTTPError("404 Not Found")
mock_requests.get.return_value = mock_resp
with pytest.raises(RuntimeError, match="Not found"):
api_get("/devices/999999", token="test-token")
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_get_rate_limited(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_get
mock_resp = MagicMock()
mock_resp.status_code = 429
mock_resp.headers = {"Retry-After": "60"}
mock_resp.text = "Rate limit exceeded"
mock_resp.raise_for_status.side_effect = Exception("429 Too Many Requests")
mock_requests.get.return_value = mock_resp
with pytest.raises(RuntimeError, match="[Rr]ate limit"):
api_get("/devices", token="test-token")
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_post_success(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_post
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.json.return_value = {
"success": True,
"data": {"id": 10, "name": "New Tag"},
}
mock_resp.raise_for_status = MagicMock()
mock_requests.post.return_value = mock_resp
result = api_post("/tags", data={"name": "New Tag"}, token="test-token")
assert result["success"] is True
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_put_success(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_put
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"success": True,
"data": {"id": 10, "name": "Updated"},
}
mock_resp.raise_for_status = MagicMock()
mock_requests.put.return_value = mock_resp
result = api_put("/tags/10", data={"name": "Updated"}, token="test-token")
assert result["success"] is True
@patch("cli_anything.rms.utils.rms_backend.requests")
def test_api_delete_success(self, mock_requests):
from cli_anything.rms.utils.rms_backend import api_delete
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"success": True}
mock_resp.raise_for_status = MagicMock()
mock_requests.delete.return_value = mock_resp
result = api_delete("/tags/10", token="test-token")
assert result["success"] is True
def test_config_save_load(self, tmp_path):
from cli_anything.rms.utils import rms_backend
config_file = tmp_path / "config.json"
with patch.object(rms_backend, "CONFIG_FILE", config_file):
with patch.object(rms_backend, "CONFIG_DIR", tmp_path):
rms_backend.save_config({"api_token": "saved-token", "default_limit": 50})
loaded = rms_backend.load_config()
assert loaded["api_token"] == "saved-token"
assert loaded["default_limit"] == 50
# ── Core module tests ──────────────────────────────────────────────────
class TestDevices:
"""Tests for devices core module."""
@patch("cli_anything.rms.core.devices.api_get")
def test_list_devices(self, mock_get):
from cli_anything.rms.core.devices import list_devices
mock_get.return_value = {
"success": True,
"data": [{"id": 1, "name": "Router-1"}],
"meta": {"total": 1},
}
result = list_devices("token")
mock_get.assert_called_once()
assert result["data"][0]["name"] == "Router-1"
@patch("cli_anything.rms.core.devices.api_get")
def test_list_devices_with_filters(self, mock_get):
from cli_anything.rms.core.devices import list_devices
mock_get.return_value = {"success": True, "data": [], "meta": {"total": 0}}
list_devices("token", status="online", tag=["office"], limit=10, offset=5)
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("status") == "online"
@patch("cli_anything.rms.core.devices.api_get")
def test_get_device(self, mock_get):
from cli_anything.rms.core.devices import get_device
mock_get.return_value = {
"success": True,
"data": {"id": 42, "name": "Gateway-42", "status": "online"},
}
result = get_device("token", "42")
assert result["data"]["id"] == 42
class TestCompanies:
@patch("cli_anything.rms.core.companies.api_get")
def test_list_companies(self, mock_get):
from cli_anything.rms.core.companies import list_companies
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_companies("token")
assert result["success"] is True
@patch("cli_anything.rms.core.companies.api_post")
def test_create_company(self, mock_post):
from cli_anything.rms.core.companies import create_company
mock_post.return_value = {"success": True, "data": {"id": 2, "name": "Acme"}}
result = create_company("token", {"name": "Acme"})
assert result["data"]["name"] == "Acme"
class TestTags:
@patch("cli_anything.rms.core.tags.api_get")
def test_list_tags(self, mock_get):
from cli_anything.rms.core.tags import list_tags
mock_get.return_value = {"success": True, "data": [{"id": 1, "name": "office"}]}
result = list_tags("token")
assert len(result["data"]) == 1
@patch("cli_anything.rms.core.tags.api_post")
def test_create_tag(self, mock_post):
from cli_anything.rms.core.tags import create_tag
mock_post.return_value = {"success": True, "data": {"id": 3, "name": "new-tag"}}
result = create_tag("token", {"name": "new-tag"})
assert result["data"]["name"] == "new-tag"
class TestAlerts:
@patch("cli_anything.rms.core.alerts.api_get")
def test_list_alerts(self, mock_get):
from cli_anything.rms.core.alerts import list_alerts
mock_get.return_value = {"success": True, "data": []}
result = list_alerts("token")
assert result["success"] is True
@patch("cli_anything.rms.core.alerts.api_get")
def test_list_alerts_by_device(self, mock_get):
from cli_anything.rms.core.alerts import list_alerts
mock_get.return_value = {"success": True, "data": []}
list_alerts("token", device_id="42")
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("device_id") == "42"
class TestLocation:
@patch("cli_anything.rms.core.location.api_get")
def test_get_location(self, mock_get):
from cli_anything.rms.core.location import get_location
mock_get.return_value = {
"success": True,
"data": {"latitude": 54.6872, "longitude": 25.2797},
}
result = get_location("token", "42")
assert "latitude" in result["data"]
class TestSession:
def test_session_create(self, tmp_path):
from cli_anything.rms.core.session import Session
sf = str(tmp_path / "session.json")
s = Session(session_file=sf)
assert s.status()["history_count"] == 0
def test_session_save_load(self, tmp_path):
from cli_anything.rms.core.session import Session
sf = str(tmp_path / "session.json")
s = Session(session_file=sf)
s.set_last_device("42")
s.save_history("devices list", {"count": 5})
s2 = Session(session_file=sf)
assert s2.last_device_id == "42"
assert len(s2.history) == 1
def test_session_clear(self, tmp_path):
from cli_anything.rms.core.session import Session
sf = str(tmp_path / "session.json")
s = Session(session_file=sf)
s.set_last_device("42")
s.save_history("test", {})
s.clear()
assert s.last_device_id is None
assert len(s.history) == 0
def test_session_history_limit(self, tmp_path):
from cli_anything.rms.core.session import Session
sf = str(tmp_path / "session.json")
s = Session(session_file=sf)
for i in range(60):
s.save_history(f"cmd-{i}", {})
assert len(s.history) == 50
# ── Phase 2: Tests for previously untested core modules ───────────────
class TestUsers:
@patch("cli_anything.rms.core.users.api_get")
def test_list_users(self, mock_get):
from cli_anything.rms.core.users import list_users
mock_get.return_value = {"success": True, "data": [{"id": 1, "email": "a@b.com"}]}
result = list_users("token")
mock_get.assert_called_once()
assert result["data"][0]["email"] == "a@b.com"
@patch("cli_anything.rms.core.users.api_get")
def test_get_user(self, mock_get):
from cli_anything.rms.core.users import get_user
mock_get.return_value = {"success": True, "data": {"id": 5, "email": "u@b.com"}}
result = get_user("token", "5")
assert result["data"]["id"] == 5
@patch("cli_anything.rms.core.users.api_post")
def test_invite_user(self, mock_post):
from cli_anything.rms.core.users import invite_user
mock_post.return_value = {"success": True, "data": {"id": 6, "email": "new@b.com"}}
result = invite_user("token", {"email": "new@b.com"})
assert result["data"]["email"] == "new@b.com"
@patch("cli_anything.rms.core.users.api_put")
def test_update_user(self, mock_put):
from cli_anything.rms.core.users import update_user
mock_put.return_value = {"success": True, "data": {"id": 5, "name": "Updated"}}
result = update_user("token", "5", {"name": "Updated"})
assert result["data"]["name"] == "Updated"
@patch("cli_anything.rms.core.users.api_delete")
def test_delete_user(self, mock_delete):
from cli_anything.rms.core.users import delete_user
mock_delete.return_value = {"success": True}
result = delete_user("token", "5")
assert result["success"] is True
class TestConfigs:
@patch("cli_anything.rms.core.configs.api_get")
def test_list_configs(self, mock_get):
from cli_anything.rms.core.configs import list_configs
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_configs("token")
mock_get.assert_called_once()
assert result["success"] is True
@patch("cli_anything.rms.core.configs.api_get")
def test_list_configs_by_device(self, mock_get):
from cli_anything.rms.core.configs import list_configs
mock_get.return_value = {"success": True, "data": []}
list_configs("token", device_id="42")
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("device_id") == "42"
@patch("cli_anything.rms.core.configs.api_get")
def test_get_config(self, mock_get):
from cli_anything.rms.core.configs import get_config
mock_get.return_value = {"success": True, "data": {"id": 10, "name": "cfg1"}}
result = get_config("token", "10")
assert result["data"]["id"] == 10
@patch("cli_anything.rms.core.configs.api_put")
def test_update_config(self, mock_put):
from cli_anything.rms.core.configs import update_config
mock_put.return_value = {"success": True, "data": {"id": 10, "value": "new"}}
result = update_config("token", "10", {"value": "new"})
assert result["success"] is True
class TestRemoteAccess:
@patch("cli_anything.rms.core.remote_access.api_get")
def test_list_sessions(self, mock_get):
from cli_anything.rms.core.remote_access import list_sessions
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_sessions("token")
mock_get.assert_called_once()
assert result["success"] is True
@patch("cli_anything.rms.core.remote_access.api_get")
def test_list_sessions_by_device(self, mock_get):
from cli_anything.rms.core.remote_access import list_sessions
mock_get.return_value = {"success": True, "data": []}
list_sessions("token", device_id="42")
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("device_id") == "42"
@patch("cli_anything.rms.core.remote_access.api_get")
def test_get_session(self, mock_get):
from cli_anything.rms.core.remote_access import get_session
mock_get.return_value = {"success": True, "data": {"id": 3, "status": "active"}}
result = get_session("token", "3")
assert result["data"]["status"] == "active"
@patch("cli_anything.rms.core.remote_access.api_post")
def test_create_session(self, mock_post):
from cli_anything.rms.core.remote_access import create_session
mock_post.return_value = {"success": True, "data": {"id": 4, "device_id": "42"}}
result = create_session("token", {"device_id": "42", "type": "ssh"})
assert result["data"]["device_id"] == "42"
@patch("cli_anything.rms.core.remote_access.api_delete")
def test_delete_session(self, mock_delete):
from cli_anything.rms.core.remote_access import delete_session
mock_delete.return_value = {"success": True}
result = delete_session("token", "3")
assert result["success"] is True
class TestLogs:
@patch("cli_anything.rms.core.logs.api_get")
def test_list_logs(self, mock_get):
from cli_anything.rms.core.logs import list_logs
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_logs("token")
mock_get.assert_called_once()
assert result["success"] is True
@patch("cli_anything.rms.core.logs.api_get")
def test_list_logs_by_device(self, mock_get):
from cli_anything.rms.core.logs import list_logs
mock_get.return_value = {"success": True, "data": []}
list_logs("token", device_id="42")
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("device_id") == "42"
@patch("cli_anything.rms.core.logs.api_get")
def test_get_log(self, mock_get):
from cli_anything.rms.core.logs import get_log
mock_get.return_value = {"success": True, "data": {"id": 7, "message": "boot"}}
result = get_log("token", "7")
assert result["data"]["id"] == 7
@patch("cli_anything.rms.core.logs.api_delete")
def test_delete_log(self, mock_delete):
from cli_anything.rms.core.logs import delete_log
mock_delete.return_value = {"success": True}
result = delete_log("token", "7")
assert result["success"] is True
class TestCredits:
@patch("cli_anything.rms.core.credits.api_get")
def test_list_credits(self, mock_get):
from cli_anything.rms.core.credits import list_credits
mock_get.return_value = {"success": True, "data": [{"id": 1, "amount": 100}]}
result = list_credits("token")
mock_get.assert_called_once()
assert result["data"][0]["amount"] == 100
@patch("cli_anything.rms.core.credits.api_post")
def test_transfer_credits(self, mock_post):
from cli_anything.rms.core.credits import transfer_credits
mock_post.return_value = {"success": True, "data": {"transferred": 50}}
result = transfer_credits("token", {"amount": 50, "to_company": "2"})
assert result["data"]["transferred"] == 50
@patch("cli_anything.rms.core.credits.api_get")
def test_list_transfer_codes(self, mock_get):
from cli_anything.rms.core.credits import list_transfer_codes
mock_get.return_value = {"success": True, "data": [{"code": "ABC123"}]}
result = list_transfer_codes("token")
assert result["data"][0]["code"] == "ABC123"
class TestFiles:
@patch("cli_anything.rms.core.files.api_get")
def test_list_files(self, mock_get):
from cli_anything.rms.core.files import list_files
mock_get.return_value = {"success": True, "data": [{"id": 1, "name": "fw.bin"}]}
result = list_files("token")
mock_get.assert_called_once()
assert result["data"][0]["name"] == "fw.bin"
@patch("cli_anything.rms.core.files.api_get")
def test_get_file(self, mock_get):
from cli_anything.rms.core.files import get_file
mock_get.return_value = {"success": True, "data": {"id": 1, "name": "fw.bin", "size": 1024}}
result = get_file("token", "1")
assert result["data"]["size"] == 1024
@patch("cli_anything.rms.core.files.api_delete")
def test_delete_file(self, mock_delete):
from cli_anything.rms.core.files import delete_file
mock_delete.return_value = {"success": True}
result = delete_file("token", "1")
assert result["success"] is True
class TestReports:
@patch("cli_anything.rms.core.reports.api_get")
def test_list_reports(self, mock_get):
from cli_anything.rms.core.reports import list_reports
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_reports("token")
mock_get.assert_called_once()
assert result["success"] is True
@patch("cli_anything.rms.core.reports.api_get")
def test_get_report(self, mock_get):
from cli_anything.rms.core.reports import get_report
mock_get.return_value = {"success": True, "data": {"id": 1, "name": "Weekly"}}
result = get_report("token", "1")
assert result["data"]["name"] == "Weekly"
@patch("cli_anything.rms.core.reports.api_post")
def test_create_report(self, mock_post):
from cli_anything.rms.core.reports import create_report
mock_post.return_value = {"success": True, "data": {"id": 2, "name": "Daily"}}
result = create_report("token", {"name": "Daily"})
assert result["data"]["name"] == "Daily"
@patch("cli_anything.rms.core.reports.api_delete")
def test_delete_report(self, mock_delete):
from cli_anything.rms.core.reports import delete_report
mock_delete.return_value = {"success": True}
result = delete_report("token", "1")
assert result["success"] is True
@patch("cli_anything.rms.core.reports.api_get")
def test_list_templates(self, mock_get):
from cli_anything.rms.core.reports import list_templates
mock_get.return_value = {"success": True, "data": [{"id": 1, "name": "tmpl1"}]}
result = list_templates("token")
assert len(result["data"]) == 1
@patch("cli_anything.rms.core.reports.api_post")
def test_create_template(self, mock_post):
from cli_anything.rms.core.reports import create_template
mock_post.return_value = {"success": True, "data": {"id": 2, "name": "new-tmpl"}}
result = create_template("token", {"name": "new-tmpl"})
assert result["data"]["name"] == "new-tmpl"
class TestHotspots:
@patch("cli_anything.rms.core.hotspots.api_get")
def test_list_hotspots(self, mock_get):
from cli_anything.rms.core.hotspots import list_hotspots
mock_get.return_value = {"success": True, "data": [{"id": 1}]}
result = list_hotspots("token")
mock_get.assert_called_once()
assert result["success"] is True
@patch("cli_anything.rms.core.hotspots.api_get")
def test_list_hotspots_by_device(self, mock_get):
from cli_anything.rms.core.hotspots import list_hotspots
mock_get.return_value = {"success": True, "data": []}
list_hotspots("token", device_id="42")
call_args = mock_get.call_args
params = call_args.kwargs.get("params") or call_args[1].get("params", {})
assert params.get("device_id") == "42"
@patch("cli_anything.rms.core.hotspots.api_get")
def test_get_hotspot(self, mock_get):
from cli_anything.rms.core.hotspots import get_hotspot
mock_get.return_value = {"success": True, "data": {"id": 1, "name": "Lobby"}}
result = get_hotspot("token", "1")
assert result["data"]["name"] == "Lobby"
@patch("cli_anything.rms.core.hotspots.api_post")
def test_create_hotspot(self, mock_post):
from cli_anything.rms.core.hotspots import create_hotspot
mock_post.return_value = {"success": True, "data": {"id": 2, "name": "Cafe"}}
result = create_hotspot("token", {"name": "Cafe"})
assert result["data"]["name"] == "Cafe"
@patch("cli_anything.rms.core.hotspots.api_put")
def test_update_hotspot(self, mock_put):
from cli_anything.rms.core.hotspots import update_hotspot
mock_put.return_value = {"success": True, "data": {"id": 1, "name": "Updated"}}
result = update_hotspot("token", "1", {"name": "Updated"})
assert result["data"]["name"] == "Updated"
@patch("cli_anything.rms.core.hotspots.api_delete")
def test_delete_hotspot(self, mock_delete):
from cli_anything.rms.core.hotspots import delete_hotspot
mock_delete.return_value = {"success": True}
result = delete_hotspot("token", "1")
assert result["success"] is True
class TestPasswords:
@patch("cli_anything.rms.core.passwords.api_get")
def test_get_password(self, mock_get):
from cli_anything.rms.core.passwords import get_password
mock_get.return_value = {"success": True, "data": {"device_id": "42", "password": "***"}}
result = get_password("token", "42")
assert result["data"]["device_id"] == "42"
@patch("cli_anything.rms.core.passwords.api_put")
def test_update_password(self, mock_put):
from cli_anything.rms.core.passwords import update_password
mock_put.return_value = {"success": True, "data": {"device_id": "42"}}
result = update_password("token", "42", {"password": "newpass"})
assert result["success"] is True
class TestSmtp:
@patch("cli_anything.rms.core.smtp.api_get")
def test_list_smtp_configs(self, mock_get):
from cli_anything.rms.core.smtp import list_smtp_configs
mock_get.return_value = {"success": True, "data": [{"id": 1, "host": "smtp.test"}]}
result = list_smtp_configs("token")
mock_get.assert_called_once()
assert result["data"][0]["host"] == "smtp.test"
@patch("cli_anything.rms.core.smtp.api_get")
def test_get_smtp_config(self, mock_get):
from cli_anything.rms.core.smtp import get_smtp_config
mock_get.return_value = {"success": True, "data": {"id": 1, "host": "smtp.test"}}
result = get_smtp_config("token", "1")
assert result["data"]["id"] == 1
@patch("cli_anything.rms.core.smtp.api_post")
def test_create_smtp_config(self, mock_post):
from cli_anything.rms.core.smtp import create_smtp_config
mock_post.return_value = {"success": True, "data": {"id": 2, "host": "new.smtp"}}
result = create_smtp_config("token", {"host": "new.smtp", "port": 587})
assert result["data"]["host"] == "new.smtp"
@patch("cli_anything.rms.core.smtp.api_put")
def test_update_smtp_config(self, mock_put):
from cli_anything.rms.core.smtp import update_smtp_config
mock_put.return_value = {"success": True, "data": {"id": 1, "host": "updated.smtp"}}
result = update_smtp_config("token", "1", {"host": "updated.smtp"})
assert result["data"]["host"] == "updated.smtp"
@patch("cli_anything.rms.core.smtp.api_delete")
def test_delete_smtp_config(self, mock_delete):
from cli_anything.rms.core.smtp import delete_smtp_config
mock_delete.return_value = {"success": True}
result = delete_smtp_config("token", "1")
assert result["success"] is True
# ── Phase 3: _handle_response direct tests ────────────────────────────
class TestHandleResponse:
"""Direct tests for _handle_response edge cases."""
def test_success_json(self):
from cli_anything.rms.utils.rms_backend import _handle_response
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {"success": True, "data": [1, 2, 3]}
result = _handle_response(resp)
assert result == {"success": True, "data": [1, 2, 3]}
def test_success_non_json(self):
from cli_anything.rms.utils.rms_backend import _handle_response
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.side_effect = ValueError("No JSON")
resp.text = "plain text body"
result = _handle_response(resp)
assert result == {"success": True, "data": "plain text body"}
def test_error_with_messages(self):
import requests as _requests
from cli_anything.rms.utils.rms_backend import _handle_response
resp = MagicMock()
resp.status_code = 400
resp.raise_for_status.side_effect = _requests.exceptions.HTTPError("400")
resp.json.return_value = {"errors": [{"message": "Invalid field"}, {"message": "Missing param"}]}
with pytest.raises(RuntimeError, match="Invalid field"):
_handle_response(resp)
def test_error_plain_text(self):
import requests as _requests
from cli_anything.rms.utils.rms_backend import _handle_response
resp = MagicMock()
resp.status_code = 500
resp.raise_for_status.side_effect = _requests.exceptions.HTTPError("500")
resp.json.side_effect = ValueError("No JSON")
resp.text = "Internal Server Error"
with pytest.raises(RuntimeError, match="Internal Server Error"):
_handle_response(resp)
def test_rate_limit(self):
from cli_anything.rms.utils.rms_backend import _handle_response
resp = MagicMock()
resp.status_code = 429
resp.headers = {"Retry-After": "30"}
with pytest.raises(RuntimeError, match="Rate limit"):
_handle_response(resp)
# ── File upload tests ─────────────────────────────────────────────────
class TestFiles:
"""Tests for file operations."""
@patch("cli_anything.rms.core.files.requests.post")
def test_upload_file(self, mock_post):
from cli_anything.rms.core.files import upload_file
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"success": True, "data": {"id": 99}}
mock_post.return_value = mock_resp
with patch("builtins.open", mock_open(read_data=b"file-content")):
result = upload_file("test-token", "/tmp/test.bin")
assert mock_post.called
assert result["success"] is True
# ── Device ID zero tests ─────────────────────────────────────────────
class TestDeviceIdZero:
"""Verify device_id=0 is not falsy-skipped."""
@patch("cli_anything.rms.core.alerts.api_get")
def test_alerts_device_id_zero(self, mock_get):
from cli_anything.rms.core.alerts import list_alerts
mock_get.return_value = {"success": True, "data": []}
list_alerts("token", device_id=0)
params = mock_get.call_args.kwargs.get("params", {})
assert "device_id" in params
@patch("cli_anything.rms.core.configs.api_get")
def test_configs_device_id_zero(self, mock_get):
from cli_anything.rms.core.configs import list_configs
mock_get.return_value = {"success": True, "data": []}
list_configs("token", device_id=0)
params = mock_get.call_args.kwargs.get("params", {})
assert "device_id" in params
@patch("cli_anything.rms.core.logs.api_get")
def test_logs_device_id_zero(self, mock_get):
from cli_anything.rms.core.logs import list_logs
mock_get.return_value = {"success": True, "data": []}
list_logs("token", device_id=0)
params = mock_get.call_args.kwargs.get("params", {})
assert "device_id" in params
# ── CLI Runner tests ──────────────────────────────────────────────────
class TestCLI:
"""Click CliRunner tests for CLI commands."""
def setup_method(self):
from click.testing import CliRunner
self.runner = CliRunner()
def test_root_help(self):
from cli_anything.rms.rms_cli import cli
result = self.runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Teltonika RMS CLI" in result.output
assert "devices" in result.output
assert "alerts" in result.output
def test_devices_help(self):
from cli_anything.rms.rms_cli import cli
result = self.runner.invoke(cli, ["devices", "--help"])
assert result.exit_code == 0
assert "list" in result.output
assert "get" in result.output
def test_auth_help(self):
from cli_anything.rms.rms_cli import cli
result = self.runner.invoke(cli, ["auth", "--help"])
assert result.exit_code == 0
assert "test" in result.output
assert "status" in result.output
def test_devices_list_json_no_token(self):
from cli_anything.rms.rms_cli import cli
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("RMS_API_TOKEN", None)
result = self.runner.invoke(cli, ["--json", "devices", "list"])
assert "error" in result.output.lower() or result.exit_code != 0
@patch("cli_anything.rms.core.devices.api_get")
def test_devices_list_json(self, mock_api):
from cli_anything.rms.rms_cli import cli
mock_api.return_value = {"success": True, "data": [{"id": 1, "name": "Router1", "serial": "ABC"}]}
with patch.dict(os.environ, {"RMS_API_TOKEN": "test-token"}):
result = self.runner.invoke(cli, ["--json", "devices", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["name"] == "Router1"
@patch("cli_anything.rms.core.devices.api_get")
def test_devices_get(self, mock_api):
from cli_anything.rms.rms_cli import cli
mock_api.return_value = {"success": True, "data": {"id": 42, "name": "Gateway"}}
with patch.dict(os.environ, {"RMS_API_TOKEN": "test-token"}):
result = self.runner.invoke(cli, ["--json", "devices", "get", "42"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["data"]["id"] == 42
assert data["data"]["name"] == "Gateway"
@patch("cli_anything.rms.utils.rms_backend.api_get")
def test_auth_test(self, mock_api):
from cli_anything.rms.rms_cli import cli
mock_api.return_value = {"success": True, "data": []}
with patch.dict(os.environ, {"RMS_API_TOKEN": "test-token"}):
result = self.runner.invoke(cli, ["auth", "test"])
assert result.exit_code == 0
assert "passed" in result.output.lower() or "ok" in result.output.lower()
def test_passwords_update_no_password(self):
from cli_anything.rms.rms_cli import cli
with patch.dict(os.environ, {"RMS_API_TOKEN": "test-token"}):
result = self.runner.invoke(cli, ["passwords", "update", "123"])
assert result.exit_code != 0 or "error" in result.output.lower()
@@ -0,0 +1,102 @@
"""E2E tests for cli-anything-rms — requires valid RMS_API_TOKEN."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
# Skip all tests if no token is available
pytestmark = pytest.mark.skipif(
not os.environ.get("RMS_API_TOKEN"),
reason="RMS_API_TOKEN not set — skipping E2E tests",
)
class TestAPIConnectivity:
def test_api_connectivity(self):
from cli_anything.rms.utils.rms_backend import api_get, get_api_token
token = get_api_token()
result = api_get("/devices", params={"limit": 1}, token=token)
assert result["success"] is True
def test_auth_headers(self):
from cli_anything.rms.utils.rms_backend import _make_auth_headers
token = os.environ["RMS_API_TOKEN"]
headers = _make_auth_headers(token)
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
class TestDevicesE2E:
def test_list_devices(self):
from cli_anything.rms.core.devices import list_devices
from cli_anything.rms.utils.rms_backend import get_api_token
token = get_api_token()
result = list_devices(token, limit=5)
assert result["success"] is True
assert "data" in result
assert isinstance(result["data"], list)
def test_get_device(self):
from cli_anything.rms.core.devices import list_devices, get_device
from cli_anything.rms.utils.rms_backend import get_api_token
token = get_api_token()
devices = list_devices(token, limit=1)
if not devices["data"]:
pytest.skip("No devices available")
device_id = str(devices["data"][0]["id"])
result = get_device(token, device_id)
assert result["success"] is True
assert result["data"]["id"] == devices["data"][0]["id"]
class TestResourceListingE2E:
def test_list_companies(self):
from cli_anything.rms.core.companies import list_companies
from cli_anything.rms.utils.rms_backend import get_api_token
token = get_api_token()
result = list_companies(token, limit=5)
assert result["success"] is True
def test_list_users(self):
from cli_anything.rms.core.users import list_users
from cli_anything.rms.utils.rms_backend import get_api_token
token = get_api_token()
result = list_users(token, limit=5)
assert result["success"] is True
def test_list_tags(self):
from cli_anything.rms.core.tags import list_tags
from cli_anything.rms.utils.rms_backend import get_api_token
token = get_api_token()
result = list_tags(token, limit=5)
assert result["success"] is True
class TestCLIIntegrationE2E:
def _run_cli(self, *args):
cmd = [sys.executable, "-m", "cli_anything.rms", "--json", *args]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result
def test_cli_devices_list(self):
result = self._run_cli("devices", "list", "--limit", "1")
assert result.returncode == 0
data = json.loads(result.stdout)
assert "data" in data or "success" in data
def test_cli_auth_test(self):
result = self._run_cli("auth", "test")
assert result.returncode == 0
@@ -0,0 +1,524 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects skills/SKILL.md inside the package
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
"anygen": "\033[38;5;141m", # soft violet
"novita": "\033[38;5;81m", # vivid blue (for Novita AI)
"rms": "\033[38;5;27m", # Teltonika blue
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the package's skills/ directory if not provided.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
# Auto-detect skill path from package layout:
# cli_anything/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/skills/SKILL.md (target)
if skill_path is None:
from pathlib import Path
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
if _auto.is_file():
skill_path = str(_auto)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
# Skill path for agent discovery
skill_line = None
if self.skill_path:
skill_icon = self._c(_MAGENTA, "")
skill_label = self._c(_DARK_GRAY, " Skill:")
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
print(top)
print(_box_line(title))
print(_box_line(ver))
if skill_line:
print(_box_line(skill_line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,124 @@
"""RMS API backend — wraps the Teltonika RMS REST API."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Optional
try:
import requests
except ImportError:
print("requests library not found. Install with: pip3 install requests", file=sys.stderr)
sys.exit(1)
API_BASE = os.environ.get("RMS_API_BASE", "https://api.rms.teltonika-networks.com").rstrip("/")
ENV_API_TOKEN = "RMS_API_TOKEN"
CONFIG_DIR = Path.home() / ".config" / "cli-anything-rms"
CONFIG_FILE = CONFIG_DIR / "config.json"
def get_config_dir() -> Path:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
return CONFIG_DIR
def load_config() -> dict:
if not CONFIG_FILE.exists():
return {}
try:
with open(CONFIG_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def save_config(config: dict) -> None:
get_config_dir()
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
CONFIG_FILE.chmod(0o600)
def get_api_token(cli_token: Optional[str] = None) -> Optional[str]:
if cli_token:
return cli_token
env_token = os.environ.get(ENV_API_TOKEN)
if env_token:
return env_token
return load_config().get("api_token")
def _require_api_token(token: Optional[str]) -> str:
if not token:
raise RuntimeError(
"RMS API token not found. Provide one via:\n"
" 1. --token <token>\n"
f" 2. export {ENV_API_TOKEN}=<token>\n"
" 3. cli-anything-rms config set api_token <token>\n"
"Get a token at https://rms.teltonika-networks.com (Settings > Applications > Personal Access Tokens)\n"
"Note: 2FA must be enabled on your account."
)
return token
def _make_auth_headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _handle_response(resp) -> dict:
"""Handle API response, raising on errors."""
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After", "unknown")
raise RuntimeError(
f"Rate limit exceeded. Retry after {retry_after} seconds. "
"RMS allows 100,000 requests/month per application."
)
try:
resp.raise_for_status()
except requests.RequestException:
detail = ""
try:
err_data = resp.json()
errors = err_data.get("errors", [])
if errors:
detail = "; ".join(
e.get("message", str(e)) if isinstance(e, dict) else str(e)
for e in errors
)
except (json.JSONDecodeError, ValueError):
detail = resp.text[:500] if resp.text else ""
raise RuntimeError(f"RMS API error ({resp.status_code}): {detail}")
try:
return resp.json()
except (json.JSONDecodeError, ValueError):
return {"success": True, "data": resp.text}
def api_get(path: str, params: Optional[dict] = None, token: Optional[str] = None) -> dict:
token = _require_api_token(token or get_api_token())
headers = _make_auth_headers(token)
resp = requests.get(f"{API_BASE}{path}", params=params, headers=headers, timeout=30)
return _handle_response(resp)
def api_post(path: str, data: Optional[dict] = None, token: Optional[str] = None) -> dict:
token = _require_api_token(token or get_api_token())
headers = _make_auth_headers(token)
resp = requests.post(f"{API_BASE}{path}", json=data, headers=headers, timeout=30)
return _handle_response(resp)
def api_put(path: str, data: Optional[dict] = None, token: Optional[str] = None) -> dict:
token = _require_api_token(token or get_api_token())
headers = _make_auth_headers(token)
resp = requests.put(f"{API_BASE}{path}", json=data, headers=headers, timeout=30)
return _handle_response(resp)
def api_delete(path: str, token: Optional[str] = None) -> dict:
token = _require_api_token(token or get_api_token())
headers = _make_auth_headers(token)
resp = requests.delete(f"{API_BASE}{path}", headers=headers, timeout=30)
return _handle_response(resp)
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""setup.py for cli-anything-rms"""
from setuptools import setup, find_namespace_packages
with open("cli_anything/rms/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-rms",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI harness for Teltonika RMS — device management, monitoring, and more. Requires: RMS_API_TOKEN",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"requests>=2.28.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-rms=cli_anything.rms.rms_cli:main",
],
},
package_data={
"cli_anything.rms": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)