feat(vpn): Proton VPN Hermes skill — CLI wrapper tools
Builds the proton-vpn skill per ARCHITECTURE.md section 6 with 9 tools: Tools: - proton_vpn_connect — connect with fastest/random/country/city/P2P/Tor/SC selection - proton_vpn_disconnect — disconnect current session - proton_vpn_status — check connection status (parse CLI output) - proton_vpn_servers — list servers with filters (country, features) - proton_vpn_killswitch — enable/disable kill switch - proton_vpn_config — view/modify DNS, NetShield, protocol - proton_vpn_login — initiate browser OAuth login - proton_vpn_logout — clear credentials - proton_vpn_refresh — refresh server list and config Implementation: - Python subprocess wrapper around official protonvpn-cli v1.0+ - Human-readable CLI output parsed into structured JSON - Privilege check (protonvpn group) before privileged operations - 30-60s timeouts with graceful error handling - dispatch() entry point for Hermes tool routing Also includes: - scripts/install.sh — distro-aware dependency installer - references/commands.md — CLI quick reference - .gitignore — exclude __pycache__, env, debug files Deviations from ARCHITECTURE.md noted in docs: - CLI uses 'login' (browser OAuth), not 'init' - No --json output — parsed from tables - Install via Proton repos, not PyPI
This commit is contained in:
parent
8fdf219337
commit
da7dac8301
6 changed files with 1454 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug
|
||||
debug_*.py
|
||||
tmp/
|
||||
406
skills/proton-vpn/SKILL.md
Normal file
406
skills/proton-vpn/SKILL.md
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
---
|
||||
name: proton-vpn
|
||||
description: Proton VPN management — connect, disconnect, status, server list, kill switch, and config via the official protonvpn-cli
|
||||
version: 1.0.0
|
||||
category: infra
|
||||
platforms: [linux]
|
||||
dependencies:
|
||||
- protonvpn-cli (official Proton VPN Linux CLI)
|
||||
- systemd-resolved (for DNS leak protection)
|
||||
- gnome-keyring (for credential storage)
|
||||
- NetworkManager (for connection management)
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [vpn, proton, network, privacy, security]
|
||||
auth: independent — protonvpn-cli manages its own OAuth session
|
||||
limitations:
|
||||
- "Does NOT work on headless setups (requires gnome-keyring + NetworkManager)"
|
||||
- "Cannot run alongside Proton VPN GUI app"
|
||||
- "Split tunneling not yet available in CLI v1.0.1"
|
||||
---
|
||||
# Proton VPN Hermes Skill
|
||||
|
||||
A Hermes skill that wraps the official [Proton VPN Linux CLI](https://github.com/ProtonVPN/proton-vpn-cli) (`protonvpn-cli`) for agent use. Provides full VPN lifecycle management — connect, disconnect, status, server discovery, kill switch, and configuration.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The skill shells out to `protonvpn-cli` v1.0.0+. Install it via the official Proton repositories:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu (add Proton repo first)
|
||||
curl -1sLf 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | sudo apt-key add -
|
||||
sudo add-apt-repository 'deb https://repo.protonvpn.com/debian stable main'
|
||||
sudo apt update && sudo apt install protonvpn-cli
|
||||
|
||||
# Fedora
|
||||
sudo dnf install protonvpn-cli
|
||||
|
||||
# Arch (AUR)
|
||||
yay -S protonvpn-cli
|
||||
```
|
||||
|
||||
### Post-Install Setup
|
||||
|
||||
1. **Initial login** (requires a desktop session — browser OAuth):
|
||||
```bash
|
||||
protonvpn-cli login
|
||||
```
|
||||
First-time login opens a browser for OAuth. After that, the session persists.
|
||||
|
||||
2. **Verify installation**:
|
||||
```bash
|
||||
protonvpn-cli status
|
||||
protonvpn-cli servers | head -10
|
||||
```
|
||||
|
||||
3. **Ensure required services**:
|
||||
```bash
|
||||
systemctl status systemd-resolved # should be running
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### `proton_vpn_connect`
|
||||
|
||||
Connect to a Proton VPN server.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_connect",
|
||||
"description": "Connect to a Proton VPN server. Supports fastest, random, country, city, P2P, Tor, Secure Core, and free server selection.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string",
|
||||
"description": "Server name or ID (e.g., 'US-NY#1'). Omitting uses default selection strategy."
|
||||
},
|
||||
"selection": {
|
||||
"type": "string",
|
||||
"enum": ["fastest", "random", "country", "city", "p2p", "tor", "free", "secure-core"],
|
||||
"description": "Server selection strategy. 'fastest' (default): lowest latency. 'random': random server. 'country': fastest in specified country. 'p2p'/'tor'/'secure-core': feature-optimized."
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code (e.g., 'US', 'CH', 'JP'). Only used when selection='country'."
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name (e.g., 'New York'). Only used when selection='city'."
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": ["wireguard", "openvpn_udp", "openvpn_tcp"],
|
||||
"description": "VPN protocol. Default is WireGuard when available."
|
||||
},
|
||||
"persistent": {
|
||||
"type": "boolean",
|
||||
"description": "Auto-reconnect if the VPN connection drops."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:**
|
||||
- Fastest: `protonvpn-cli connect --fastest`
|
||||
- Random: `protonvpn-cli connect --random`
|
||||
- Country: `protonvpn-cli connect --country US --protocol wireguard`
|
||||
- P2P: `protonvpn-cli connect --p2p`
|
||||
- Server name: `protonvpn-cli connect US-NY#1`
|
||||
|
||||
**Returns:** JSON with connection status, server name, protocol, and IP.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_disconnect`
|
||||
|
||||
Disconnect the current VPN session.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_disconnect",
|
||||
"description": "Disconnect the current VPN session and restore normal network connectivity.",
|
||||
"parameters": {}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli disconnect`
|
||||
|
||||
**Returns:** JSON with confirmation message.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_status`
|
||||
|
||||
Get current VPN connection status.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_status",
|
||||
"description": "Check the current Proton VPN connection status — connected server, protocol, uptime, IP information.",
|
||||
"parameters": {}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli status`
|
||||
|
||||
**Returns:** JSON with connection state, server name, country, protocol, uptime, and local IP.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_servers`
|
||||
|
||||
List available Proton VPN servers with features and load.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_servers",
|
||||
"description": "List available Proton VPN servers. Shows country, city, current load percentage, and supported features (P2P, Tor, Secure Core).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Filter by two-letter country code (e.g., 'US')."
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["p2p", "tor", "secure-core", "free"] },
|
||||
"description": "Filter by required features."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["table", "json"],
|
||||
"description": "Output format. 'table': human-readable. 'json': machine-parseable."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli servers` (table output, parsed to JSON)
|
||||
|
||||
**Returns:** JSON array of servers with name, country, city, load, features.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_killswitch`
|
||||
|
||||
Enable or disable the VPN kill switch.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_killswitch",
|
||||
"description": "Enable or disable the VPN kill switch. When enabled, all internet traffic is blocked outside the VPN tunnel, preventing data leaks if the VPN drops.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["on", "off"],
|
||||
"description": "Kill switch state. 'on': enable (blocks non-VPN traffic). 'off': disable."
|
||||
}
|
||||
},
|
||||
"required": ["state"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli settings --killswitch on|off`
|
||||
|
||||
**Returns:** JSON with kill switch state and confirmation.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_config`
|
||||
|
||||
View or modify VPN configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_config",
|
||||
"description": "View current Proton VPN configuration or modify settings like DNS, NetShield, protocol preference, and VPN Accelerator.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["view", "set-dns", "set-netshield", "set-protocol"],
|
||||
"description": "'view' (default): show current config. 'set-dns': set custom DNS servers. 'set-netshield': toggle NetShield ad-blocker. 'set-protocol': set preferred protocol."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Value for the setting. For 'set-dns': comma-separated IPs (e.g., '1.1.1.1,1.0.0.1'). For 'set-netshield': 'on', 'off', or 'strict'. For 'set-protocol': 'wireguard', 'openvpn_udp', or 'openvpn_tcp'."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:**
|
||||
- View: `protonvpn-cli config --list`
|
||||
- Kill switch: `protonvpn-cli settings --killswitch on|off`
|
||||
- NetShield: `protonvpn-cli settings --netshield on|off|strict`
|
||||
- DNS: `protonvpn-cli settings --custom-dns <ip>`
|
||||
|
||||
**Returns:** JSON with configuration key-value pairs or confirmation message.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_login`
|
||||
|
||||
Authenticate with Proton VPN (browser OAuth).
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_login",
|
||||
"description": "Authenticate with Proton VPN. Requires a desktop session to open a browser for OAuth login. Proton VPN CLI manages credentials after initial login.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Proton account username/email."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli login [username]`
|
||||
|
||||
**Note:** First-time login requires a browser for OAuth. The agent should warn users that this won't work in headless environments.
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_logout`
|
||||
|
||||
Log out from Proton VPN.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_logout",
|
||||
"description": "Log out from Proton VPN. Clears stored credentials and disconnects if connected.",
|
||||
"parameters": {}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli logout`
|
||||
|
||||
---
|
||||
|
||||
### `proton_vpn_refresh`
|
||||
|
||||
Refresh server list and VPN configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "proton_vpn_refresh",
|
||||
"description": "Refresh the server list and VPN configuration from Proton's API. Useful after a network change or if servers appear outdated.",
|
||||
"parameters": {}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI mapping:** `protonvpn-cli refresh`
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
All tools shell out to `protonvpn-cli` via Python `subprocess`. The implementation module lives at `scripts/tools.py` in this skill directory.
|
||||
|
||||
### General Pattern
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
import json
|
||||
import shlex
|
||||
|
||||
def _run_vpn_command(args: list[str], timeout: int = 30) -> dict:
|
||||
"""Run a protonvpn-cli command and return structured output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["protonvpn-cli"] + args,
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {"error": result.stderr.strip(), "exit_code": result.returncode}
|
||||
return {
|
||||
"success": True,
|
||||
"output": result.stdout.strip(),
|
||||
"command": "protonvpn-cli " + " ".join(shlex.quote(a) for a in args)
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": f"Command timed out after {timeout}s"}
|
||||
except FileNotFoundError:
|
||||
return {"error": "protonvpn-cli not found. Install via Proton repos."}
|
||||
```
|
||||
|
||||
### Privilege Check
|
||||
|
||||
`protonvpn-cli` requires either root or membership in the `protonvpn` group for WireGuard interface creation. All tools should call a privilege check before executing:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def _check_vpn_privileges() -> dict | None:
|
||||
"""Check if the current user can use protonvpn-cli. Returns error dict or None."""
|
||||
if os.geteuid() == 0:
|
||||
return None # root can always use it
|
||||
import subprocess
|
||||
groups_result = subprocess.run(["groups"], capture_output=True, text=True)
|
||||
groups = groups_result.stdout.strip()
|
||||
if "protonvpn" not in groups:
|
||||
return {
|
||||
"error": (
|
||||
"User not in 'protonvpn' group. "
|
||||
"Run: sudo usermod -aG protonvpn $USER && logout && login"
|
||||
)
|
||||
}
|
||||
return None
|
||||
```
|
||||
|
||||
### Output Parsing
|
||||
|
||||
The CLI outputs human-readable tables (not JSON). The tool parses output into structured JSON:
|
||||
|
||||
- `protonvpn-cli status` → parsed into connection state, server, protocol, uptime, IP
|
||||
- `protonvpn-cli servers` → parsed into array of server objects
|
||||
- `protonvpn-cli config --list` → parsed into key-value pairs
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Required | Notes |
|
||||
|------------|----------|-------|
|
||||
| `protonvpn-cli` (>=1.0.0) | Yes | Official Proton VPN CLI, v1.0.1 latest (Apr 2026). [GitHub](https://github.com/ProtonVPN/proton-vpn-cli) |
|
||||
| `systemd-resolved` | Recommended | DNS leak protection via systemd-resolved |
|
||||
| `gnome-keyring` | Recommended | Credential storage for initial login |
|
||||
| `NetworkManager` | Required | VPN connection profiles managed via NetworkManager |
|
||||
| `WireGuard` | Recommended | Default protocol; faster than OpenVPN |
|
||||
| `OpenVPN` | Fallback | Alternative protocol when WireGuard unavailable |
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No headless support** — The CLI requires `gnome-keyring` and `NetworkManager`. It does not work on minimal server installs without a desktop environment.
|
||||
2. **Cannot coexist with Proton VPN GUI** — Running both simultaneously causes conflicts.
|
||||
3. **Split tunneling not yet available** — Feature is planned for a future release.
|
||||
4. **Requires internet for login** — OAuth flow needs a browser.
|
||||
5. **Kill switch uses iptables** — May conflict with other firewall rules. Verify carefully.
|
||||
|
||||
## Security
|
||||
|
||||
- Proton VPN CLI stores credentials in `gnome-keyring` (encrypted at rest)
|
||||
- Connection logs: `~/.cache/Proton/VPN/logs/`
|
||||
- Configuration: `~/.config/Proton/VPN/`
|
||||
- The skill passes connection results and server lists back to the agent; no credentials are exposed
|
||||
- Kill switch is system-level (iptables rules), not agent-level
|
||||
|
||||
## Related
|
||||
|
||||
- [Official Proton VPN CLI GitHub](https://github.com/ProtonVPN/proton-vpn-cli)
|
||||
- [Proton VPN Linux CLI support page](https://protonvpn.com/support/linux-cli)
|
||||
- [Hermes hardware keychain skill](https://github.com/NousResearch/hermes-agent) (example of subprocess skill pattern)
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — Hermes-Proton integration design (section 6)
|
||||
102
skills/proton-vpn/references/commands.md
Normal file
102
skills/proton-vpn/references/commands.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# protonvpn-cli Reference — Commands Quick Reference
|
||||
|
||||
> Official Proton VPN Linux CLI (v1.0.0+)
|
||||
> Source: https://github.com/ProtonVPN/proton-vpn-cli
|
||||
> Support: https://protonvpn.com/support/linux-cli
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
curl -fsSL 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/protonvpn.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/protonvpn.gpg] https://repo.protonvpn.com/debian stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/protonvpn.list
|
||||
sudo apt update && sudo apt install protonvpn-cli
|
||||
|
||||
# Fedora
|
||||
sudo dnf install protonvpn-cli
|
||||
|
||||
# Arch (AUR)
|
||||
yay -S protonvpn-cli
|
||||
```
|
||||
|
||||
## Login / Logout
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `protonvpn-cli login [username]` | Authenticate via browser OAuth |
|
||||
| `protonvpn-cli logout` | Clear credentials |
|
||||
|
||||
## Connect / Disconnect
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `protonvpn-cli connect [servername]` | Connect to specific server |
|
||||
| `protonvpn-cli connect --fastest` | Connect to lowest-latency server |
|
||||
| `protonvpn-cli connect --random` | Connect to random server |
|
||||
| `protonvpn-cli connect --country US` | Connect to fastest server in country |
|
||||
| `protonvpn-cli connect --city "New York"` | Connect to fastest server in city |
|
||||
| `protonvpn-cli connect --p2p` | Connect to fastest P2P server |
|
||||
| `protonvpn-cli connect --tor` | Connect to fastest Tor server |
|
||||
| `protonvpn-cli connect --free` | Connect to free server |
|
||||
| `protonvpn-cli connect --secure-core` | Connect to Secure Core server |
|
||||
| `protonvpn-cli connect --protocol wireguard` | Specify protocol |
|
||||
| `protonvpn-cli connect --persistent` | Auto-reconnect if VPN drops |
|
||||
| `protonvpn-cli disconnect` | Disconnect current session |
|
||||
|
||||
All connect options can be combined with `-p udp` or `-p tcp` to specify protocol
|
||||
(for OpenVPN mode).
|
||||
|
||||
## Status & Info
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `protonvpn-cli status` | Show connection status, server, uptime, IP |
|
||||
| `protonvpn-cli servers` | List all servers with features and load |
|
||||
|
||||
## Settings & Config
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `protonvpn-cli config --list` | Show current configuration |
|
||||
| `protonvpn-cli settings --killswitch on` | Enable kill switch |
|
||||
| `protonvpn-cli settings --killswitch off` | Disable kill switch |
|
||||
| `protonvpn-cli settings --netshield on` | Enable NetShield (block malware) |
|
||||
| `protonvpn-cli settings --netshield off` | Disable NetShield |
|
||||
| `protonvpn-cli settings --netshield strict` | Strict NetShield mode |
|
||||
| `protonvpn-cli settings --custom-dns 1.1.1.1` | Set custom DNS |
|
||||
| `protonvpn-cli settings --protocol wireguard` | Set preferred protocol |
|
||||
| `protonvpn-cli settings --dnsleak-protection on` | Enable DNS leak protection |
|
||||
| `protonvpn-cli settings --dnsleak-protection off` | Disable DNS leak protection |
|
||||
| `protonvpn-cli refresh` | Refresh server list and config |
|
||||
|
||||
## General
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `protonvpn-cli --help` | Show help |
|
||||
| `protonvpn-cli --version` | Show version |
|
||||
| `protonvpn-cli --debug` | Enable verbose debug logging |
|
||||
|
||||
## Logs & Files
|
||||
|
||||
- **Logs:** `~/.cache/Proton/VPN/logs/`
|
||||
- **Config:** `~/.config/Proton/VPN/`
|
||||
- **Settings DB:** `~/.config/Proton/VPN/settings.json`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3
|
||||
- systemd-resolved (for DNS leak protection)
|
||||
- gnome-keyring (for credential storage)
|
||||
- NetworkManager (for connection profiles)
|
||||
- WireGuard kernel module (preferred protocol) or OpenVPN
|
||||
|
||||
## Known Limitations (v1.0.1)
|
||||
|
||||
- No headless support (requires gnome-keyring + NetworkManager)
|
||||
- Cannot run alongside Proton VPN GUI app
|
||||
- Split tunneling is not yet available
|
||||
- First login requires a browser for OAuth
|
||||
- Kill switch uses iptables (may conflict with other firewall rules)
|
||||
186
skills/proton-vpn/scripts/install.sh
Executable file
186
skills/proton-vpn/scripts/install.sh
Executable file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# install.sh — Proton VPN CLI dependency installer for Hermes proton-vpn skill
|
||||
#
|
||||
# Installs the official Proton VPN Linux CLI on Debian/Ubuntu, Fedora, or Arch.
|
||||
# Run as root or with sudo.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./install.sh # auto-detect distro and install
|
||||
# sudo ./install.sh --check # only check if already installed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── colors ─────────────────────────────────────────────────────────────
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
# ── check if installed ─────────────────────────────────────────────────
|
||||
|
||||
check_installed() {
|
||||
if command -v protonvpn-cli &>/dev/null; then
|
||||
local version
|
||||
version=$(protonvpn-cli --version 2>/dev/null || echo "unknown")
|
||||
ok "protonvpn-cli is already installed (version: $version)"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── detect distro ──────────────────────────────────────────────────────
|
||||
|
||||
detect_distro() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
elif command -v lsb_release &>/dev/null; then
|
||||
lsb_release -is | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Debian/Ubuntu install ──────────────────────────────────────────────
|
||||
|
||||
install_debian() {
|
||||
info "Adding Proton VPN repository..."
|
||||
|
||||
# Install prerequisites
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl gnupg ca-certificates
|
||||
|
||||
# Add Proton VPN repo key and source
|
||||
curl -fsSL 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | \
|
||||
gpg --dearmor -o /usr/share/keyrings/protonvpn.gpg
|
||||
|
||||
cat > /etc/apt/sources.list.d/protonvpn.list <<EOF
|
||||
deb [signed-by=/usr/share/keyrings/protonvpn.gpg] https://repo.protonvpn.com/debian stable main
|
||||
EOF
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq protonvpn-cli
|
||||
|
||||
ok "protonvpn-cli installed via apt"
|
||||
}
|
||||
|
||||
# ── Fedora install ─────────────────────────────────────────────────────
|
||||
|
||||
install_fedora() {
|
||||
info "Installing protonvpn-cli via dnf..."
|
||||
dnf install -y protonvpn-cli
|
||||
ok "protonvpn-cli installed via dnf"
|
||||
}
|
||||
|
||||
# ── Arch install ───────────────────────────────────────────────────────
|
||||
|
||||
install_arch() {
|
||||
info "Installing protonvpn-cli from AUR (via yay)..."
|
||||
if ! command -v yay &>/dev/null; then
|
||||
err "yay (AUR helper) not found. Install yay first or use:"
|
||||
err " git clone https://aur.archlinux.org/protonvpn-cli.git"
|
||||
err " cd protonvpn-cli && makepkg -si"
|
||||
exit 1
|
||||
fi
|
||||
yay -S --noconfirm protonvpn-cli
|
||||
ok "protonvpn-cli installed via AUR"
|
||||
}
|
||||
|
||||
# ── post-install setup ────────────────────────────────────────────────
|
||||
|
||||
post_install() {
|
||||
info "Performing post-install setup..."
|
||||
|
||||
# Add user to protonvpn group (so CLI works without sudo)
|
||||
if [ -n "${SUDO_USER:-}" ]; then
|
||||
usermod -aG protonvpn "$SUDO_USER" 2>/dev/null || true
|
||||
warn "User '$SUDO_USER' added to 'protonvpn' group."
|
||||
warn "Log out and back in for group membership to take effect."
|
||||
fi
|
||||
|
||||
# Check systemd-resolved
|
||||
if systemctl is-active --quiet systemd-resolved 2>/dev/null; then
|
||||
ok "systemd-resolved is running"
|
||||
else
|
||||
warn "systemd-resolved is not running. DNS leak protection may not work."
|
||||
warn " Enable: sudo systemctl enable --now systemd-resolved"
|
||||
fi
|
||||
|
||||
# Check NetworkManager
|
||||
if systemctl is-active --quiet NetworkManager 2>/dev/null; then
|
||||
ok "NetworkManager is running"
|
||||
else
|
||||
warn "NetworkManager is not running. protonvpn-cli requires it."
|
||||
fi
|
||||
|
||||
# Check gnome-keyring
|
||||
if command -v gnome-keyring-daemon &>/dev/null; then
|
||||
ok "gnome-keyring found"
|
||||
else
|
||||
warn "gnome-keyring not found. Install it for credential storage."
|
||||
warn " sudo apt install gnome-keyring # Debian/Ubuntu"
|
||||
warn " sudo dnf install gnome-keyring # Fedora"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Setup complete! Run 'protonvpn-cli login' to authenticate."
|
||||
info "Then try: protonvpn-cli status"
|
||||
}
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────┐"
|
||||
echo " │ Proton VPN CLI — Hermes Skill Setup │"
|
||||
echo " └─────────────────────────────────────┘"
|
||||
echo ""
|
||||
|
||||
if [ "$1" = "--check" ]; then
|
||||
check_installed
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if check_installed; then
|
||||
post_install
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
err "This script must be run as root (sudo ./install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
distro=$(detect_distro)
|
||||
info "Detected distribution: $distro"
|
||||
|
||||
case "$distro" in
|
||||
debian|ubuntu|linuxmint|pop|elementary|kali|zorin|mx)
|
||||
install_debian
|
||||
;;
|
||||
fedora|rhel|centos)
|
||||
install_fedora
|
||||
;;
|
||||
arch|manjaro|endeavouros|artix)
|
||||
install_arch
|
||||
;;
|
||||
*)
|
||||
err "Unsupported distro: $distro"
|
||||
err "Manual installation: see https://protonvpn.com/support/linux-cli"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
post_install
|
||||
}
|
||||
|
||||
main "${1:-install}"
|
||||
711
skills/proton-vpn/scripts/tools.py
Normal file
711
skills/proton-vpn/scripts/tools.py
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Proton VPN Hermes skill — tool implementations.
|
||||
|
||||
All tools shell out to the official `protonvpn-cli` binary and parse
|
||||
human-readable CLI output into structured JSON for agent consumption.
|
||||
|
||||
Requires: protonvpn-cli >= 1.0.0 (installed via Proton repos)
|
||||
See SKILL.md for installation and setup instructions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_vpn(args: list[str], timeout: int = 30) -> dict:
|
||||
"""Run a `protonvpn-cli` command and return a structured result dict."""
|
||||
cmd = ["protonvpn-cli"] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
out = result.stdout.strip()
|
||||
err = result.stderr.strip()
|
||||
|
||||
if result.returncode != 0:
|
||||
# The CLI often prints errors to stdout as well
|
||||
return {
|
||||
"success": False,
|
||||
"exit_code": result.returncode,
|
||||
"error": err or out,
|
||||
"command": " ".join(shlex.quote(s) for s in cmd),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"output": out,
|
||||
"command": " ".join(shlex.quote(s) for s in cmd),
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Command timed out after {timeout}s: {' '.join(shlex.quote(s) for s in cmd)}",
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"protonvpn-cli not found. Install via Proton repos:\n"
|
||||
" https://protonvpn.com/support/linux-cli"
|
||||
),
|
||||
}
|
||||
except OSError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _check_vpn_privileges() -> dict | None:
|
||||
"""
|
||||
Check that the current user can use protonvpn-cli.
|
||||
|
||||
Returns an error dict if the user lacks privileges, or None if OK.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
return None # root can always run VPN commands
|
||||
|
||||
try:
|
||||
groups_out = subprocess.run(
|
||||
["groups"], capture_output=True, text=True, timeout=5
|
||||
).stdout.strip()
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": f"Cannot check groups: {exc}"}
|
||||
|
||||
if "protonvpn" not in groups_out:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"User is not in the 'protonvpn' group. "
|
||||
"Run: sudo usermod -aG protonvpn $USER && log out and back in."
|
||||
),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _privilege_guard() -> dict | None:
|
||||
"""Run privilege check and return a structured error if it fails."""
|
||||
err = _check_vpn_privileges()
|
||||
if err is not None:
|
||||
return err
|
||||
return None
|
||||
|
||||
|
||||
# ── output parsers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_status(raw: str) -> dict:
|
||||
"""
|
||||
Parse `protonvpn-cli status` output.
|
||||
|
||||
Typical output:
|
||||
Status: Connected
|
||||
Server: US-NY#123
|
||||
Country: United States
|
||||
City: New York
|
||||
Protocol: WireGuard
|
||||
Uptime: 47m 32s
|
||||
Local IP: 192.168.1.42
|
||||
Public IP: 10.2.3.4
|
||||
"""
|
||||
result = {"connected": False}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip().lower().replace(" ", "_")
|
||||
value = value.strip()
|
||||
|
||||
if key == "status":
|
||||
result["connected"] = value.lower() == "connected"
|
||||
result["status"] = value
|
||||
elif key == "server":
|
||||
result["server_name"] = value
|
||||
elif key == "country":
|
||||
result["country"] = value
|
||||
elif key == "city":
|
||||
result["city"] = value
|
||||
elif key == "protocol":
|
||||
result["protocol"] = value
|
||||
elif key == "uptime":
|
||||
result["uptime"] = value
|
||||
elif key == "local_ip":
|
||||
result["local_ip"] = value
|
||||
elif key == "public_ip":
|
||||
result["public_ip"] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_servers(raw: str) -> list[dict]:
|
||||
"""
|
||||
Parse `protonvpn-cli servers` output (table format).
|
||||
|
||||
The CLI outputs a table with columns like:
|
||||
Server Name Country City Load Features
|
||||
─────────────────────────────────────────────────────────────────
|
||||
US-NY#123 United States New York 47% P2P
|
||||
CH-ZR#45 Switzerland Zurich 12% Tor
|
||||
JP-TK#78 Japan Tokyo 89% Secure Core
|
||||
|
||||
Note: the exact format may vary by version. This parser is heuristic.
|
||||
"""
|
||||
lines = raw.splitlines()
|
||||
servers = []
|
||||
header_found = False
|
||||
|
||||
for line in lines:
|
||||
# Skip separators
|
||||
if re.match(r"^[─\-\s]+$", line):
|
||||
continue
|
||||
# Skip empty lines and banner
|
||||
if not line.strip() or line.strip().startswith("Server"):
|
||||
header_found = True
|
||||
continue
|
||||
|
||||
if not header_found:
|
||||
continue
|
||||
|
||||
# Split on 2+ spaces (table columns)
|
||||
parts = re.split(r"\s{2,}", line.strip())
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": parts[0].strip(),
|
||||
"country": parts[1].strip() if len(parts) > 1 else "",
|
||||
"city": parts[2].strip() if len(parts) > 2 else "",
|
||||
}
|
||||
|
||||
if len(parts) > 3:
|
||||
load_str = parts[3].strip().rstrip("%")
|
||||
try:
|
||||
entry["load_percent"] = int(load_str)
|
||||
except ValueError:
|
||||
entry["load_percent"] = None
|
||||
else:
|
||||
entry["load_percent"] = None
|
||||
|
||||
if len(parts) > 4:
|
||||
features = [f.strip().lower() for f in parts[4].split(",") if f.strip()]
|
||||
entry["features"] = features
|
||||
else:
|
||||
entry["features"] = []
|
||||
|
||||
servers.append(entry)
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def _parse_config(raw: str) -> dict:
|
||||
"""
|
||||
Parse `protonvpn-cli config --list` or `protonvpn-cli settings` output.
|
||||
|
||||
Lines like:
|
||||
Kill switch: enabled
|
||||
NetShield: disabled
|
||||
DNS leak protection: enabled
|
||||
Protocol preference: wireguard
|
||||
"""
|
||||
config = {}
|
||||
for line in raw.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
config[key.strip().lower().replace(" ", "_")] = value.strip()
|
||||
return config
|
||||
|
||||
|
||||
# ── tool handlers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def proton_vpn_login(args: dict) -> dict:
|
||||
"""
|
||||
Authenticate with Proton VPN.
|
||||
|
||||
NOTE: This opens a browser for OAuth and requires a desktop session.
|
||||
It will not work in headless environments.
|
||||
"""
|
||||
username = args.get("username", "")
|
||||
cmd_args = ["login"]
|
||||
if username:
|
||||
cmd_args.append(username)
|
||||
|
||||
result = _run_vpn(cmd_args, timeout=60) # login can be slow (browser wait)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Login initiated. Follow the browser prompt to authenticate.",
|
||||
"output": result["output"],
|
||||
"note": (
|
||||
"Proton VPN CLI uses browser-based OAuth. "
|
||||
"This requires a desktop session with a browser available."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_logout(args: dict) -> dict:
|
||||
"""Log out from Proton VPN, clearing stored credentials."""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
result = _run_vpn(["logout"])
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logged out of Proton VPN. Credentials cleared.",
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_connect(args: dict) -> dict:
|
||||
"""
|
||||
Connect to a Proton VPN server.
|
||||
|
||||
Supports: fastest (default), random, country, city, P2P, Tor, Secure Core.
|
||||
"""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
cmd_args = ["connect"]
|
||||
selection = args.get("selection", "fastest")
|
||||
|
||||
# Server name takes precedence
|
||||
server_name = args.get("server")
|
||||
if server_name:
|
||||
cmd_args.append(server_name)
|
||||
elif selection == "fastest":
|
||||
cmd_args.append("--fastest")
|
||||
elif selection == "random":
|
||||
cmd_args.append("--random")
|
||||
elif selection == "country":
|
||||
country = args.get("country")
|
||||
if not country:
|
||||
return {"success": False, "error": "country parameter required when selection='country'"}
|
||||
cmd_args.append(f"--country={country}")
|
||||
elif selection == "city":
|
||||
city = args.get("city")
|
||||
if not city:
|
||||
return {"success": False, "error": "city parameter required when selection='city'"}
|
||||
cmd_args.append(f"--city={city}")
|
||||
elif selection == "p2p":
|
||||
cmd_args.append("--p2p")
|
||||
elif selection == "tor":
|
||||
cmd_args.append("--tor")
|
||||
elif selection == "free":
|
||||
cmd_args.append("--free")
|
||||
elif selection == "secure-core":
|
||||
cmd_args.append("--secure-core")
|
||||
|
||||
# Optional protocol
|
||||
protocol = args.get("protocol")
|
||||
if protocol:
|
||||
cmd_args.extend(["--protocol", protocol])
|
||||
|
||||
# Optional persistent flag
|
||||
if args.get("persistent"):
|
||||
cmd_args.append("--persistent")
|
||||
|
||||
result = _run_vpn(cmd_args, timeout=45)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action": "connect",
|
||||
"selection": selection,
|
||||
"message": result["output"],
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_disconnect(args: dict) -> dict:
|
||||
"""Disconnect the current VPN session."""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
result = _run_vpn(["disconnect"])
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action": "disconnect",
|
||||
"message": "VPN disconnected.",
|
||||
"output": result["output"],
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_status(args: dict) -> dict:
|
||||
"""Get current VPN connection status."""
|
||||
result = _run_vpn(["status"])
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
parsed = _parse_status(result["output"])
|
||||
return {
|
||||
"success": True,
|
||||
**parsed,
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_servers(args: dict) -> dict:
|
||||
"""List available Proton VPN servers."""
|
||||
result = _run_vpn(["servers"], timeout=60)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
servers = _parse_servers(result["output"])
|
||||
|
||||
# Apply filters
|
||||
country_filter = args.get("country")
|
||||
if country_filter:
|
||||
country_upper = country_filter.upper()
|
||||
servers = [s for s in servers if s.get("country", "").upper() == country_upper]
|
||||
|
||||
features_filter = args.get("features")
|
||||
if features_filter:
|
||||
if isinstance(features_filter, str):
|
||||
features_filter = [features_filter]
|
||||
features_lower = [f.lower() for f in features_filter]
|
||||
servers = [
|
||||
s
|
||||
for s in servers
|
||||
if any(f in [sf.lower() for sf in s.get("features", [])] for f in features_lower)
|
||||
]
|
||||
|
||||
fmt = args.get("format", "json")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": len(servers),
|
||||
"servers": servers,
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_killswitch(args: dict) -> dict:
|
||||
"""Enable or disable the VPN kill switch."""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
state = args.get("state", "").lower()
|
||||
if state not in ("on", "off"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "state must be 'on' or 'off'",
|
||||
}
|
||||
|
||||
result = _run_vpn(["settings", f"--killswitch={state}"])
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"killswitch": state,
|
||||
"message": f"Kill switch {state == 'on' and 'enabled' or 'disabled'}.",
|
||||
"output": result["output"],
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_config(args: dict) -> dict:
|
||||
"""View or modify VPN configuration."""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
action = args.get("action", "view")
|
||||
|
||||
if action == "view":
|
||||
result = _run_vpn(["config", "--list"])
|
||||
if not result["success"]:
|
||||
return result
|
||||
parsed = _parse_config(result["output"])
|
||||
return {
|
||||
"success": True,
|
||||
"action": "view",
|
||||
"config": parsed,
|
||||
}
|
||||
|
||||
value = args.get("value", "")
|
||||
|
||||
if action == "set-dns":
|
||||
result = _run_vpn(["settings", f"--custom-dns={value}"])
|
||||
elif action == "set-netshield":
|
||||
if value not in ("on", "off", "strict"):
|
||||
return {"success": False, "error": "NetShield value must be 'on', 'off', or 'strict'"}
|
||||
result = _run_vpn(["settings", f"--netshield={value}"])
|
||||
elif action == "set-protocol":
|
||||
if value not in ("wireguard", "openvpn_udp", "openvpn_tcp"):
|
||||
return {"success": False, "error": "Protocol must be 'wireguard', 'openvpn_udp', or 'openvpn_tcp'"}
|
||||
result = _run_vpn(["settings", f"--protocol={value}"])
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown action: {action}"}
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"value": value,
|
||||
"message": f"Configuration updated: {action} = {value}",
|
||||
}
|
||||
|
||||
|
||||
def proton_vpn_refresh(args: dict) -> dict:
|
||||
"""Refresh server list and VPN config from Proton's API."""
|
||||
priv = _privilege_guard()
|
||||
if priv:
|
||||
return priv
|
||||
|
||||
result = _run_vpn(["refresh"], timeout=60)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Server list and configuration refreshed.",
|
||||
"output": result["output"],
|
||||
}
|
||||
|
||||
|
||||
# ── tool registry ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
TOOLS: dict[str, dict[str, Any]] = {
|
||||
"proton_vpn_login": {
|
||||
"handler": proton_vpn_login,
|
||||
"schema": {
|
||||
"name": "proton_vpn_login",
|
||||
"description": "Authenticate with Proton VPN. Requires a desktop session for browser OAuth.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Proton account username/email (optional)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proton_vpn_logout": {
|
||||
"handler": proton_vpn_logout,
|
||||
"schema": {
|
||||
"name": "proton_vpn_logout",
|
||||
"description": "Log out from Proton VPN. Clears stored credentials and disconnects.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
"proton_vpn_connect": {
|
||||
"handler": proton_vpn_connect,
|
||||
"schema": {
|
||||
"name": "proton_vpn_connect",
|
||||
"description": "Connect to a Proton VPN server. Supports fastest, random, country, city, P2P, Tor, Secure Core, and free server selection.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string",
|
||||
"description": "Server name or ID (e.g., 'US-NY#1')."
|
||||
},
|
||||
"selection": {
|
||||
"type": "string",
|
||||
"enum": ["fastest", "random", "country", "city", "p2p", "tor", "free", "secure-core"],
|
||||
"description": "Server selection strategy. Default: fastest."
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code (e.g., 'US'). Used with selection='country'."
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name (e.g., 'New York'). Used with selection='city'."
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": ["wireguard", "openvpn_udp", "openvpn_tcp"],
|
||||
"description": "VPN protocol."
|
||||
},
|
||||
"persistent": {
|
||||
"type": "boolean",
|
||||
"description": "Auto-reconnect on disconnect."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proton_vpn_disconnect": {
|
||||
"handler": proton_vpn_disconnect,
|
||||
"schema": {
|
||||
"name": "proton_vpn_disconnect",
|
||||
"description": "Disconnect the current VPN session and restore normal network connectivity.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
"proton_vpn_status": {
|
||||
"handler": proton_vpn_status,
|
||||
"schema": {
|
||||
"name": "proton_vpn_status",
|
||||
"description": "Check current VPN connection status — server, protocol, uptime, IP information.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
"proton_vpn_servers": {
|
||||
"handler": proton_vpn_servers,
|
||||
"schema": {
|
||||
"name": "proton_vpn_servers",
|
||||
"description": "List available Proton VPN servers with country, city, load, and features.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Filter by two-letter country code (e.g., 'US')."
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["p2p", "tor", "secure-core", "free"]
|
||||
},
|
||||
"description": "Filter by required features."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["table", "json"],
|
||||
"description": "Output format. 'json' (default) returns structured data."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proton_vpn_killswitch": {
|
||||
"handler": proton_vpn_killswitch,
|
||||
"schema": {
|
||||
"name": "proton_vpn_killswitch",
|
||||
"description": "Enable or disable the VPN kill switch. When enabled, all internet traffic is blocked outside the VPN tunnel.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["on", "off"],
|
||||
"description": "Kill switch state."
|
||||
}
|
||||
},
|
||||
"required": ["state"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"proton_vpn_config": {
|
||||
"handler": proton_vpn_config,
|
||||
"schema": {
|
||||
"name": "proton_vpn_config",
|
||||
"description": "View current Proton VPN configuration or modify DNS, NetShield, and protocol settings.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["view", "set-dns", "set-netshield", "set-protocol"],
|
||||
"description": "'view' (default): show config. 'set-*': modify setting."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Setting value. For DNS: comma-separated IPs. For NetShield: 'on'/'off'/'strict'. For protocol: 'wireguard'/'openvpn_udp'/'openvpn_tcp'."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proton_vpn_refresh": {
|
||||
"handler": proton_vpn_refresh,
|
||||
"schema": {
|
||||
"name": "proton_vpn_refresh",
|
||||
"description": "Refresh server list and VPN configuration from Proton's API.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def dispatch(tool_name: str, args: dict | None = None) -> str:
|
||||
"""
|
||||
Dispatch a tool by name with the given arguments.
|
||||
|
||||
Returns a JSON string (Hermes tools return strings, not dicts).
|
||||
"""
|
||||
if args is None:
|
||||
args = {}
|
||||
|
||||
tool = TOOLS.get(tool_name)
|
||||
if tool is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Unknown tool: {tool_name}. Available: {', '.join(sorted(TOOLS.keys()))}",
|
||||
})
|
||||
|
||||
try:
|
||||
result = tool["handler"](args)
|
||||
return json.dumps(result, indent=2, default=str)
|
||||
except Exception as exc:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"{tool_name} raised: {exc}",
|
||||
}, indent=2)
|
||||
|
||||
|
||||
# ── CLI entry point (for testing) ──────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for testing tools from the command line."""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <tool_name> [json_args]")
|
||||
print(f"Tools: {', '.join(sorted(TOOLS.keys()))}")
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = sys.argv[1]
|
||||
args = {}
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
args = json.loads(sys.argv[2])
|
||||
except json.JSONDecodeError as exc:
|
||||
print(json.dumps({"error": f"Invalid JSON args: {exc}"}))
|
||||
sys.exit(1)
|
||||
|
||||
result = dispatch(tool_name, args)
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
skills/proton-vpn/scripts/verify.py
Normal file
23
skills/proton-vpn/scripts/verify.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify proton-vpn skill module loads and all tools are registered."""
|
||||
import sys
|
||||
sys.path.insert(0, 'skills/proton-vpn/scripts')
|
||||
|
||||
import tools
|
||||
import json
|
||||
|
||||
# Verify all tools are in registry
|
||||
print("=== Tool Registry ===")
|
||||
for name, info in sorted(tools.TOOLS.items()):
|
||||
params = info["schema"].get("parameters", {}).get("properties", {})
|
||||
print(f" ✓ {name} — {len(params)} params")
|
||||
|
||||
print(f"\nTotal tools: {len(tools.TOOLS)}")
|
||||
|
||||
# Test dispatch for error cases (no CLI installed = expected)
|
||||
result = json.loads(tools.dispatch('proton_vpn_status'))
|
||||
print(f"\nStatus dispatch (expected error — no CLI): success={result.get('success')}")
|
||||
print(f" error={result.get('error', '')[:120]}")
|
||||
|
||||
result2 = json.loads(tools.dispatch('nonexistent'))
|
||||
print(f"\nNonexistent tool: error={result2.get('error', '')[:80]}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue