Sync all skills and memories 2026-04-14 07:27

This commit is contained in:
2026-04-14 07:27:20 +09:00
parent 516bb44fe6
commit 1eba2bca95
386 changed files with 167655 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Google Workspace API CLI for Hermes Agent.
Thin wrapper that delegates to gws (googleworkspace/cli) via gws_bridge.py.
Maintains the same CLI interface for backward compatibility with Hermes skills.
Usage:
python google_api.py gmail search "is:unread" [--max 10]
python google_api.py gmail get MESSAGE_ID
python google_api.py gmail send --to user@example.com --subject "Hi" --body "Hello"
python google_api.py gmail reply MESSAGE_ID --body "Thanks"
python google_api.py calendar list [--start DATE] [--end DATE] [--calendar primary]
python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME
python google_api.py calendar delete EVENT_ID
python google_api.py drive search "budget report" [--max 10]
python google_api.py contacts list [--max 20]
python google_api.py sheets get SHEET_ID RANGE
python google_api.py sheets update SHEET_ID RANGE --values '[[...]]'
python google_api.py sheets append SHEET_ID RANGE --values '[[...]]'
python google_api.py docs get DOC_ID
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
BRIDGE = Path(__file__).parent / "gws_bridge.py"
PYTHON = sys.executable
def gws(*args: str) -> None:
"""Call gws via the bridge and exit with its return code."""
result = subprocess.run(
[PYTHON, str(BRIDGE)] + list(args),
env={**os.environ, "HERMES_HOME": os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))},
)
sys.exit(result.returncode)
# -- Gmail --
def gmail_search(args):
cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"]
gws(*cmd)
def gmail_get(args):
gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json")
def gmail_send(args):
cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"]
if args.cc:
cmd += ["--cc", args.cc]
if args.html:
cmd.append("--html")
gws(*cmd)
def gmail_reply(args):
gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json")
def gmail_labels(args):
gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json")
def gmail_modify(args):
body = {}
if args.add_labels:
body["addLabelIds"] = args.add_labels.split(",")
if args.remove_labels:
body["removeLabelIds"] = args.remove_labels.split(",")
gws(
"gmail", "users", "messages", "modify",
"--params", json.dumps({"userId": "me", "id": args.message_id}),
"--json", json.dumps(body),
"--format", "json",
)
# -- Calendar --
def calendar_list(args):
if args.start or args.end:
# Specific date range — use raw Calendar API for precise timeMin/timeMax
from datetime import datetime, timedelta, timezone as tz
now = datetime.now(tz.utc)
time_min = args.start or now.isoformat()
time_max = args.end or (now + timedelta(days=7)).isoformat()
gws(
"calendar", "events", "list",
"--params", json.dumps({
"calendarId": args.calendar,
"timeMin": time_min,
"timeMax": time_max,
"maxResults": args.max,
"singleEvents": True,
"orderBy": "startTime",
}),
"--format", "json",
)
else:
# No date range — use +agenda helper (defaults to 7 days)
cmd = ["calendar", "+agenda", "--days", "7", "--format", "json"]
if args.calendar != "primary":
cmd += ["--calendar", args.calendar]
gws(*cmd)
def calendar_create(args):
cmd = [
"calendar", "+insert",
"--summary", args.summary,
"--start", args.start,
"--end", args.end,
"--format", "json",
]
if args.location:
cmd += ["--location", args.location]
if args.description:
cmd += ["--description", args.description]
if args.attendees:
for email in args.attendees.split(","):
cmd += ["--attendee", email.strip()]
if args.calendar != "primary":
cmd += ["--calendar", args.calendar]
gws(*cmd)
def calendar_delete(args):
gws(
"calendar", "events", "delete",
"--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}),
"--format", "json",
)
# -- Drive --
def drive_search(args):
query = args.query if args.raw_query else f"fullText contains '{args.query}'"
gws(
"drive", "files", "list",
"--params", json.dumps({
"q": query,
"pageSize": args.max,
"fields": "files(id,name,mimeType,modifiedTime,webViewLink)",
}),
"--format", "json",
)
# -- Contacts --
def contacts_list(args):
gws(
"people", "people", "connections", "list",
"--params", json.dumps({
"resourceName": "people/me",
"pageSize": args.max,
"personFields": "names,emailAddresses,phoneNumbers",
}),
"--format", "json",
)
# -- Sheets --
def sheets_get(args):
gws(
"sheets", "+read",
"--spreadsheet", args.sheet_id,
"--range", args.range,
"--format", "json",
)
def sheets_update(args):
values = json.loads(args.values)
gws(
"sheets", "spreadsheets", "values", "update",
"--params", json.dumps({
"spreadsheetId": args.sheet_id,
"range": args.range,
"valueInputOption": "USER_ENTERED",
}),
"--json", json.dumps({"values": values}),
"--format", "json",
)
def sheets_append(args):
values = json.loads(args.values)
gws(
"sheets", "+append",
"--spreadsheet", args.sheet_id,
"--json-values", json.dumps(values),
"--format", "json",
)
# -- Docs --
def docs_get(args):
gws(
"docs", "documents", "get",
"--params", json.dumps({"documentId": args.doc_id}),
"--format", "json",
)
# -- CLI parser (backward-compatible interface) --
def main():
parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)")
sub = parser.add_subparsers(dest="service", required=True)
# --- Gmail ---
gmail = sub.add_parser("gmail")
gmail_sub = gmail.add_subparsers(dest="action", required=True)
p = gmail_sub.add_parser("search")
p.add_argument("query", help="Gmail search query (e.g. 'is:unread')")
p.add_argument("--max", type=int, default=10)
p.set_defaults(func=gmail_search)
p = gmail_sub.add_parser("get")
p.add_argument("message_id")
p.set_defaults(func=gmail_get)
p = gmail_sub.add_parser("send")
p.add_argument("--to", required=True)
p.add_argument("--subject", required=True)
p.add_argument("--body", required=True)
p.add_argument("--cc", default="")
p.add_argument("--html", action="store_true", help="Send body as HTML")
p.add_argument("--thread-id", default="", help="Thread ID (unused with gws, kept for compat)")
p.set_defaults(func=gmail_send)
p = gmail_sub.add_parser("reply")
p.add_argument("message_id", help="Message ID to reply to")
p.add_argument("--body", required=True)
p.set_defaults(func=gmail_reply)
p = gmail_sub.add_parser("labels")
p.set_defaults(func=gmail_labels)
p = gmail_sub.add_parser("modify")
p.add_argument("message_id")
p.add_argument("--add-labels", default="", help="Comma-separated label IDs to add")
p.add_argument("--remove-labels", default="", help="Comma-separated label IDs to remove")
p.set_defaults(func=gmail_modify)
# --- Calendar ---
cal = sub.add_parser("calendar")
cal_sub = cal.add_subparsers(dest="action", required=True)
p = cal_sub.add_parser("list")
p.add_argument("--start", default="", help="Start time (ISO 8601)")
p.add_argument("--end", default="", help="End time (ISO 8601)")
p.add_argument("--max", type=int, default=25)
p.add_argument("--calendar", default="primary")
p.set_defaults(func=calendar_list)
p = cal_sub.add_parser("create")
p.add_argument("--summary", required=True)
p.add_argument("--start", required=True, help="Start (ISO 8601 with timezone)")
p.add_argument("--end", required=True, help="End (ISO 8601 with timezone)")
p.add_argument("--location", default="")
p.add_argument("--description", default="")
p.add_argument("--attendees", default="", help="Comma-separated email addresses")
p.add_argument("--calendar", default="primary")
p.set_defaults(func=calendar_create)
p = cal_sub.add_parser("delete")
p.add_argument("event_id")
p.add_argument("--calendar", default="primary")
p.set_defaults(func=calendar_delete)
# --- Drive ---
drv = sub.add_parser("drive")
drv_sub = drv.add_subparsers(dest="action", required=True)
p = drv_sub.add_parser("search")
p.add_argument("query")
p.add_argument("--max", type=int, default=10)
p.add_argument("--raw-query", action="store_true", help="Use query as raw Drive API query")
p.set_defaults(func=drive_search)
# --- Contacts ---
con = sub.add_parser("contacts")
con_sub = con.add_subparsers(dest="action", required=True)
p = con_sub.add_parser("list")
p.add_argument("--max", type=int, default=50)
p.set_defaults(func=contacts_list)
# --- Sheets ---
sh = sub.add_parser("sheets")
sh_sub = sh.add_subparsers(dest="action", required=True)
p = sh_sub.add_parser("get")
p.add_argument("sheet_id")
p.add_argument("range")
p.set_defaults(func=sheets_get)
p = sh_sub.add_parser("update")
p.add_argument("sheet_id")
p.add_argument("range")
p.add_argument("--values", required=True, help="JSON array of arrays")
p.set_defaults(func=sheets_update)
p = sh_sub.add_parser("append")
p.add_argument("sheet_id")
p.add_argument("range")
p.add_argument("--values", required=True, help="JSON array of arrays")
p.set_defaults(func=sheets_append)
# --- Docs ---
docs = sub.add_parser("docs")
docs_sub = docs.add_subparsers(dest="action", required=True)
p = docs_sub.add_parser("get")
p.add_argument("doc_id")
p.set_defaults(func=docs_get)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Bridge between Hermes OAuth token and gws CLI.
Refreshes the token if expired, then executes gws with the valid access token.
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
def get_hermes_home() -> Path:
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
def get_token_path() -> Path:
return get_hermes_home() / "google_token.json"
def refresh_token(token_data: dict) -> dict:
"""Refresh the access token using the refresh token."""
import urllib.error
import urllib.parse
import urllib.request
params = urllib.parse.urlencode({
"client_id": token_data["client_id"],
"client_secret": token_data["client_secret"],
"refresh_token": token_data["refresh_token"],
"grant_type": "refresh_token",
}).encode()
req = urllib.request.Request(token_data["token_uri"], data=params)
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
print(f"ERROR: Token refresh failed (HTTP {e.code}): {body}", file=sys.stderr)
print("Re-run setup.py to re-authenticate.", file=sys.stderr)
sys.exit(1)
token_data["token"] = result["access_token"]
token_data["expiry"] = datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + result["expires_in"],
tz=timezone.utc,
).isoformat()
get_token_path().write_text(json.dumps(token_data, indent=2))
return token_data
def get_valid_token() -> str:
"""Return a valid access token, refreshing if needed."""
token_path = get_token_path()
if not token_path.exists():
print("ERROR: No Google token found. Run setup.py --auth-url first.", file=sys.stderr)
sys.exit(1)
token_data = json.loads(token_path.read_text())
expiry = token_data.get("expiry", "")
if expiry:
exp_dt = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if now >= exp_dt:
token_data = refresh_token(token_data)
return token_data["token"]
def main():
"""Refresh token if needed, then exec gws with remaining args."""
if len(sys.argv) < 2:
print("Usage: gws_bridge.py <gws args...>", file=sys.stderr)
sys.exit(1)
access_token = get_valid_token()
env = os.environ.copy()
env["GOOGLE_WORKSPACE_CLI_TOKEN"] = access_token
result = subprocess.run(["gws"] + sys.argv[1:], env=env)
sys.exit(result.returncode)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,397 @@
#!/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
try:
from hermes_constants import display_hermes_home, get_hermes_home
except ModuleNotFoundError:
HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4]
if HERMES_AGENT_ROOT.exists():
sys.path.insert(0, str(HERMES_AGENT_ROOT))
from hermes_constants import display_hermes_home, get_hermes_home
HERMES_HOME = get_hermes_home()
TOKEN_PATH = HERMES_HOME / "google_token.json"
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.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 _load_token_payload(path: Path = TOKEN_PATH) -> dict:
try:
return json.loads(path.read_text())
except Exception:
return {}
def _missing_scopes_from_payload(payload: dict) -> list[str]:
raw = payload.get("scopes") or payload.get("scope")
if not raw:
return []
granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}
return sorted(scope for scope in SCOPES if scope not in granted)
def _format_missing_scopes(missing_scopes: list[str]) -> str:
bullets = "\n".join(f" - {scope}" for scope in missing_scopes)
return (
"Token is valid but missing required Google Workspace scopes:\n"
f"{bullets}\n"
"Run the Google Workspace setup again from this same Hermes profile to refresh consent."
)
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:
# Don't pass scopes — user may have authorized only a subset.
# Passing scopes forces google-auth to validate them on refresh,
# which fails with invalid_scope if the token has fewer scopes
# than requested.
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
except Exception as e:
print(f"TOKEN_CORRUPT: {e}")
return False
payload = _load_token_payload(TOKEN_PATH)
if creds.valid:
missing_scopes = _missing_scopes_from_payload(payload)
if missing_scopes:
print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:")
for s in missing_scopes:
print(f" - {s}")
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())
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
if missing_scopes:
print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:")
for s in missing_scopes:
print(f" - {s}")
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 _save_pending_auth(*, state: str, code_verifier: str):
"""Persist the OAuth session bits needed for a later token exchange."""
PENDING_AUTH_PATH.write_text(
json.dumps(
{
"state": state,
"code_verifier": code_verifier,
"redirect_uri": REDIRECT_URI,
},
indent=2,
)
)
def _load_pending_auth() -> dict:
"""Load the pending OAuth session created by get_auth_url()."""
if not PENDING_AUTH_PATH.exists():
print("ERROR: No pending OAuth session found. Run --auth-url first.")
sys.exit(1)
try:
data = json.loads(PENDING_AUTH_PATH.read_text())
except Exception as e:
print(f"ERROR: Could not read pending OAuth session: {e}")
print("Run --auth-url again to start a fresh OAuth session.")
sys.exit(1)
if not data.get("state") or not data.get("code_verifier"):
print("ERROR: Pending OAuth session is missing PKCE data.")
print("Run --auth-url again to start a fresh OAuth session.")
sys.exit(1)
return data
def _extract_code_and_state(code_or_url: str) -> tuple[str, str | None]:
"""Accept either a raw auth code or the full redirect URL pasted by the user."""
if not code_or_url.startswith("http"):
return code_or_url, None
from urllib.parse import parse_qs, urlparse
parsed = urlparse(code_or_url)
params = parse_qs(parsed.query)
if "code" not in params:
print("ERROR: No 'code' parameter found in URL.")
sys.exit(1)
state = params.get("state", [None])[0]
return params["code"][0], state
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,
autogenerate_code_verifier=True,
)
auth_url, state = flow.authorization_url(
access_type="offline",
prompt="consent",
)
_save_pending_auth(state=state, code_verifier=flow.code_verifier)
# 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)
pending_auth = _load_pending_auth()
code, returned_state = _extract_code_and_state(code)
if returned_state and returned_state != pending_auth["state"]:
print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.")
sys.exit(1)
_ensure_deps()
from google_auth_oauthlib.flow import Flow
from urllib.parse import parse_qs, urlparse
# Extract granted scopes from the callback URL if present
if returned_state and "scope" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith("http") else {}):
granted_scopes = parse_qs(urlparse(code).query)["scope"][0].split()
else:
# Try to extract from code_or_url parameter
if isinstance(code, str) and code.startswith("http"):
params = parse_qs(urlparse(code).query)
if "scope" in params:
granted_scopes = params["scope"][0].split()
else:
granted_scopes = SCOPES
else:
granted_scopes = SCOPES
flow = Flow.from_client_secrets_file(
str(CLIENT_SECRET_PATH),
scopes=granted_scopes,
redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI),
state=pending_auth["state"],
code_verifier=pending_auth["code_verifier"],
)
try:
# Accept partial scopes — user may deselect some permissions in the consent screen
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
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_payload = json.loads(creds.to_json())
# Store only the scopes actually granted by the user, not what was requested.
# creds.to_json() writes the requested scopes, which causes refresh to fail
# with invalid_scope if the user only authorized a subset.
actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else []
if actually_granted:
token_payload["scopes"] = actually_granted
elif granted_scopes != SCOPES:
# granted_scopes was extracted from the callback URL
token_payload["scopes"] = granted_scopes
missing_scopes = _missing_scopes_from_payload(token_payload)
if missing_scopes:
print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")
print("Some services may not be available.")
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
PENDING_AUTH_PATH.unlink(missing_ok=True)
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
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)
PENDING_AUTH_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()