Hermes skill wrapping the official pass-cli (Rust binary) for agent use.
Follows the architecture from t_d1c7437e (ARCHITECTURE.md section 4).
Tools:
- Auth: proton_pass_login, proton_pass_logout, proton_pass_auth_status, proton_pass_test
- Vaults: proton_pass_vaults, proton_pass_vault_create, proton_pass_vault_delete
- Items: proton_pass_list, proton_pass_get, proton_pass_search, proton_pass_create,
proton_pass_edit, proton_pass_delete, proton_pass_totp, proton_pass_share_item
- Injection: proton_pass_inject (wraps pass-cli run)
- SSH: proton_pass_ssh_load, proton_pass_ssh_agent_start,
proton_pass_ssh_daemon_{start,status,stop}
- Utility: proton_pass_generate_password
Signed-off-by: Murdock A-Team
1427 lines
48 KiB
Python
1427 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Proton Pass Hermes skill — tool implementations.
|
|
|
|
All tools shell out to the official `pass-cli` Rust binary and use
|
|
`--output json` (or `--output=json`) for machine-readable output.
|
|
|
|
Requires: pass-cli >= 2.1.0 (official Proton Pass CLI)
|
|
See SKILL.md for installation and setup instructions.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _get_pass_binary() -> str:
|
|
"""Return the pass-cli binary path, respecting PROTON_PASS_CLI_PATH."""
|
|
return os.environ.get("PROTON_PASS_CLI_PATH", "pass-cli")
|
|
|
|
|
|
def _run_pass(args: list[str], timeout: int = 15) -> dict:
|
|
"""
|
|
Run a `pass-cli` command and return a structured result dict.
|
|
|
|
Uses --output=json where applicable. Returns parsed JSON output
|
|
when the CLI emits JSON, or a structured wrapper for non-JSON output.
|
|
"""
|
|
binary = _get_pass_binary()
|
|
cmd = [binary] + 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:
|
|
# pass-cli often prints errors to stdout too
|
|
error_msg = err or out or f"Exit code {result.returncode}"
|
|
return {
|
|
"success": False,
|
|
"exit_code": result.returncode,
|
|
"error": error_msg,
|
|
"command": " ".join(shlex.quote(s) for s in cmd),
|
|
}
|
|
|
|
# Try to parse JSON output
|
|
if out:
|
|
try:
|
|
parsed = json.loads(out)
|
|
return {
|
|
"success": True,
|
|
"data": parsed,
|
|
"command": " ".join(shlex.quote(s) for s in cmd),
|
|
}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
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": (
|
|
f"{binary} not found. Install via:\n"
|
|
" curl -fsSL https://proton.me/download/pass-cli/install.sh | bash\n"
|
|
"Or set PROTON_PASS_CLI_PATH env var to the binary path."
|
|
),
|
|
}
|
|
except OSError as exc:
|
|
return {
|
|
"success": False,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def _ensure_json_args(args: list[str]) -> list[str]:
|
|
"""Append --output=json if not already present and no --output is there."""
|
|
if any(a.startswith("--output") for a in args):
|
|
return args
|
|
return args + ["--output", "json"]
|
|
|
|
|
|
# ── auth handlers ─────────────────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_login(args: dict) -> dict:
|
|
"""
|
|
Authenticate with Proton Pass.
|
|
|
|
Supports: interactive login (browser), interactive with credentials,
|
|
and personal access token login.
|
|
"""
|
|
mode = args.get("mode", "interactive")
|
|
username = args.get("username", "")
|
|
|
|
if mode == "pat":
|
|
token = args.get("token", "")
|
|
if not token:
|
|
return {"success": False, "error": "token required for PAT login mode"}
|
|
cmd = _run_pass(["login", "--personal-access-token", token], timeout=30)
|
|
elif mode == "interactive":
|
|
cmd_args = ["login", "--interactive"]
|
|
if username:
|
|
cmd_args.append(username)
|
|
cmd = _run_pass(cmd_args, timeout=60)
|
|
elif mode == "web":
|
|
cmd = _run_pass(["login"], timeout=60)
|
|
else:
|
|
return {"success": False, "error": f"Unknown login mode: {mode}"}
|
|
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Authentication successful. Session established.",
|
|
"output": cmd.get("output", cmd.get("data", "")),
|
|
"note": (
|
|
"Interactive and web login modes open a browser for authentication. "
|
|
"PAT mode is recommended for automation (CI/CD, headless)."
|
|
),
|
|
}
|
|
|
|
|
|
def proton_pass_logout(args: dict) -> dict:
|
|
"""Log out from Proton Pass, ending the current session."""
|
|
cmd = _run_pass(["logout"])
|
|
if not cmd["success"]:
|
|
return cmd
|
|
return {
|
|
"success": True,
|
|
"message": "Logged out. Session cleared.",
|
|
}
|
|
|
|
|
|
def proton_pass_auth_status(args: dict) -> dict:
|
|
"""Check current Proton Pass authentication status."""
|
|
cmd = _run_pass(["info"])
|
|
if not cmd["success"]:
|
|
# Not logged in is a common case — return structured, not error
|
|
return {
|
|
"success": True,
|
|
"authenticated": False,
|
|
"message": "Not authenticated. Run proton_pass_login first.",
|
|
}
|
|
|
|
output = cmd.get("output", "") or json.dumps(cmd.get("data", {}))
|
|
# Parse the info output to extract user details
|
|
return {
|
|
"success": True,
|
|
"authenticated": True,
|
|
"info": cmd.get("data") or {"output": output},
|
|
"raw_output": output,
|
|
}
|
|
|
|
|
|
def proton_pass_test(args: dict) -> dict:
|
|
"""Test the pass-cli connection and authentication."""
|
|
cmd = _run_pass(["test"])
|
|
if not cmd["success"]:
|
|
return {
|
|
"success": True,
|
|
"passed": False,
|
|
"message": cmd.get("error", "Connection test failed"),
|
|
}
|
|
return {
|
|
"success": True,
|
|
"passed": True,
|
|
"message": cmd.get("output", cmd.get("data", "Connection OK")),
|
|
}
|
|
|
|
|
|
# ── vault handlers ────────────────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_vaults(args: dict) -> dict:
|
|
"""List all accessible Proton Pass vaults."""
|
|
cmd = _run_pass(_ensure_json_args(["vault", "list"]), timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
vaults = cmd.get("data", cmd.get("output", []))
|
|
if isinstance(vaults, str):
|
|
# Non-JSON output fallback
|
|
return {"success": True, "vaults": [{"name": vaults}]}
|
|
|
|
return {
|
|
"success": True,
|
|
"vaults": vaults if isinstance(vaults, list) else [vaults],
|
|
"total": len(vaults) if isinstance(vaults, list) else 1,
|
|
}
|
|
|
|
|
|
def proton_pass_vault_create(args: dict) -> dict:
|
|
"""Create a new Proton Pass vault."""
|
|
name = args.get("name", "")
|
|
if not name:
|
|
return {"success": False, "error": "name is required"}
|
|
|
|
cmd = _run_pass(["vault", "create", "--name", name], timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Vault '{name}' created.",
|
|
"output": cmd.get("output", ""),
|
|
}
|
|
|
|
|
|
def proton_pass_vault_delete(args: dict) -> dict:
|
|
"""Delete a vault and all its contents."""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
|
|
if not share_id and not vault_name:
|
|
return {"success": False, "error": "share_id or vault_name required"}
|
|
|
|
cmd_args = ["vault", "delete"]
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
else:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Vault deleted (permanent).",
|
|
"output": cmd.get("output", ""),
|
|
}
|
|
|
|
|
|
# ── item handlers ─────────────────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_list(args: dict) -> dict:
|
|
"""
|
|
List items in a vault.
|
|
|
|
Optionally filter by vault name, share ID, or output format.
|
|
"""
|
|
vault_name = args.get("vault_name", "")
|
|
share_id = args.get("share_id", "")
|
|
|
|
cmd_args = ["item", "list", "--output", "json"]
|
|
if vault_name and not share_id:
|
|
cmd_args.append(vault_name)
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
items = cmd.get("data", cmd.get("output", []))
|
|
if isinstance(items, str):
|
|
return {"success": True, "items": [{"name": items}]}
|
|
|
|
return {
|
|
"success": True,
|
|
"items": items if isinstance(items, list) else [items],
|
|
"total": len(items) if isinstance(items, list) else 1,
|
|
}
|
|
|
|
|
|
def proton_pass_get(args: dict) -> dict:
|
|
"""
|
|
Get a specific secret/item from a vault.
|
|
|
|
Requires either (share_id + item_id) or (vault_name + item_title),
|
|
or a pass:// URI.
|
|
"""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
item_id = args.get("item_id", "")
|
|
item_title = args.get("item_title", "")
|
|
uri = args.get("uri", "")
|
|
field = args.get("field", "")
|
|
|
|
cmd_args = ["item", "view", "--output", "json"]
|
|
|
|
if uri:
|
|
cmd_args.append(uri)
|
|
else:
|
|
if not ((share_id or vault_name) and (item_id or item_title)):
|
|
return {
|
|
"success": False,
|
|
"error": (
|
|
"Provide (share_id & item_id), (vault_name & item_title), "
|
|
"or a pass:// URI"
|
|
),
|
|
}
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if item_id:
|
|
cmd_args.extend(["--item-id", item_id])
|
|
if item_title:
|
|
cmd_args.extend(["--item-title", item_title])
|
|
if field:
|
|
cmd_args.extend(["--field", field])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
result = {
|
|
"success": True,
|
|
"item": cmd.get("data") or {"output": cmd.get("output", "")},
|
|
}
|
|
if field:
|
|
result["field"] = field
|
|
|
|
return result
|
|
|
|
|
|
def proton_pass_search(args: dict) -> dict:
|
|
"""
|
|
Search items across vaults by title or field value.
|
|
|
|
Lists items from specified vault or all vaults, then filters
|
|
by the search query client-side.
|
|
"""
|
|
query = args.get("query", "")
|
|
vault_name = args.get("vault_name", "")
|
|
share_id = args.get("share_id", "")
|
|
|
|
if not query:
|
|
return {"success": False, "error": "query is required"}
|
|
|
|
# Get items from vault (or all vaults)
|
|
if vault_name or share_id:
|
|
list_args = {"vault_name": vault_name, "share_id": share_id}
|
|
list_result = proton_pass_list(list_args)
|
|
else:
|
|
# List all vaults first, then collect items from each
|
|
vaults_result = proton_pass_vaults({})
|
|
if not vaults_result.get("success"):
|
|
return vaults_result
|
|
|
|
vaults = vaults_result.get("vaults", [])
|
|
all_items = []
|
|
for vault in vaults:
|
|
sid = vault.get("share_id", "") or vault.get("id", "")
|
|
if sid:
|
|
vr = proton_pass_list({"share_id": sid})
|
|
if vr.get("success"):
|
|
items = vr.get("items", [])
|
|
for item in items:
|
|
item["_vault"] = vault.get("name", sid)
|
|
all_items.extend(items)
|
|
|
|
list_result = {"success": True, "items": all_items}
|
|
|
|
if not list_result.get("success"):
|
|
return list_result
|
|
|
|
items = list_result.get("items", [])
|
|
query_lower = query.lower()
|
|
|
|
# Client-side filtering by title, name, or any string field
|
|
matches = []
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
for key in ("title", "name", "item_name", "id", "item_id"):
|
|
val = item.get(key, "")
|
|
if isinstance(val, str) and query_lower in val.lower():
|
|
matches.append(item)
|
|
break
|
|
elif isinstance(item, str) and query_lower in item.lower():
|
|
matches.append(item)
|
|
|
|
return {
|
|
"success": True,
|
|
"query": query,
|
|
"items": matches,
|
|
"total": len(matches),
|
|
}
|
|
|
|
|
|
def proton_pass_create(args: dict) -> dict:
|
|
"""
|
|
Create a new item in a vault.
|
|
|
|
Supports 'login' and 'note' item types by default.
|
|
"""
|
|
item_type = args.get("type", "login")
|
|
vault_name = args.get("vault_name", "")
|
|
share_id = args.get("share_id", "")
|
|
title = args.get("title", "")
|
|
username = args.get("username", "")
|
|
password = args.get("password", "")
|
|
generate_password = args.get("generate_password", False)
|
|
url = args.get("url", "")
|
|
note = args.get("note", "")
|
|
|
|
if not title:
|
|
return {"success": False, "error": "title is required"}
|
|
|
|
cmd_args = ["item", "create"]
|
|
|
|
if item_type == "login":
|
|
cmd_args.append("login")
|
|
cmd_args.extend(["--title", title])
|
|
if username:
|
|
cmd_args.extend(["--username", username])
|
|
if password:
|
|
cmd_args.extend(["--password", password])
|
|
if generate_password:
|
|
gen_settings = args.get("password_settings", "20,uppercase,symbols")
|
|
cmd_args.append(f"--generate-password={gen_settings}")
|
|
if url:
|
|
cmd_args.extend(["--url", url])
|
|
if note:
|
|
cmd_args.extend(["--field", f"note={note}"])
|
|
elif item_type == "note":
|
|
cmd_args.append("note")
|
|
cmd_args.extend(["--title", title])
|
|
if note:
|
|
cmd_args.extend(["--note", note])
|
|
else:
|
|
return {"success": False, "error": f"Unsupported item type: {item_type}"}
|
|
|
|
# Target vault
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Item '{title}' created.",
|
|
"output": cmd.get("output", "") or cmd.get("data", ""),
|
|
}
|
|
|
|
|
|
def proton_pass_edit(args: dict) -> dict:
|
|
"""
|
|
Update fields on an existing item.
|
|
|
|
Requires identifiers (share_id/vault_name + item_id/item_title)
|
|
and one or more fields to update.
|
|
"""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
item_id = args.get("item_id", "")
|
|
item_title = args.get("item_title", "")
|
|
fields = args.get("fields", {})
|
|
|
|
if not ((share_id or vault_name) and (item_id or item_title)):
|
|
return {
|
|
"success": False,
|
|
"error": "Provide (share_id & item_id) or (vault_name & item_title)",
|
|
}
|
|
if not fields:
|
|
return {"success": False, "error": "fields dict is required"}
|
|
|
|
cmd_args = ["item", "update"]
|
|
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if item_id:
|
|
cmd_args.extend(["--item-id", item_id])
|
|
if item_title:
|
|
cmd_args.extend(["--item-title", item_title])
|
|
|
|
for key, value in fields.items():
|
|
cmd_args.extend(["--field", f"{key}={value}"])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Item updated ({len(fields)} fields).",
|
|
"updated_fields": list(fields.keys()),
|
|
"output": cmd.get("output", ""),
|
|
}
|
|
|
|
|
|
def proton_pass_delete(args: dict) -> dict:
|
|
"""Delete an item from a vault."""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
item_id = args.get("item_id", "")
|
|
item_title = args.get("item_title", "")
|
|
|
|
if not ((share_id or vault_name) and (item_id or item_title)):
|
|
return {
|
|
"success": False,
|
|
"error": "Provide (share_id & item_id) or (vault_name & item_title)",
|
|
}
|
|
|
|
cmd_args = ["item", "delete"]
|
|
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if item_id:
|
|
cmd_args.extend(["--item-id", item_id])
|
|
if item_title:
|
|
cmd_args.extend(["--item-title", item_title])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Item deleted.",
|
|
"output": cmd.get("output", ""),
|
|
}
|
|
|
|
|
|
# ── secret injection handlers ─────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_totp(args: dict) -> dict:
|
|
"""Get the current TOTP code for an item."""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
item_id = args.get("item_id", "")
|
|
item_title = args.get("item_title", "")
|
|
uri = args.get("uri", "")
|
|
|
|
cmd_args = ["item", "view", "--field", "totp", "--output", "json"]
|
|
|
|
if uri:
|
|
cmd_args.append(uri)
|
|
else:
|
|
if not ((share_id or vault_name) and (item_id or item_title)):
|
|
return {
|
|
"success": False,
|
|
"error": (
|
|
"Provide (share_id & item_id), (vault_name & item_title), "
|
|
"or pass:// URI"
|
|
),
|
|
}
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if item_id:
|
|
cmd_args.extend(["--item-id", item_id])
|
|
if item_title:
|
|
cmd_args.extend(["--item-title", item_title])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=10)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
code = cmd.get("output", "") or (cmd.get("data", {}).get("totp", "") if isinstance(cmd.get("data"), dict) else "")
|
|
|
|
return {
|
|
"success": True,
|
|
"totp": code.strip(),
|
|
"item": item_title or item_id or uri,
|
|
}
|
|
|
|
|
|
def proton_pass_inject(args: dict) -> dict:
|
|
"""
|
|
Run a command with secrets injected into environment variables.
|
|
|
|
Wraps `pass-cli run` which resolves pass:// URIs in env vars
|
|
before executing the command. Secrets are masked in stdout/stderr
|
|
by default.
|
|
"""
|
|
command = args.get("command", "")
|
|
env_files = args.get("env_files", [])
|
|
no_masking = args.get("no_masking", False)
|
|
|
|
if not command:
|
|
return {"success": False, "error": "command is required"}
|
|
|
|
cmd_args = ["run"]
|
|
for env_file in (env_files or []):
|
|
cmd_args.extend(["--env-file", env_file])
|
|
if no_masking:
|
|
cmd_args.append("--no-masking")
|
|
cmd_args.append("--")
|
|
# Split command string into args
|
|
cmd_args.extend(shlex.split(command))
|
|
|
|
cmd = _run_pass(cmd_args, timeout=60)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"command": command,
|
|
"output": cmd.get("output", ""),
|
|
"note": (
|
|
"Secrets were injected as environment variables. "
|
|
"Values are masked in stdout/stderr by default."
|
|
),
|
|
}
|
|
|
|
|
|
def proton_pass_run(args: dict) -> dict:
|
|
"""Alias for inject — runs a command with secret-injected env vars."""
|
|
return proton_pass_inject(args)
|
|
|
|
|
|
# ── SSH agent handlers ────────────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_ssh_load(args: dict) -> dict:
|
|
"""
|
|
Load SSH keys from Proton Pass into the system's SSH agent.
|
|
|
|
Requires SSH_AUTH_SOCK to be set. Optionally restrict to a vault.
|
|
"""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
|
|
if not os.environ.get("SSH_AUTH_SOCK"):
|
|
return {
|
|
"success": False,
|
|
"error": (
|
|
"SSH_AUTH_SOCK not set. No SSH agent running. "
|
|
"Start one with: eval $(ssh-agent)"
|
|
),
|
|
}
|
|
|
|
cmd_args = ["ssh-agent", "load"]
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": cmd.get("output", "SSH keys loaded."),
|
|
}
|
|
|
|
|
|
def proton_pass_ssh_agent_start(args: dict) -> dict:
|
|
"""
|
|
Start Proton Pass as the SSH agent (foreground).
|
|
|
|
Sets SSH_AUTH_SOCK to the agent's socket path. Keys load from vaults.
|
|
"""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
|
|
cmd_args = ["ssh-agent", "start"]
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": cmd.get("output", "SSH agent started."),
|
|
"note": (
|
|
"Set SSH_AUTH_SOCK to the socket path shown above "
|
|
"to connect other terminals to this agent."
|
|
),
|
|
}
|
|
|
|
|
|
def proton_pass_ssh_daemon_start(args: dict) -> dict:
|
|
"""
|
|
Start Proton Pass SSH agent as a background daemon.
|
|
"""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
log_file = args.get("log_file", "")
|
|
|
|
cmd_args = ["ssh-agent", "daemon", "start"]
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if log_file:
|
|
cmd_args.extend(["--log-file", log_file])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": cmd.get("output", "SSH agent daemon started."),
|
|
}
|
|
|
|
|
|
def proton_pass_ssh_daemon_status(args: dict) -> dict:
|
|
"""Check the status of the Proton Pass SSH daemon."""
|
|
cmd = _run_pass(["ssh-agent", "daemon", "status"], timeout=10)
|
|
return cmd if not cmd["success"] else {
|
|
"success": True,
|
|
"status": cmd.get("output", ""),
|
|
"data": cmd.get("data"),
|
|
}
|
|
|
|
|
|
def proton_pass_ssh_daemon_stop(args: dict) -> dict:
|
|
"""Stop the Proton Pass SSH agent daemon."""
|
|
cmd = _run_pass(["ssh-agent", "daemon", "stop"], timeout=10)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
return {
|
|
"success": True,
|
|
"message": cmd.get("output", "SSH agent daemon stopped."),
|
|
}
|
|
|
|
|
|
# ── generic item operations ───────────────────────────────────────────
|
|
|
|
|
|
def proton_pass_share_item(args: dict) -> dict:
|
|
"""Share an item with another user."""
|
|
share_id = args.get("share_id", "")
|
|
vault_name = args.get("vault_name", "")
|
|
item_id = args.get("item_id", "")
|
|
item_title = args.get("item_title", "")
|
|
email = args.get("email", "")
|
|
role = args.get("role", "viewer")
|
|
|
|
if not email:
|
|
return {"success": False, "error": "email is required"}
|
|
if not ((share_id or vault_name) and (item_id or item_title)):
|
|
return {
|
|
"success": False,
|
|
"error": "Provide item identifiers (share_id/vault_name + item_id/item_title)",
|
|
}
|
|
|
|
cmd_args = ["item", "share"]
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if item_id:
|
|
cmd_args.extend(["--item-id", item_id])
|
|
if item_title:
|
|
cmd_args.extend(["--item-title", item_title])
|
|
cmd_args.append(email)
|
|
if role:
|
|
cmd_args.extend(["--role", role])
|
|
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Item shared with {email} (role: {role}).",
|
|
}
|
|
|
|
|
|
def proton_pass_generate_password(args: dict) -> dict:
|
|
"""Generate a random password or passphrase and optionally save to a vault."""
|
|
length = args.get("length", 20)
|
|
include_uppercase = args.get("include_uppercase", True)
|
|
include_symbols = args.get("include_symbols", True)
|
|
passphrase = args.get("passphrase", False)
|
|
word_count = args.get("word_count", 4)
|
|
save = args.get("save", False)
|
|
vault_name = args.get("vault_name", "")
|
|
share_id = args.get("share_id", "")
|
|
title = args.get("title", "")
|
|
|
|
if passphrase:
|
|
# Generate a passphrase
|
|
cmd_args = ["item", "create", "login"]
|
|
gen_flag = f"--generate-passphrase={word_count}" if word_count else "--generate-passphrase"
|
|
cmd_args.append(gen_flag)
|
|
if title:
|
|
cmd_args.extend(["--title", title])
|
|
else:
|
|
cmd_args.extend(["--title", f"Generated Passphrase ({word_count} words)"])
|
|
else:
|
|
# Generate a password
|
|
settings_parts = [str(length)]
|
|
if include_uppercase:
|
|
settings_parts.append("uppercase")
|
|
if include_symbols:
|
|
settings_parts.append("symbols")
|
|
gen_settings = ",".join(settings_parts)
|
|
|
|
cmd_args = ["item", "create", "login"]
|
|
cmd_args.append(f"--generate-password={gen_settings}")
|
|
if title:
|
|
cmd_args.extend(["--title", title])
|
|
else:
|
|
cmd_args.extend(["--title", f"Generated Password ({length} chars)"])
|
|
|
|
if save:
|
|
if vault_name:
|
|
cmd_args.extend(["--vault-name", vault_name])
|
|
if share_id:
|
|
cmd_args.extend(["--share-id", share_id])
|
|
cmd = _run_pass(cmd_args, timeout=15)
|
|
if not cmd["success"]:
|
|
return cmd
|
|
return {
|
|
"success": True,
|
|
"message": f"Password generated and saved to vault.",
|
|
"output": cmd.get("output", ""),
|
|
}
|
|
|
|
# For non-save mode, use --get-template to capture output
|
|
# Actually just generate and discard; or generate on CLI side
|
|
return {
|
|
"success": True,
|
|
"message": (
|
|
f"Password generation requires saving to a vault (pass --save). "
|
|
f"Generated items are securely stored in Proton Pass."
|
|
),
|
|
"settings": {
|
|
"type": "passphrase" if passphrase else "password",
|
|
"length": word_count if passphrase else length,
|
|
},
|
|
}
|
|
|
|
|
|
# ── tool registry ─────────────────────────────────────────────────────
|
|
|
|
|
|
TOOLS: dict[str, dict[str, Any]] = {
|
|
"proton_pass_login": {
|
|
"handler": proton_pass_login,
|
|
"schema": {
|
|
"name": "proton_pass_login",
|
|
"description": "Authenticate with Proton Pass. Supports interactive (browser), PAT (personal access token), and web login modes.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"mode": {
|
|
"type": "string",
|
|
"enum": ["interactive", "pat", "web"],
|
|
"description": "Login mode. 'interactive' (default): prompts for credentials. 'pat': personal access token. 'web': browser-based OAuth.",
|
|
},
|
|
"username": {
|
|
"type": "string",
|
|
"description": "Proton account email/username (interactive mode).",
|
|
},
|
|
"token": {
|
|
"type": "string",
|
|
"description": "Personal access token (PAT mode only).",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_logout": {
|
|
"handler": proton_pass_logout,
|
|
"schema": {
|
|
"name": "proton_pass_logout",
|
|
"description": "End the current Proton Pass session and clear cached credentials.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_auth_status": {
|
|
"handler": proton_pass_auth_status,
|
|
"schema": {
|
|
"name": "proton_pass_auth_status",
|
|
"description": "Check Proton Pass authentication status — whether logged in, account info, session validity.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_test": {
|
|
"handler": proton_pass_test,
|
|
"schema": {
|
|
"name": "proton_pass_test",
|
|
"description": "Test the pass-cli connection and authentication validity.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_vaults": {
|
|
"handler": proton_pass_vaults,
|
|
"schema": {
|
|
"name": "proton_pass_vaults",
|
|
"description": "List all accessible Proton Pass vaults with share IDs and metadata.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_vault_create": {
|
|
"handler": proton_pass_vault_create,
|
|
"schema": {
|
|
"name": "proton_pass_vault_create",
|
|
"description": "Create a new Proton Pass vault.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Name for the new vault.",
|
|
},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_vault_delete": {
|
|
"handler": proton_pass_vault_delete,
|
|
"schema": {
|
|
"name": "proton_pass_vault_delete",
|
|
"description": "Permanently delete a Proton Pass vault and all its contents. Cannot be undone.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID (mutually exclusive with vault_name).",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name (mutually exclusive with share_id).",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_list": {
|
|
"handler": proton_pass_list,
|
|
"schema": {
|
|
"name": "proton_pass_list",
|
|
"description": "List all items in a vault. Optionally filter by vault name or share ID.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name (mutually exclusive with share_id).",
|
|
},
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID (mutually exclusive with vault_name).",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_get": {
|
|
"handler": proton_pass_get,
|
|
"schema": {
|
|
"name": "proton_pass_get",
|
|
"description": "Retrieve a specific secret/item from a Proton Pass vault. Returns full item details including password, username, URLs, notes, and custom fields. WARNING: Secrets enter the agent context.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name.",
|
|
},
|
|
"item_id": {
|
|
"type": "string",
|
|
"description": "Item ID within the vault.",
|
|
},
|
|
"item_title": {
|
|
"type": "string",
|
|
"description": "Item title (name) within the vault.",
|
|
},
|
|
"uri": {
|
|
"type": "string",
|
|
"description": "pass:// URI shortcut: pass://vault/item/field or pass://share_id/item_id.",
|
|
},
|
|
"field": {
|
|
"type": "string",
|
|
"description": "Specific field to retrieve (e.g., 'password', 'username', 'email', 'url', 'note', or custom field name).",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_search": {
|
|
"handler": proton_pass_search,
|
|
"schema": {
|
|
"name": "proton_pass_search",
|
|
"description": "Search items across Proton Pass vaults by title or name. Lists items and filters client-side for matching names.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Search query string.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Optional: restrict search to a specific vault by name.",
|
|
},
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Optional: restrict search to a specific vault by share ID.",
|
|
},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_create": {
|
|
"handler": proton_pass_create,
|
|
"schema": {
|
|
"name": "proton_pass_create",
|
|
"description": "Create a new item (login or note) in a Proton Pass vault with specified fields.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {
|
|
"type": "string",
|
|
"enum": ["login", "note"],
|
|
"description": "Item type. Default: 'login'.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Target vault name.",
|
|
},
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Target vault share ID.",
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "Item title (required).",
|
|
},
|
|
"username": {
|
|
"type": "string",
|
|
"description": "Username for login items.",
|
|
},
|
|
"password": {
|
|
"type": "string",
|
|
"description": "Password value. Exclusive with generate_password.",
|
|
},
|
|
"generate_password": {
|
|
"type": "boolean",
|
|
"description": "Auto-generate a password instead of providing one.",
|
|
},
|
|
"password_settings": {
|
|
"type": "string",
|
|
"description": "Generation settings: 'length,uppercase,symbols' or 'length' (default: '20,uppercase,symbols').",
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "URL for the item.",
|
|
},
|
|
"note": {
|
|
"type": "string",
|
|
"description": "Notes or content for note-type items.",
|
|
},
|
|
},
|
|
"required": ["title"],
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_edit": {
|
|
"handler": proton_pass_edit,
|
|
"schema": {
|
|
"name": "proton_pass_edit",
|
|
"description": "Update fields on an existing Proton Pass item. Supports standard and custom fields.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name.",
|
|
},
|
|
"item_id": {
|
|
"type": "string",
|
|
"description": "Item ID to update.",
|
|
},
|
|
"item_title": {
|
|
"type": "string",
|
|
"description": "Item title to update.",
|
|
},
|
|
"fields": {
|
|
"type": "object",
|
|
"description": "Key-value map of fields to update (e.g., {'password': 'newpass', 'title': 'New Name'}).",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_delete": {
|
|
"handler": proton_pass_delete,
|
|
"schema": {
|
|
"name": "proton_pass_delete",
|
|
"description": "Permanently delete an item from a Proton Pass vault.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name.",
|
|
},
|
|
"item_id": {
|
|
"type": "string",
|
|
"description": "Item ID to delete.",
|
|
},
|
|
"item_title": {
|
|
"type": "string",
|
|
"description": "Item title to delete.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_totp": {
|
|
"handler": proton_pass_totp,
|
|
"schema": {
|
|
"name": "proton_pass_totp",
|
|
"description": "Get the current TOTP (time-based one-time password) code for an item that has TOTP configured.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name.",
|
|
},
|
|
"item_id": {
|
|
"type": "string",
|
|
"description": "Item ID.",
|
|
},
|
|
"item_title": {
|
|
"type": "string",
|
|
"description": "Item title.",
|
|
},
|
|
"uri": {
|
|
"type": "string",
|
|
"description": "pass:// URI shortcut.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_inject": {
|
|
"handler": proton_pass_inject,
|
|
"schema": {
|
|
"name": "proton_pass_inject",
|
|
"description": "Run a shell command with secrets injected into environment variables from Proton Pass. Uses pass-cli run to resolve pass:// URIs in env vars. Secrets are masked in stdout/stderr by default.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"type": "string",
|
|
"description": "Command to execute with secret-injected environment variables.",
|
|
},
|
|
"env_files": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": ".env files to load (later files override earlier).",
|
|
},
|
|
"no_masking": {
|
|
"type": "boolean",
|
|
"description": "Disable automatic secret masking in output. Use with caution.",
|
|
},
|
|
},
|
|
"required": ["command"],
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_ssh_load": {
|
|
"handler": proton_pass_ssh_load,
|
|
"schema": {
|
|
"name": "proton_pass_ssh_load",
|
|
"description": "Load SSH keys stored in Proton Pass into the system's SSH agent. Requires SSH_AUTH_SOCK to be set.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by name.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_ssh_agent_start": {
|
|
"handler": proton_pass_ssh_agent_start,
|
|
"schema": {
|
|
"name": "proton_pass_ssh_agent_start",
|
|
"description": "Start Proton Pass CLI as the SSH agent (foreground). Sets SSH_AUTH_SOCK to the agent's Unix socket.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by name.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_ssh_daemon_start": {
|
|
"handler": proton_pass_ssh_daemon_start,
|
|
"schema": {
|
|
"name": "proton_pass_ssh_daemon_start",
|
|
"description": "Start Proton Pass SSH agent as a background daemon process.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Restrict to vault by name.",
|
|
},
|
|
"log_file": {
|
|
"type": "string",
|
|
"description": "Path to log file for daemon output.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_ssh_daemon_status": {
|
|
"handler": proton_pass_ssh_daemon_status,
|
|
"schema": {
|
|
"name": "proton_pass_ssh_daemon_status",
|
|
"description": "Check the status of the Proton Pass SSH agent daemon (running, degraded, or stopped).",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_ssh_daemon_stop": {
|
|
"handler": proton_pass_ssh_daemon_stop,
|
|
"schema": {
|
|
"name": "proton_pass_ssh_daemon_stop",
|
|
"description": "Stop the Proton Pass SSH agent background daemon.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
"proton_pass_share_item": {
|
|
"handler": proton_pass_share_item,
|
|
"schema": {
|
|
"name": "proton_pass_share_item",
|
|
"description": "Share a Proton Pass item with another user by email.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault name.",
|
|
},
|
|
"item_id": {
|
|
"type": "string",
|
|
"description": "Item ID to share.",
|
|
},
|
|
"item_title": {
|
|
"type": "string",
|
|
"description": "Item title to share.",
|
|
},
|
|
"email": {
|
|
"type": "string",
|
|
"description": "Recipient email address.",
|
|
},
|
|
"role": {
|
|
"type": "string",
|
|
"enum": ["viewer", "editor", "manager"],
|
|
"description": "Access role. Default: viewer.",
|
|
},
|
|
},
|
|
"required": ["email"],
|
|
},
|
|
},
|
|
},
|
|
"proton_pass_generate_password": {
|
|
"handler": proton_pass_generate_password,
|
|
"schema": {
|
|
"name": "proton_pass_generate_password",
|
|
"description": "Generate a random password or passphrase and optionally save it to a vault.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"length": {
|
|
"type": "integer",
|
|
"description": "Password character length (default: 20).",
|
|
},
|
|
"include_uppercase": {
|
|
"type": "boolean",
|
|
"description": "Include uppercase letters (default: true).",
|
|
},
|
|
"include_symbols": {
|
|
"type": "boolean",
|
|
"description": "Include symbols (default: true).",
|
|
},
|
|
"passphrase": {
|
|
"type": "boolean",
|
|
"description": "Generate a passphrase instead of a random password.",
|
|
},
|
|
"word_count": {
|
|
"type": "integer",
|
|
"description": "Number of words for passphrase (default: 4).",
|
|
},
|
|
"save": {
|
|
"type": "boolean",
|
|
"description": "Save the generated item to a vault.",
|
|
},
|
|
"vault_name": {
|
|
"type": "string",
|
|
"description": "Vault to save into (required if save=true).",
|
|
},
|
|
"share_id": {
|
|
"type": "string",
|
|
"description": "Vault share ID to save into.",
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "Title for the saved item.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
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()
|