diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md new file mode 100644 index 000000000..77374d2e8 --- /dev/null +++ b/skills/productivity/google-workspace/SKILL.md @@ -0,0 +1,240 @@ +--- +name: google-workspace +description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via Python. Uses OAuth2 with automatic token refresh. No external binaries needed — runs entirely with Google's Python client libraries in the Hermes venv. +version: 1.0.0 +author: Nous Research +license: MIT +metadata: + hermes: + tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth] + homepage: https://github.com/NousResearch/hermes-agent + related_skills: [himalaya] +--- + +# Google Workspace + +Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python scripts in this skill. No external binaries to install. + +## References + +- `references/gmail-search-syntax.md` — Gmail search operators (is:unread, from:, newer_than:, etc.) + +## Scripts + +- `scripts/setup.py` — OAuth2 setup (run once to authorize) +- `scripts/google_api.py` — API wrapper CLI (agent uses this for all operations) + +## First-Time Setup + +The setup is fully non-interactive — you drive it step by step so it works +on CLI, Telegram, Discord, or any platform. + +Define a shorthand first: + +```bash +GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" +``` + +### Step 0: Check if already set up + +```bash +$GSETUP --check +``` + +If it prints `AUTHENTICATED`, skip to Usage — setup is already done. + +### Step 1: Triage — ask the user what they need + +Before starting OAuth setup, ask the user TWO questions: + +**Question 1: "What Google services do you need? Just email, or also +Calendar/Drive/Sheets/Docs?"** + +- **Email only** → They don't need this skill at all. Use the `himalaya` skill + instead — it works with a Gmail App Password (Settings → Security → App + Passwords) and takes 2 minutes to set up. No Google Cloud project needed. + Load the himalaya skill and follow its setup instructions. + +- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue with this + skill's OAuth setup below. + +**Question 2: "Does your Google account use Advanced Protection (hardware +security keys required to sign in)? If you're not sure, you probably don't +— it's something you would have explicitly enrolled in."** + +- **No / Not sure** → Normal setup. Continue below. +- **Yes** → Their Workspace admin must add the OAuth client ID to the org's + allowed apps list before Step 4 will work. Let them know upfront. + +### Step 2: Create OAuth credentials (one-time, ~5 minutes) + +Tell the user: + +> You need a Google Cloud OAuth client. This is a one-time setup: +> +> 1. Go to https://console.cloud.google.com/apis/credentials +> 2. Create a project (or use an existing one) +> 3. Click "Enable APIs" and enable: Gmail API, Google Calendar API, +> Google Drive API, Google Sheets API, Google Docs API, People API +> 4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID +> 5. Application type: "Desktop app" → Create +> 6. Click "Download JSON" and tell me the file path + +Once they provide the path: + +```bash +$GSETUP --client-secret /path/to/client_secret.json +``` + +### Step 3: Get authorization URL + +```bash +$GSETUP --auth-url +``` + +This prints a URL. **Send the URL to the user** and tell them: + +> Open this link in your browser, sign in with your Google account, and +> authorize access. After authorizing, you'll be redirected to a page that +> may show an error — that's expected. Copy the ENTIRE URL from your +> browser's address bar and paste it back to me. + +### Step 4: Exchange the code + +The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...` +or just the code string. Either works: + +```bash +$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" +``` + +### Step 5: Verify + +```bash +$GSETUP --check +``` + +Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on. + +### Notes + +- Token is stored at `~/.hermes/google_token.json` and auto-refreshes. +- To revoke: `$GSETUP --revoke` + +## Usage + +All commands go through the API script. Set `GAPI` as a shorthand: + +```bash +GAPI="python ~/.hermes/skills/productivity/google-workspace/scripts/google_api.py" +``` + +### Gmail + +```bash +# Search (returns JSON array with id, from, subject, date, snippet) +$GAPI gmail search "is:unread" --max 10 +$GAPI gmail search "from:boss@company.com newer_than:1d" +$GAPI gmail search "has:attachment filename:pdf newer_than:7d" + +# Read full message (returns JSON with body text) +$GAPI gmail get MESSAGE_ID + +# Send +$GAPI gmail send --to user@example.com --subject "Hello" --body "Message text" +$GAPI gmail send --to user@example.com --subject "Report" --body "
Details...
" --html + +# Reply (automatically threads and sets In-Reply-To) +$GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." + +# Labels +$GAPI gmail labels +$GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID +$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD +``` + +### Calendar + +```bash +# List events (defaults to next 7 days) +$GAPI calendar list +$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z + +# Create event (ISO 8601 with timezone required) +$GAPI calendar create --summary "Team Standup" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00 +$GAPI calendar create --summary "Lunch" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location "Cafe" +$GAPI calendar create --summary "Review" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees "alice@co.com,bob@co.com" + +# Delete event +$GAPI calendar delete EVENT_ID +``` + +### Drive + +```bash +$GAPI drive search "quarterly report" --max 10 +$GAPI drive search "mimeType='application/pdf'" --raw-query --max 5 +``` + +### Contacts + +```bash +$GAPI contacts list --max 20 +``` + +### Sheets + +```bash +# Read +$GAPI sheets get SHEET_ID "Sheet1!A1:D10" + +# Write +$GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]' + +# Append rows +$GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' +``` + +### Docs + +```bash +$GAPI docs get DOC_ID +``` + +## Output Format + +All commands return JSON. Parse with `jq` or read directly. Key fields: + +- **Gmail search**: `[{id, threadId, from, to, subject, date, snippet, labels}]` +- **Gmail get**: `{id, threadId, from, to, subject, date, labels, body}` +- **Gmail send/reply**: `{status: "sent", id, threadId}` +- **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]` +- **Calendar create**: `{status: "created", id, summary, htmlLink}` +- **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]` +- **Contacts list**: `[{name, emails: [...], phones: [...]}]` +- **Sheets get**: `[[cell, cell, ...], ...]` + +## Rules + +1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval. +2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup. +3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view("google-workspace", file_path="references/gmail-search-syntax.md")`. +4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`). +5. **Respect rate limits** — avoid rapid-fire sequential API calls. Batch reads when possible. + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `NOT_AUTHENTICATED` | Run setup Steps 2-5 above | +| `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 | +| `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 | +| `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console | +| `ModuleNotFoundError` | Run `$GSETUP --install-deps` | +| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID | + +## Revoking Access + +```bash +$GSETUP --revoke +``` diff --git a/skills/productivity/google-workspace/references/gmail-search-syntax.md b/skills/productivity/google-workspace/references/gmail-search-syntax.md new file mode 100644 index 000000000..f66234679 --- /dev/null +++ b/skills/productivity/google-workspace/references/gmail-search-syntax.md @@ -0,0 +1,63 @@ +# Gmail Search Syntax + +Standard Gmail search operators work in the `query` argument. + +## Common Operators + +| Operator | Example | Description | +|----------|---------|-------------| +| `is:unread` | `is:unread` | Unread messages | +| `is:starred` | `is:starred` | Starred messages | +| `is:important` | `is:important` | Important messages | +| `in:inbox` | `in:inbox` | Inbox only | +| `in:sent` | `in:sent` | Sent folder | +| `in:drafts` | `in:drafts` | Drafts | +| `in:trash` | `in:trash` | Trash | +| `in:anywhere` | `in:anywhere` | All mail including spam/trash | +| `from:` | `from:alice@example.com` | Sender | +| `to:` | `to:bob@example.com` | Recipient | +| `cc:` | `cc:team@example.com` | CC recipient | +| `subject:` | `subject:invoice` | Subject contains | +| `label:` | `label:work` | Has label | +| `has:attachment` | `has:attachment` | Has attachments | +| `filename:` | `filename:pdf` | Attachment filename/type | +| `larger:` | `larger:5M` | Larger than size | +| `smaller:` | `smaller:1M` | Smaller than size | + +## Date Operators + +| Operator | Example | Description | +|----------|---------|-------------| +| `newer_than:` | `newer_than:7d` | Within last N days (d), months (m), years (y) | +| `older_than:` | `older_than:30d` | Older than N days/months/years | +| `after:` | `after:2026/02/01` | After date (YYYY/MM/DD) | +| `before:` | `before:2026/03/01` | Before date | + +## Combining + +| Syntax | Example | Description | +|--------|---------|-------------| +| space | `from:alice subject:meeting` | AND (implicit) | +| `OR` | `from:alice OR from:bob` | OR | +| `-` | `-from:noreply@` | NOT (exclude) | +| `()` | `(from:alice OR from:bob) subject:meeting` | Grouping | +| `""` | `"exact phrase"` | Exact phrase match | + +## Common Patterns + +``` +# Unread emails from the last day +is:unread newer_than:1d + +# Emails with PDF attachments from a specific sender +from:accounting@company.com has:attachment filename:pdf + +# Important unread emails (not promotions/social) +is:unread -category:promotions -category:social + +# Emails in a thread about a topic +subject:"Q4 budget" newer_than:30d + +# Large attachments to clean up +has:attachment larger:10M older_than:90d +``` diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py new file mode 100644 index 000000000..19c1159d2 --- /dev/null +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Google Workspace API CLI for Hermes Agent. + +A thin CLI wrapper around Google's Python client libraries. +Authenticates using the token stored by setup.py. + +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 [--from DATE] [--to DATE] [--calendar primary] + python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME + 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 base64 +import json +import os +import sys +from datetime import datetime, timedelta, timezone +from email.mime.text import MIMEText +from pathlib import Path + +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +TOKEN_PATH = HERMES_HOME / "google_token.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", +] + + +def get_credentials(): + """Load and refresh credentials from token file.""" + if not TOKEN_PATH.exists(): + print("Not authenticated. Run the setup script first:", file=sys.stderr) + print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr) + sys.exit(1) + + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + + creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES) + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + TOKEN_PATH.write_text(creds.to_json()) + if not creds.valid: + print("Token is invalid. Re-run setup.", file=sys.stderr) + sys.exit(1) + return creds + + +def build_service(api, version): + from googleapiclient.discovery import build + return build(api, version, credentials=get_credentials()) + + +# ========================================================================= +# Gmail +# ========================================================================= + +def gmail_search(args): + service = build_service("gmail", "v1") + results = service.users().messages().list( + userId="me", q=args.query, maxResults=args.max + ).execute() + messages = results.get("messages", []) + if not messages: + print("No messages found.") + return + + output = [] + for msg_meta in messages: + msg = service.users().messages().get( + userId="me", id=msg_meta["id"], format="metadata", + metadataHeaders=["From", "To", "Subject", "Date"], + ).execute() + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + output.append({ + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + "labels": msg.get("labelIds", []), + }) + print(json.dumps(output, indent=2, ensure_ascii=False)) + + +def gmail_get(args): + service = build_service("gmail", "v1") + msg = service.users().messages().get( + userId="me", id=args.message_id, format="full" + ).execute() + + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + + # Extract body text + body = "" + payload = msg.get("payload", {}) + if payload.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace") + elif payload.get("parts"): + for part in payload["parts"]: + if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + if not body: + for part in payload["parts"]: + if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + + result = { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "labels": msg.get("labelIds", []), + "body": body, + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +def gmail_send(args): + service = build_service("gmail", "v1") + message = MIMEText(args.body, "html" if args.html else "plain") + message["to"] = args.to + message["subject"] = args.subject + if args.cc: + message["cc"] = args.cc + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw} + + if args.thread_id: + body["threadId"] = args.thread_id + + result = service.users().messages().send(userId="me", body=body).execute() + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + + +def gmail_reply(args): + service = build_service("gmail", "v1") + # Fetch original to get thread ID and headers + original = service.users().messages().get( + userId="me", id=args.message_id, format="metadata", + metadataHeaders=["From", "Subject", "Message-ID"], + ).execute() + headers = {h["name"]: h["value"] for h in original.get("payload", {}).get("headers", [])} + + subject = headers.get("Subject", "") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + + message = MIMEText(args.body) + message["to"] = headers.get("From", "") + message["subject"] = subject + if headers.get("Message-ID"): + message["In-Reply-To"] = headers["Message-ID"] + message["References"] = headers["Message-ID"] + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw, "threadId": original["threadId"]} + + result = service.users().messages().send(userId="me", body=body).execute() + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + + +def gmail_labels(args): + service = build_service("gmail", "v1") + results = service.users().labels().list(userId="me").execute() + labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] + print(json.dumps(labels, indent=2)) + + +def gmail_modify(args): + service = build_service("gmail", "v1") + body = {} + if args.add_labels: + body["addLabelIds"] = args.add_labels.split(",") + if args.remove_labels: + body["removeLabelIds"] = args.remove_labels.split(",") + result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute() + print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) + + +# ========================================================================= +# Calendar +# ========================================================================= + +def calendar_list(args): + service = build_service("calendar", "v3") + now = datetime.now(timezone.utc) + time_min = args.start or now.isoformat() + time_max = args.end or (now + timedelta(days=7)).isoformat() + + # Ensure timezone info + for val in [time_min, time_max]: + if "T" in val and "Z" not in val and "+" not in val and "-" not in val[11:]: + val += "Z" + + results = service.events().list( + calendarId=args.calendar, timeMin=time_min, timeMax=time_max, + maxResults=args.max, singleEvents=True, orderBy="startTime", + ).execute() + + events = [] + for e in results.get("items", []): + events.append({ + "id": e["id"], + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")), + "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")), + "location": e.get("location", ""), + "description": e.get("description", ""), + "status": e.get("status", ""), + "htmlLink": e.get("htmlLink", ""), + }) + print(json.dumps(events, indent=2, ensure_ascii=False)) + + +def calendar_create(args): + service = build_service("calendar", "v3") + event = { + "summary": args.summary, + "start": {"dateTime": args.start}, + "end": {"dateTime": args.end}, + } + if args.location: + event["location"] = args.location + if args.description: + event["description"] = args.description + if args.attendees: + event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",")] + + result = service.events().insert(calendarId=args.calendar, body=event).execute() + print(json.dumps({ + "status": "created", + "id": result["id"], + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + }, indent=2)) + + +def calendar_delete(args): + service = build_service("calendar", "v3") + service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute() + print(json.dumps({"status": "deleted", "eventId": args.event_id})) + + +# ========================================================================= +# Drive +# ========================================================================= + +def drive_search(args): + service = build_service("drive", "v3") + query = f"fullText contains '{args.query}'" if not args.raw_query else args.query + results = service.files().list( + q=query, pageSize=args.max, fields="files(id, name, mimeType, modifiedTime, webViewLink)", + ).execute() + files = results.get("files", []) + print(json.dumps(files, indent=2, ensure_ascii=False)) + + +# ========================================================================= +# Contacts +# ========================================================================= + +def contacts_list(args): + service = build_service("people", "v1") + results = service.people().connections().list( + resourceName="people/me", + pageSize=args.max, + personFields="names,emailAddresses,phoneNumbers", + ).execute() + contacts = [] + for person in results.get("connections", []): + names = person.get("names", [{}]) + emails = person.get("emailAddresses", []) + phones = person.get("phoneNumbers", []) + contacts.append({ + "name": names[0].get("displayName", "") if names else "", + "emails": [e.get("value", "") for e in emails], + "phones": [p.get("value", "") for p in phones], + }) + print(json.dumps(contacts, indent=2, ensure_ascii=False)) + + +# ========================================================================= +# Sheets +# ========================================================================= + +def sheets_get(args): + service = build_service("sheets", "v4") + result = service.spreadsheets().values().get( + spreadsheetId=args.sheet_id, range=args.range, + ).execute() + print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + + +def sheets_update(args): + service = build_service("sheets", "v4") + values = json.loads(args.values) + body = {"values": values} + result = service.spreadsheets().values().update( + spreadsheetId=args.sheet_id, range=args.range, + valueInputOption="USER_ENTERED", body=body, + ).execute() + print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + + +def sheets_append(args): + service = build_service("sheets", "v4") + values = json.loads(args.values) + body = {"values": values} + result = service.spreadsheets().values().append( + spreadsheetId=args.sheet_id, range=args.range, + valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body, + ).execute() + print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) + + +# ========================================================================= +# Docs +# ========================================================================= + +def docs_get(args): + service = build_service("docs", "v1") + doc = service.documents().get(documentId=args.doc_id).execute() + # Extract plain text from the document structure + text_parts = [] + for element in doc.get("body", {}).get("content", []): + paragraph = element.get("paragraph", {}) + for pe in paragraph.get("elements", []): + text_run = pe.get("textRun", {}) + if text_run.get("content"): + text_parts.append(text_run["content"]) + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": "".join(text_parts), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +# ========================================================================= +# CLI parser +# ========================================================================= + +def main(): + parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent") + 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 for threading") + 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() diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py new file mode 100644 index 000000000..44a5a097f --- /dev/null +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -0,0 +1,261 @@ +#!/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()