mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
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:
@@ -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__/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user