#!/usr/bin/env python3 """Google Workspace OAuth2 setup for Hermes Agent. Fully non-interactive — designed to be driven by the agent via terminal commands. The agent mediates between this script and the user (works on CLI, Telegram, Discord, etc.) Commands: setup.py --check # Is auth valid? Exit 0 = yes, 1 = no setup.py --client-secret /path/to.json # Store OAuth client credentials setup.py --auth-url # Print the OAuth URL for user to visit setup.py --auth-code CODE # Exchange auth code for token setup.py --revoke # Revoke and delete stored token setup.py --install-deps # Install Python dependencies only Agent workflow: 1. Run --check. If exit 0, auth is good — skip setup. 2. Ask user for client_secret.json path. Run --client-secret PATH. 3. Run --auth-url. Send the printed URL to the user. 4. User opens URL, authorizes, gets redirected to a page with a code. 5. User pastes the code. Agent runs --auth-code CODE. 6. Run --check to verify. Done. """ import argparse import json import os import subprocess import sys from pathlib import Path HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) TOKEN_PATH = HERMES_HOME / "google_token.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/documents.readonly", ] REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"] # OAuth redirect for "out of band" manual code copy flow. # Google deprecated OOB, so we use a localhost redirect and tell the user to # copy the code from the browser's URL bar (or the page body). REDIRECT_URI = "http://localhost:1" def install_deps(): """Install Google API packages if missing. Returns True on success.""" try: import googleapiclient # noqa: F401 import google_auth_oauthlib # noqa: F401 print("Dependencies already installed.") return True except ImportError: pass print("Installing Google API dependencies...") try: subprocess.check_call( [sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES, stdout=subprocess.DEVNULL, ) print("Dependencies installed.") return True except subprocess.CalledProcessError as e: print(f"ERROR: Failed to install dependencies: {e}") print(f"Try manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}") return False def _ensure_deps(): """Check deps are available, install if not, exit on failure.""" try: import googleapiclient # noqa: F401 import google_auth_oauthlib # noqa: F401 except ImportError: if not install_deps(): sys.exit(1) def check_auth(): """Check if stored credentials are valid. Prints status, exits 0 or 1.""" if not TOKEN_PATH.exists(): print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}") return False _ensure_deps() from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request try: creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES) except Exception as e: print(f"TOKEN_CORRUPT: {e}") return False if creds.valid: print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") return True if creds.expired and creds.refresh_token: try: creds.refresh(Request()) TOKEN_PATH.write_text(creds.to_json()) print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: print(f"REFRESH_FAILED: {e}") return False print("TOKEN_INVALID: Re-run setup.") return False def store_client_secret(path: str): """Copy and validate client_secret.json to Hermes home.""" src = Path(path).expanduser().resolve() if not src.exists(): print(f"ERROR: File not found: {src}") sys.exit(1) try: data = json.loads(src.read_text()) except json.JSONDecodeError: print("ERROR: File is not valid JSON.") sys.exit(1) if "installed" not in data and "web" not in data: print("ERROR: Not a Google OAuth client secret file (missing 'installed' key).") print("Download the correct file from: https://console.cloud.google.com/apis/credentials") sys.exit(1) CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2)) print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}") def get_auth_url(): """Print the OAuth authorization URL. User visits this in a browser.""" if not CLIENT_SECRET_PATH.exists(): print("ERROR: No client secret stored. Run --client-secret first.") sys.exit(1) _ensure_deps() from google_auth_oauthlib.flow import Flow flow = Flow.from_client_secrets_file( str(CLIENT_SECRET_PATH), scopes=SCOPES, redirect_uri=REDIRECT_URI, ) auth_url, _ = flow.authorization_url( access_type="offline", prompt="consent", ) # Print just the URL so the agent can extract it cleanly print(auth_url) def exchange_auth_code(code: str): """Exchange the authorization code for a token and save it.""" if not CLIENT_SECRET_PATH.exists(): print("ERROR: No client secret stored. Run --client-secret first.") sys.exit(1) _ensure_deps() from google_auth_oauthlib.flow import Flow flow = Flow.from_client_secrets_file( str(CLIENT_SECRET_PATH), scopes=SCOPES, redirect_uri=REDIRECT_URI, ) # The code might come as a full redirect URL or just the code itself if code.startswith("http"): # Extract code from redirect URL: http://localhost:1/?code=CODE&scope=... from urllib.parse import urlparse, parse_qs parsed = urlparse(code) params = parse_qs(parsed.query) if "code" not in params: print("ERROR: No 'code' parameter found in URL.") sys.exit(1) code = params["code"][0] try: flow.fetch_token(code=code) except Exception as e: print(f"ERROR: Token exchange failed: {e}") print("The code may have expired. Run --auth-url to get a fresh URL.") sys.exit(1) creds = flow.credentials TOKEN_PATH.write_text(creds.to_json()) print(f"OK: Authenticated. Token saved to {TOKEN_PATH}") def revoke(): """Revoke stored token and delete it.""" if not TOKEN_PATH.exists(): print("No token to revoke.") return _ensure_deps() from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request try: creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES) if creds.expired and creds.refresh_token: creds.refresh(Request()) import urllib.request urllib.request.urlopen( urllib.request.Request( f"https://oauth2.googleapis.com/revoke?token={creds.token}", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) ) print("Token revoked with Google.") except Exception as e: print(f"Remote revocation failed (token may already be invalid): {e}") TOKEN_PATH.unlink(missing_ok=True) print(f"Deleted {TOKEN_PATH}") def main(): parser = argparse.ArgumentParser(description="Google Workspace OAuth setup for Hermes") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)") group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json") group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit") group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token") group.add_argument("--revoke", action="store_true", help="Revoke and delete stored token") group.add_argument("--install-deps", action="store_true", help="Install Python dependencies") args = parser.parse_args() if args.check: sys.exit(0 if check_auth() else 1) elif args.client_secret: store_client_secret(args.client_secret) elif args.auth_url: get_auth_url() elif args.auth_code: exchange_auth_code(args.auth_code) elif args.revoke: revoke() elif args.install_deps: sys.exit(0 if install_deps() else 1) if __name__ == "__main__": main()