feat(skills): add garmin-auth skill with 2-turn Telegram MFA flow
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: garmin-auth
|
||||
description: Manage Garmin Connect authentication, proactive OAuth token keepalives, and interactive 2-turn MFA login over Telegram. Use when Garmin authentication fails (HTTP 401, expired tokens) or when the user asks to log into Garmin.
|
||||
---
|
||||
|
||||
# 🔐 Garmin Connect Authentication & MFA Manager
|
||||
|
||||
Headless authentication lifecycle manager for Garmin Connect, allowing OpenClaw to initiate logins, keep tokens perpetually fresh, and verify 6-digit MFA codes directly via Telegram.
|
||||
|
||||
## 🎯 When to Use
|
||||
- **Authentication Errors**: Whenever a `garmin` MCP tool call fails with `Garmin authentication failed`, `Tokens expired`, `401 Unauthorized`, or `Failed to retrieve social profile`.
|
||||
- **User Request**: Whenever the user explicitly asks: *"Logge dich bei Garmin ein"*, *"Garmin MFA Code eingeben"*, *"Garmin Re-Auth"*, *"Verbinde Garmin"*.
|
||||
- **Keepalive / Watchdog**: During morning briefings or background checks to ensure tokens are proactively refreshed before expiring.
|
||||
- **Status Checks**: Whenever asked about Garmin token health or expiration: *"Ist Garmin eingeloggt?"*, *"Wie lange ist der Garmin-Token noch gültig?"*.
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ Interactive Telegram MFA Login Flow (Protocol)
|
||||
|
||||
Because Garmin requires Multi-Factor Authentication (OTP sent via SMS/email), login operates in **two conversation turns**:
|
||||
|
||||
### Turn 1: Request Code (Initiate Login)
|
||||
1. Execute the `request-code` command:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py request-code
|
||||
```
|
||||
2. The command spawns a background authentication daemon and triggers Garmin to dispatch the 6-digit code to the user's email/SMS.
|
||||
3. Immediately send a clear, friendly prompt to the user on Telegram:
|
||||
> 🔑 **Garmin-Authentifizierung erforderlich**
|
||||
> Ich habe einen neuen Login bei Garmin gestartet. Garmin hat dir soeben einen 6-stelligen Sicherheitscode per SMS oder E-Mail gesendet.
|
||||
>
|
||||
> Bitte antworte mir einfach kurz mit dem **6-stelligen Code**!
|
||||
|
||||
### Turn 2: Verify Code (Complete Login)
|
||||
1. When the user sends a 6-digit message (e.g. `538291`, `538 291`):
|
||||
2. Execute the `verify` command:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py verify "<mfa_code>"
|
||||
```
|
||||
3. If successful:
|
||||
- The tokens are automatically dumped to `/home/node/.openclaw/garmin_tokens/garmin_tokens.json` and base64 exported.
|
||||
- Reply to the user:
|
||||
> ✅ **Garmin-Login erfolgreich!**
|
||||
> Angemeldet als **Moritz Graf**. Alle Garmin-Tools und Workouts sind wieder voll einsatzbereit.
|
||||
4. If the code was incorrect:
|
||||
- The daemon remains alive for 5 minutes. Inform the user and allow them to retry:
|
||||
> ⚠️ Der eingegebene Code war leider ungültig oder abgelaufen. Bitte prüfe den Code und sende ihn erneut.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ CLI Operations Reference
|
||||
|
||||
### 1. Check Token Health & Validity
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py status
|
||||
```
|
||||
Outputs JSON with `valid`, `user`, `exp_utc`, `pending_mfa`.
|
||||
|
||||
### 2. Proactive Keepalive (Refresh Token before Expiry)
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py keepalive
|
||||
```
|
||||
Touches the API, triggering `python-garminconnect`'s automatic token refresh and persisting the newly rotated refresh token to `/home/node/.openclaw/garmin_tokens/garmin_tokens.json`.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Environment
|
||||
- Requires `GARMIN_EMAIL` and `GARMIN_PASSWORD` (already injected via `openclaw-secrets`).
|
||||
- Persistent storage: `/home/node/.openclaw/garmin_tokens/garmin_tokens.json` (on PVC `openclaw-data`).
|
||||
- Directory permissions are automatically set to `0700` and files to `0600`.
|
||||
Executable
+533
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "garminconnect>=0.2.20",
|
||||
# "curl_cffi>=0.7.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Garmin Connect Authentication & Token Lifecycle Manager for OpenClaw.
|
||||
|
||||
Provides headless, 2-turn MFA login via Telegram, proactive token keepalive,
|
||||
and token status inspection.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stderr)],
|
||||
)
|
||||
logger = logging.getLogger("garmin_auth")
|
||||
|
||||
|
||||
def get_token_dir() -> str:
|
||||
"""Get the token directory from environment or default locations."""
|
||||
if os.environ.get("GARMINTOKENS"):
|
||||
p = os.path.expanduser(os.environ["GARMINTOKENS"])
|
||||
elif os.path.exists("/home/node/.openclaw"):
|
||||
p = "/home/node/.openclaw/garmin_tokens"
|
||||
else:
|
||||
p = os.path.expanduser("~/.garminconnect")
|
||||
os.makedirs(p, mode=0o700, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def get_token_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.json")
|
||||
|
||||
|
||||
def get_token_base64_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.base64")
|
||||
|
||||
|
||||
def get_socket_path() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_daemon.sock")
|
||||
return "/tmp/garmin_auth.sock"
|
||||
|
||||
|
||||
def get_status_file() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_status.json")
|
||||
return "/tmp/garmin_auth_status.json"
|
||||
|
||||
|
||||
def get_credentials() -> Tuple[str, str]:
|
||||
email = os.environ.get("GARMIN_EMAIL") or os.environ.get("garmin-email")
|
||||
password = os.environ.get("GARMIN_PASSWORD") or os.environ.get("garmin-password")
|
||||
|
||||
if not email or not password:
|
||||
# Check files if defined
|
||||
email_file = os.environ.get("GARMIN_EMAIL_FILE")
|
||||
if email_file and os.path.exists(email_file):
|
||||
with open(email_file) as f:
|
||||
email = f.read().strip()
|
||||
password_file = os.environ.get("GARMIN_PASSWORD_FILE")
|
||||
if password_file and os.path.exists(password_file):
|
||||
with open(password_file) as f:
|
||||
password = f.read().strip()
|
||||
|
||||
if not email or not password:
|
||||
raise ValueError(
|
||||
"GARMIN_EMAIL and GARMIN_PASSWORD environment variables must be set."
|
||||
)
|
||||
return email.strip(), password.strip()
|
||||
|
||||
|
||||
def secure_tokens(token_dir: str) -> None:
|
||||
"""Set secure 700 / 600 permissions."""
|
||||
try:
|
||||
if os.path.isdir(token_dir):
|
||||
os.chmod(token_dir, 0o700)
|
||||
for entry in os.scandir(token_dir):
|
||||
if entry.is_file():
|
||||
os.chmod(entry.path, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not secure directory permissions: {e}")
|
||||
|
||||
|
||||
def decode_jwt_exp(jwt_token: str) -> Tuple[Optional[datetime], Optional[int]]:
|
||||
"""Extract expiration datetime and timestamp from JWT."""
|
||||
try:
|
||||
parts = jwt_token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None, None
|
||||
payload_b64 = parts[1]
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
exp = payload.get("exp")
|
||||
if exp:
|
||||
dt = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
return dt, exp
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: status
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_status() -> int:
|
||||
token_file = get_token_file()
|
||||
token_dir = get_token_dir()
|
||||
status_file = get_status_file()
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"token_dir": token_dir,
|
||||
"token_file": token_file,
|
||||
"exists": os.path.exists(token_file),
|
||||
"valid": False,
|
||||
"user": None,
|
||||
"exp_utc": None,
|
||||
"exp_timestamp": None,
|
||||
"pending_mfa": False,
|
||||
}
|
||||
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
s = json.load(f)
|
||||
if s.get("state") == "waiting_for_mfa":
|
||||
info["pending_mfa"] = True
|
||||
info["mfa_started_at"] = s.get("started_at")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not info["exists"]:
|
||||
print(json.dumps(info, indent=2))
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(token_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
di_token = data.get("di_token")
|
||||
if di_token:
|
||||
dt, exp = decode_jwt_exp(di_token)
|
||||
if dt:
|
||||
info["exp_utc"] = dt.isoformat()
|
||||
info["exp_timestamp"] = exp
|
||||
|
||||
# Test live connectivity
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
full_name = client.get_full_name()
|
||||
if full_name:
|
||||
info["valid"] = True
|
||||
info["user"] = full_name
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
print(json.dumps(info, indent=2))
|
||||
return 0 if info["valid"] else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background Daemon: Run MFA Flow & Listen on Unix Socket
|
||||
# ---------------------------------------------------------------------------
|
||||
def _run_daemon():
|
||||
"""Executed as a detached background worker process."""
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
base64_file = get_token_base64_file()
|
||||
|
||||
# Clean up old socket
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
sys.exit(1)
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "initiating_login",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
try:
|
||||
garmin = Garmin(email=email, password=password, return_on_mfa=True)
|
||||
status, _ = garmin.login()
|
||||
|
||||
if status != "needs_mfa":
|
||||
# Logged in directly without MFA
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
with open(token_file, "r") as tf:
|
||||
raw = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(raw.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
name = garmin.get_full_name()
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "success", "user": name}, f)
|
||||
sys.exit(0)
|
||||
|
||||
# Needs MFA - set up listening Unix socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(socket_path)
|
||||
os.chmod(socket_path, 0o600)
|
||||
sock.listen(1)
|
||||
sock.settimeout(300.0) # 5 minutes timeout for user to submit code
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "waiting_for_mfa",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
"socket": socket_path,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{"state": "timed_out", "error": "MFA wait timed out after 5 minutes"},
|
||||
f,
|
||||
)
|
||||
break
|
||||
|
||||
with conn:
|
||||
raw_data = conn.recv(1024).decode().strip()
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_data)
|
||||
code = payload.get("code", "").strip()
|
||||
except Exception:
|
||||
code = raw_data.strip()
|
||||
|
||||
code = re.sub(r"\D", "", code)
|
||||
if len(code) != 6:
|
||||
response = {
|
||||
"status": "invalid_code",
|
||||
"error": "Code must be 6 digits",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
continue
|
||||
|
||||
try:
|
||||
garmin.resume_login(None, code)
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
|
||||
# Export base64 string
|
||||
with open(token_file, "r") as tf:
|
||||
token_str = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(token_str.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
|
||||
name = garmin.get_full_name() or "User"
|
||||
response = {"status": "success", "user": name}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "success",
|
||||
"user": name,
|
||||
"finished_at": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
break # Success! Clean exit
|
||||
|
||||
except GarminConnectAuthenticationError as ae:
|
||||
response = {
|
||||
"status": "auth_error",
|
||||
"error": f"MFA verification failed: {ae}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
except Exception as ex:
|
||||
response = {
|
||||
"status": "error",
|
||||
"error": f"Unexpected error during resume_login: {ex}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
finally:
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: request-code
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_request_code() -> int:
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
|
||||
# If daemon is already running and waiting, check status
|
||||
if os.path.exists(socket_path) and os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
st = json.load(f)
|
||||
if st.get("state") == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_ALREADY_ACTIVE: A pending login session is already waiting for your MFA code. "
|
||||
"Reply with the 6-digit code or wait for timeout."
|
||||
)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up stale files
|
||||
for p in (socket_path, status_file):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Spawn daemon process via subprocess.Popen detached
|
||||
script_path = os.path.abspath(__file__)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, script_path, "_daemon"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info(f"Spawned Garmin auth daemon (PID: {proc.pid})")
|
||||
|
||||
# Poll status file up to 25 seconds
|
||||
start_wait = time.time()
|
||||
while time.time() - start_wait < 25.0:
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
data = json.load(f)
|
||||
state = data.get("state")
|
||||
if state == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_REQUIRED: Garmin has sent a 6-digit verification code to your email/SMS. "
|
||||
"Please reply with the code."
|
||||
)
|
||||
return 0
|
||||
elif state == "success":
|
||||
print(
|
||||
f"SUCCESS: Logged in directly as {data.get('user')} without MFA. Tokens saved."
|
||||
)
|
||||
return 0
|
||||
elif state == "error":
|
||||
print(f"ERROR: Login failed: {data.get('error')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("ERROR: Timed out waiting for Garmin SSO to send MFA code.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: verify <code>
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_verify(code: str) -> int:
|
||||
code = re.sub(r"\D", "", code.strip())
|
||||
if len(code) != 6:
|
||||
print(f"ERROR: Code must be exactly 6 digits, got {code!r}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
socket_path = get_socket_path()
|
||||
if not os.path.exists(socket_path):
|
||||
print(
|
||||
"ERROR: No active Garmin auth session found. Run 'request-code' first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(socket_path)
|
||||
sock.settimeout(35.0)
|
||||
|
||||
sock.sendall(json.dumps({"code": code}).encode())
|
||||
raw_res = sock.recv(2048).decode()
|
||||
sock.close()
|
||||
|
||||
res = json.loads(raw_res)
|
||||
if res.get("status") == "success":
|
||||
user = res.get("user", "Moritz Graf")
|
||||
print(f"SUCCESS: Authenticated as {user}! Tokens saved successfully.")
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: {res.get('error', 'Authentication failed')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed communicating with auth daemon: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: keepalive
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_keepalive() -> int:
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
|
||||
if not os.path.exists(token_file):
|
||||
print(f"ERROR: No tokens found at {token_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
# Calling an API endpoint triggers _token_expires_soon() and auto-refresh + dump
|
||||
name = client.get_full_name()
|
||||
if not name:
|
||||
print("ERROR: Login returned empty profile name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(token_file) as f:
|
||||
data = json.load(f)
|
||||
dt, exp = decode_jwt_exp(data.get("di_token", ""))
|
||||
|
||||
exp_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt else "unknown"
|
||||
print(f"KEEPALIVE_OK: Token valid for {name}. Current DI token expires at: {exp_str}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"ERROR: Keepalive check failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Router
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Garmin Authentication & Token Lifecycle CLI for OpenClaw"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Check stored token status and validity")
|
||||
|
||||
# request-code
|
||||
subparsers.add_parser(
|
||||
"request-code", help="Initiate Garmin login and request an MFA code"
|
||||
)
|
||||
|
||||
# verify <code>
|
||||
verify_parser = subparsers.add_parser(
|
||||
"verify", help="Submit MFA code to the active auth session"
|
||||
)
|
||||
verify_parser.add_argument("code", help="6-digit verification code")
|
||||
|
||||
# keepalive
|
||||
subparsers.add_parser(
|
||||
"keepalive", help="Ping Garmin API and proactively refresh token if needed"
|
||||
)
|
||||
|
||||
# internal daemon
|
||||
subparsers.add_parser("_daemon", help=argparse.SUPPRESS)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "_daemon":
|
||||
_run_daemon()
|
||||
elif args.command == "status":
|
||||
sys.exit(cmd_status())
|
||||
elif args.command == "request-code":
|
||||
sys.exit(cmd_request_code())
|
||||
elif args.command == "verify":
|
||||
sys.exit(cmd_verify(args.code))
|
||||
elif args.command == "keepalive":
|
||||
sys.exit(cmd_keepalive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user