diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index e4553e425..fb9f00be2 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,35 +1,19 @@ --- name: google-workspace -description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via gws CLI (googleworkspace/cli). Uses OAuth2 with automatic token refresh via bridge script. Requires gws binary. -version: 2.0.0 +description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. +version: 1.0.0 author: Nous Research license: MIT -required_credential_files: - - path: google_token.json - description: Google OAuth2 token (created by setup script) - - path: google_client_secret.json - description: Google OAuth2 client credentials (downloaded from Google Cloud Console) metadata: hermes: - tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws] + 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 — powered by `gws` (Google's official Rust CLI). The skill provides a backward-compatible Python wrapper that handles OAuth token refresh and delegates to `gws`. - -## Architecture - -``` -google_api.py → gws_bridge.py → gws CLI -(argparse compat) (token refresh) (Google APIs) -``` - -- `setup.py` handles OAuth2 (headless-compatible, works on CLI/Telegram/Discord) -- `gws_bridge.py` refreshes the Hermes token and injects it into `gws` via `GOOGLE_WORKSPACE_CLI_TOKEN` -- `google_api.py` provides the same CLI interface as v1 but delegates to `gws` +Gmail, Calendar, Drive, Contacts, Sheets, and Docs — through Hermes-managed OAuth and a thin CLI wrapper. When `gws` is installed, the skill uses it as the execution backend for broader Google Workspace coverage; otherwise it falls back to the bundled Python client implementation. ## References @@ -38,22 +22,7 @@ google_api.py → gws_bridge.py → gws CLI ## Scripts - `scripts/setup.py` — OAuth2 setup (run once to authorize) -- `scripts/gws_bridge.py` — Token refresh bridge to gws CLI -- `scripts/google_api.py` — Backward-compatible API wrapper (delegates to gws) - -## Prerequisites - -Install `gws`: - -```bash -cargo install google-workspace-cli -# or via npm (recommended, downloads prebuilt binary): -npm install -g @googleworkspace/cli -# or via Homebrew: -brew install googleworkspace-cli -``` - -Verify: `gws --version` +- `scripts/google_api.py` — compatibility wrapper CLI. It prefers `gws` for operations when available, while preserving Hermes' existing JSON output contract. ## First-Time Setup @@ -63,13 +32,7 @@ on CLI, Telegram, Discord, or any platform. Define a shorthand first: ```bash -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" -PYTHON_BIN="${HERMES_PYTHON:-python3}" -if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then - PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" -fi -GSETUP="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/setup.py" +GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" ``` ### Step 0: Check if already set up @@ -82,88 +45,166 @@ 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** → Use the `himalaya` skill instead — simpler setup. -- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below. +- **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. -**Partial scopes**: Users can authorize only a subset of services. The setup -script accepts partial scopes and warns about missing ones. +- **Email + Calendar** → Continue with this skill, but use + `--services email,calendar` during auth so the consent screen only asks for + the scopes they actually need. -**Question 2: "Does your Google account use Advanced Protection?"** +- **Calendar/Drive/Sheets/Docs only** → Continue with this skill and use a + narrower `--services` set like `calendar,drive,sheets,docs`. -- **No / Not sure** → Normal setup. -- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first. +- **Full Workspace access** → Continue with this skill and use the default + `all` service set. + +**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: -> 1. Go to https://console.cloud.google.com/apis/credentials -> 2. Create a project (or use an existing one) -> 3. Enable the APIs you need (Gmail, Calendar, Drive, Sheets, Docs, People) -> 4. Credentials → Create Credentials → OAuth 2.0 Client ID → Desktop app -> 5. Download JSON and tell me the file path +> You need a Google Cloud OAuth client. This is a one-time setup: +> +> 1. Create or select a project: +> https://console.cloud.google.com/projectselector2/home/dashboard +> 2. Enable the required APIs from the API Library: +> https://console.cloud.google.com/apis/library +> Enable: Gmail API, Google Calendar API, Google Drive API, +> Google Sheets API, Google Docs API, People API +> 3. Create the OAuth client here: +> https://console.cloud.google.com/apis/credentials +> Credentials → Create Credentials → OAuth 2.0 Client ID +> 4. Application type: "Desktop app" → Create +> 5. If the app is still in Testing, add the user's Google account as a test user here: +> https://console.cloud.google.com/auth/audience +> Audience → Test users → Add users +> 6. Download the JSON file and tell me the file path +> +> Important Hermes CLI note: if the file path starts with `/`, do NOT send only the bare path as its own message in the CLI, because it can be mistaken for a slash command. Send it in a sentence instead, like: +> `The JSON file path is: /home/user/Downloads/client_secret_....json` + +Once they provide the path: ```bash $GSETUP --client-secret /path/to/client_secret.json ``` +If they paste the raw client ID / client secret values instead of a file path, +write a valid Desktop OAuth JSON file for them yourself, save it somewhere +explicit (for example `~/Downloads/hermes-google-client-secret.json`), then run +`--client-secret` against that file. + ### Step 3: Get authorization URL +Use the service set chosen in Step 1. Examples: + ```bash -$GSETUP --auth-url +$GSETUP --auth-url --services email,calendar --format json +$GSETUP --auth-url --services calendar,drive,sheets,docs --format json +$GSETUP --auth-url --services all --format json ``` -Send the URL to the user. After authorizing, they paste back the redirect URL or code. +This returns JSON with an `auth_url` field and also saves the exact URL to +`~/.hermes/google_oauth_last_url.txt`. + +Agent rules for this step: +- Extract the `auth_url` field and send that exact URL to the user as a single line. +- Tell the user that the browser will likely fail on `http://localhost:1` after approval, and that this is expected. +- Tell them to copy the ENTIRE redirected URL from the browser address bar. +- If the user gets `Error 403: access_denied`, send them directly to `https://console.cloud.google.com/auth/audience` to add themselves as a test user. ### 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. The `--auth-url` step stores a temporary +pending OAuth session locally so `--auth-code` can complete the PKCE exchange +later, even on headless systems: + ```bash -$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" +$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" --format json ``` +If `--auth-code` fails because the code expired, was already used, or came from +an older browser tab, it now returns a fresh `fresh_auth_url`. In that case, +immediately send the new URL to the user and have them retry with the newest +browser redirect only. + ### Step 5: Verify ```bash $GSETUP --check ``` -Should print `AUTHENTICATED`. Token refreshes automatically from now on. +Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on. + +### Notes + +- Token is stored at `~/.hermes/google_token.json` and auto-refreshes. +- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. +- If `gws` is installed, `google_api.py` points it at the same `~/.hermes/google_token.json` credentials file. Users do not need to run a separate `gws auth login` flow. +- To revoke: `$GSETUP --revoke` ## Usage -All commands go through the API script: +All commands go through the API script. Set `GAPI` as a shorthand: ```bash -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" -PYTHON_BIN="${HERMES_PYTHON:-python3}" -if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then - PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" -fi -GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py" +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 "

Q4

" --html +$GAPI gmail send --to user@example.com --subject "Report" --body "

Q4

Details...

" --html +$GAPI gmail send --to user@example.com --subject "Hello" --from '"Research Agent" ' --body "Message text" + +# Reply (automatically threads and sets In-Reply-To) $GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." +$GAPI gmail reply MESSAGE_ID --from '"Support Bot" ' --body "Thanks" + +# 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 create --summary "Standup" --start 2026-03-01T10:00:00+01:00 --end 2026-03-01T10:30:00+01:00 -$GAPI calendar create --summary "Review" --start ... --end ... --attendees "alice@co.com,bob@co.com" +$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 ``` @@ -183,8 +224,13 @@ $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"]]' ``` @@ -194,52 +240,37 @@ $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' $GAPI docs get DOC_ID ``` -### Direct gws access (advanced) - -For operations not covered by the wrapper, use `gws_bridge.py` directly: - -```bash -GBRIDGE="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/gws_bridge.py" -$GBRIDGE calendar +agenda --today --format table -$GBRIDGE gmail +triage --labels --format json -$GBRIDGE drive +upload ./report.pdf -$GBRIDGE sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:D10" -``` - ## Output Format -All commands return JSON via `gws --format json`. Key output shapes: +All commands return JSON. Parse with `jq` or read directly. Key fields: -- **Gmail search/triage**: Array of message summaries (sender, subject, date, snippet) -- **Gmail get/read**: Message object with headers and body text -- **Gmail send/reply**: Confirmation with message ID -- **Calendar list/agenda**: Array of event objects (summary, start, end, location) -- **Calendar create**: Confirmation with event ID and htmlLink -- **Drive search**: Array of file objects (id, name, mimeType, webViewLink) -- **Sheets get/read**: 2D array of cell values -- **Docs get**: Full document JSON (use `body.content` for text extraction) -- **Contacts list**: Array of person objects with names, emails, phones - -Parse output with `jq` or read JSON directly. +- **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.** -2. **Check auth before first use** — run `setup.py --check`. -3. **Use the Gmail search syntax reference** for complex queries. -4. **Calendar times must include timezone** — ISO 8601 with offset or UTC. -5. **Respect rate limits** — avoid rapid-fire sequential API calls. +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 | -| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 | -| `gws: command not found` | Install: `npm install -g @googleworkspace/cli` | -| `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 | -| `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console | -| Advanced Protection blocks auth | Admin must allowlist the OAuth client ID | +| `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 diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index ae8732f4b..5289539aa 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -1,17 +1,17 @@ #!/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. +Uses the Google Workspace CLI (`gws`) when available, but preserves the +existing Hermes-facing JSON contract and falls back to the Python client +libraries if `gws` is not installed. 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 list [--from DATE] [--to 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 @@ -21,47 +21,396 @@ Usage: """ import argparse +import base64 import json import os +import shutil import subprocess import sys +from datetime import datetime, timedelta, timezone +from email.mime.text import MIMEText from pathlib import Path -BRIDGE = Path(__file__).parent / "gws_bridge.py" -PYTHON = sys.executable +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", +] -def gws(*args: str) -> None: - """Call gws via the bridge and exit with its return code.""" +def _ensure_authenticated(): + 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) + + +def _stored_token_scopes() -> list[str]: + try: + data = json.loads(TOKEN_PATH.read_text()) + except Exception: + return list(SCOPES) + scopes = data.get("scopes") + if isinstance(scopes, list) and scopes: + return scopes + return list(SCOPES) + + +def _gws_binary() -> str | None: + override = os.getenv("HERMES_GWS_BIN") + if override: + return override + return shutil.which("gws") + + +def _gws_env() -> dict[str, str]: + env = os.environ.copy() + env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH) + return env + + +def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None = None): + binary = _gws_binary() + if not binary: + raise RuntimeError("gws not installed") + + _ensure_authenticated() + + cmd = [binary, *parts] + if params is not None: + cmd.extend(["--params", json.dumps(params)]) + if body is not None: + cmd.extend(["--json", json.dumps(body)]) + result = subprocess.run( - [PYTHON, str(BRIDGE)] + list(args), - env={**os.environ, "HERMES_HOME": os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))}, + cmd, + capture_output=True, + text=True, + env=_gws_env(), ) - sys.exit(result.returncode) + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() or "Unknown gws error" + print(err, file=sys.stderr) + sys.exit(result.returncode or 1) + + stdout = result.stdout.strip() + if not stdout: + return {} + + try: + return json.loads(stdout) + except json.JSONDecodeError: + print("ERROR: Unexpected non-JSON output from gws:", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(1) -# -- Gmail -- +def _headers_dict(msg: dict) -> dict[str, str]: + return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + + +def _extract_message_body(msg: dict) -> str: + 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 + return body + + +def _extract_doc_text(doc: dict) -> str: + 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"]) + return "".join(text_parts) + + +def _datetime_with_timezone(value: str) -> str: + if not value: + return value + if "T" not in value: + return value + if value.endswith("Z"): + return value + tail = value[10:] + if "+" in tail or "-" in tail: + return value + return value + "Z" + + +def get_credentials(): + """Load and refresh credentials from token file.""" + _ensure_authenticated() + + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + + creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), _stored_token_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): - cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"] - gws(*cmd) + if _gws_binary(): + results = _run_gws( + ["gmail", "users", "messages", "list"], + params={"userId": "me", "q": args.query, "maxResults": args.max}, + ) + messages = results.get("messages", []) + output = [] + for msg_meta in messages: + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": msg_meta["id"], + "format": "metadata", + "metadataHeaders": ["From", "To", "Subject", "Date"], + }, + ) + headers = _headers_dict(msg) + 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)) + return + + 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 = _headers_dict(msg) + 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): - gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json") + if _gws_binary(): + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={"userId": "me", "id": args.message_id, "format": "full"}, + ) + headers = _headers_dict(msg) + 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": _extract_message_body(msg), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + service = build_service("gmail", "v1") + msg = service.users().messages().get( + userId="me", id=args.message_id, format="full" + ).execute() + + headers = _headers_dict(msg) + 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": _extract_message_body(msg), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + + def gmail_send(args): - cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"] + if _gws_binary(): + 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 + if args.from_header: + message["from"] = args.from_header + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw} + if args.thread_id: + body["threadId"] = args.thread_id + + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body=body, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + + 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: - cmd += ["--cc", args.cc] - if args.html: - cmd.append("--html") - gws(*cmd) + message["cc"] = args.cc + if args.from_header: + message["from"] = args.from_header + + 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): - gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json") + if _gws_binary(): + original = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": args.message_id, + "format": "metadata", + "metadataHeaders": ["From", "Subject", "Message-ID"], + }, + ) + headers = _headers_dict(original) + + 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 args.from_header: + message["from"] = args.from_header + 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() + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body={"raw": raw, "threadId": original["threadId"]}, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + + service = build_service("gmail", "v1") + original = service.users().messages().get( + userId="me", id=args.message_id, format="metadata", + metadataHeaders=["From", "Subject", "Message-ID"], + ).execute() + headers = _headers_dict(original) + + 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 args.from_header: + message["from"] = args.from_header + 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): - gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json") + if _gws_binary(): + results = _run_gws(["gmail", "users", "labels", "list"], params={"userId": "me"}) + labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] + print(json.dumps(labels, indent=2)) + return + + 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): body = {} @@ -69,145 +418,310 @@ def gmail_modify(args): 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", - ) + + if _gws_binary(): + result = _run_gws( + ["gmail", "users", "messages", "modify"], + params={"userId": "me", "id": args.message_id}, + body=body, + ) + print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) + return + + service = build_service("gmail", "v1") + 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 -- +# ========================================================================= +# 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({ + now = datetime.now(timezone.utc) + time_min = _datetime_with_timezone(args.start or now.isoformat()) + time_max = _datetime_with_timezone(args.end or (now + timedelta(days=7)).isoformat()) + + if _gws_binary(): + results = _run_gws( + ["calendar", "events", "list"], + params={ "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) + 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)) + return + + service = build_service("calendar", "v3") + 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): - cmd = [ - "calendar", "+insert", - "--summary", args.summary, - "--start", args.start, - "--end", args.end, - "--format", "json", - ] + event = { + "summary": args.summary, + "start": {"dateTime": args.start}, + "end": {"dateTime": args.end}, + } if args.location: - cmd += ["--location", args.location] + event["location"] = args.location if args.description: - cmd += ["--description", args.description] + event["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) + event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",") if e.strip()] + + if _gws_binary(): + result = _run_gws( + ["calendar", "events", "insert"], + params={"calendarId": args.calendar}, + body=event, + ) + print(json.dumps({ + "status": "created", + "id": result["id"], + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + }, indent=2)) + return + + service = build_service("calendar", "v3") + 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): - gws( - "calendar", "events", "delete", - "--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}), - "--format", "json", - ) + if _gws_binary(): + _run_gws(["calendar", "events", "delete"], params={"calendarId": args.calendar, "eventId": args.event_id}) + print(json.dumps({"status": "deleted", "eventId": args.event_id})) + return + + 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 -- +# ========================================================================= +# 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", - ) + if _gws_binary(): + results = _run_gws( + ["drive", "files", "list"], + params={ + "q": query, + "pageSize": args.max, + "fields": "files(id, name, mimeType, modifiedTime, webViewLink)", + }, + ) + print(json.dumps(results.get("files", []), indent=2, ensure_ascii=False)) + return + + service = build_service("drive", "v3") + 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 -- +# ========================================================================= +# 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", - ) + if _gws_binary(): + results = _run_gws( + ["people", "people", "connections", "list"], + params={ + "resourceName": "people/me", + "pageSize": args.max, + "personFields": "names,emailAddresses,phoneNumbers", + }, + ) + 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)) + return + + 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 -- +# ========================================================================= +# Sheets +# ========================================================================= + def sheets_get(args): - gws( - "sheets", "+read", - "--spreadsheet", args.sheet_id, - "--range", args.range, - "--format", "json", - ) + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "get"], + params={"spreadsheetId": args.sheet_id, "range": args.range}, + ) + print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + return + + 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): 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", - ) + body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "update"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + return + + service = build_service("sheets", "v4") + 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): values = json.loads(args.values) - gws( - "sheets", "+append", - "--spreadsheet", args.sheet_id, - "--json-values", json.dumps(values), - "--format", "json", - ) + body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "append"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + "insertDataOption": "INSERT_ROWS", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) + return + + service = build_service("sheets", "v4") + 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 -- +# ========================================================================= +# Docs +# ========================================================================= + def docs_get(args): - gws( - "docs", "documents", "get", - "--params", json.dumps({"documentId": args.doc_id}), - "--format", "json", - ) + if _gws_binary(): + doc = _run_gws(["docs", "documents", "get"], params={"documentId": args.doc_id}) + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": _extract_doc_text(doc), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + service = build_service("docs", "v1") + doc = service.documents().get(documentId=args.doc_id).execute() + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": _extract_doc_text(doc), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) -# -- CLI parser (backward-compatible interface) -- +# ========================================================================= +# CLI parser +# ========================================================================= + def main(): - parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)") + parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent") sub = parser.add_subparsers(dest="service", required=True) # --- Gmail --- @@ -228,13 +742,15 @@ def main(): p.add_argument("--subject", required=True) p.add_argument("--body", required=True) p.add_argument("--cc", default="") + p.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" ')") 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.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.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" ')") p.set_defaults(func=gmail_reply) p = gmail_sub.add_parser("labels") diff --git a/website/docs/user-guide/skills/google-workspace.md b/website/docs/user-guide/skills/google-workspace.md new file mode 100644 index 000000000..920e6e260 --- /dev/null +++ b/website/docs/user-guide/skills/google-workspace.md @@ -0,0 +1,191 @@ +--- +sidebar_position: 2 +sidebar_label: "Google Workspace" +title: "Google Workspace — Gmail, Calendar, Drive, Sheets & Docs" +description: "Send email, manage calendar events, search Drive, read/write Sheets, and access Docs — all through OAuth2-authenticated Google APIs" +--- + +# Google Workspace Skill + +Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/nicholasgasior/gws) when available for broader coverage, and falls back to Google's Python client libraries otherwise. + +**Skill path:** `skills/productivity/google-workspace/` + +## Setup + +The setup is fully agent-driven — ask Hermes to set up Google Workspace and it walks you through each step. The flow: + +1. **Create a Google Cloud project** and enable the required APIs (Gmail, Calendar, Drive, Sheets, Docs, People) +2. **Create OAuth 2.0 credentials** (Desktop app type) and download the client secret JSON +3. **Authorize** — Hermes generates an auth URL, you approve in the browser, paste back the redirect URL +4. **Done** — token auto-refreshes from that point on + +:::tip Email-only users +If you only need email (no Calendar/Drive/Sheets), use the **himalaya** skill instead — it works with a Gmail App Password and takes 2 minutes. No Google Cloud project needed. +::: + +## Gmail + +### Searching + +```bash +$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" +``` + +Returns JSON with `id`, `from`, `subject`, `date`, `snippet`, and `labels` for each message. + +### Reading + +```bash +$GAPI gmail get MESSAGE_ID +``` + +Returns the full message body as text (prefers plain text, falls back to HTML). + +### Sending + +```bash +# Basic send +$GAPI gmail send --to user@example.com --subject "Hello" --body "Message text" + +# HTML email +$GAPI gmail send --to user@example.com --subject "Report" \ + --body "

Q4 Results

Details here

" --html + +# Custom From header (display name + email) +$GAPI gmail send --to user@example.com --subject "Hello" \ + --from '"Research Agent" ' --body "Message text" + +# With CC +$GAPI gmail send --to user@example.com --cc "team@example.com" \ + --subject "Update" --body "FYI" +``` + +### Custom From Header + +The `--from` flag lets you customize the sender display name on outgoing emails. This is useful when multiple agents share the same Gmail account but you want recipients to see different names: + +```bash +# Agent 1 +$GAPI gmail send --to client@co.com --subject "Research Summary" \ + --from '"Research Agent" ' --body "..." + +# Agent 2 +$GAPI gmail send --to client@co.com --subject "Code Review" \ + --from '"Code Assistant" ' --body "..." +``` + +**How it works:** The `--from` value is set as the RFC 5322 `From` header on the MIME message. Gmail allows customizing the display name on your own authenticated email address without any additional configuration. Recipients see the custom display name (e.g. "Research Agent") while the email address stays the same. + +**Important:** If you use a *different email address* in `--from` (not the authenticated account), Gmail requires that address to be configured as a [Send As alias](https://support.google.com/mail/answer/22370) in Gmail Settings → Accounts → Send mail as. + +The `--from` flag works on both `send` and `reply`: + +```bash +$GAPI gmail reply MESSAGE_ID \ + --from '"Support Bot" ' --body "We're on it" +``` + +### Replying + +```bash +$GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." +``` + +Automatically threads the reply (sets `In-Reply-To` and `References` headers) and uses the original message's thread ID. + +### Labels + +```bash +# List all labels +$GAPI gmail labels + +# Add/remove 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 (timezone required) +$GAPI calendar create --summary "Team Standup" \ + --start 2026-03-01T10:00:00-07:00 --end 2026-03-01T10:30:00-07:00 + +# With location and attendees +$GAPI calendar create --summary "Lunch" \ + --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z \ + --location "Cafe" --attendees "alice@co.com,bob@co.com" + +# Delete event +$GAPI calendar delete EVENT_ID +``` + +:::warning +Calendar times **must** include a timezone offset (e.g. `-07:00`) or use UTC (`Z`). Bare datetimes like `2026-03-01T10:00:00` are ambiguous and will be treated as UTC. +::: + +## Drive + +```bash +$GAPI drive search "quarterly report" --max 10 +$GAPI drive search "mimeType='application/pdf'" --raw-query --max 5 +``` + +## Sheets + +```bash +# Read a range +$GAPI sheets get SHEET_ID "Sheet1!A1:D10" + +# Write to a range +$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 +``` + +Returns the document title and full text content. + +## Contacts + +```bash +$GAPI contacts list --max 20 +``` + +## Output Format + +All commands return JSON. Key fields per service: + +| Command | 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`, `id`, `threadId` | +| `calendar list` | `id`, `summary`, `start`, `end`, `location`, `description`, `htmlLink` | +| `calendar create` | `status`, `id`, `summary`, `htmlLink` | +| `drive search` | `id`, `name`, `mimeType`, `modifiedTime`, `webViewLink` | +| `contacts list` | `name`, `emails`, `phones` | +| `sheets get` | 2D array of cell values | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `NOT_AUTHENTICATED` | Run setup (ask Hermes to set up Google Workspace) | +| `REFRESH_FAILED` | Token revoked — re-run authorization steps | +| `HttpError 403: Insufficient Permission` | Missing scope — revoke and re-authorize with the right services | +| `HttpError 403: Access Not Configured` | API not enabled in Google Cloud Console | +| `ModuleNotFoundError` | Run setup script with `--install-deps` | diff --git a/website/sidebars.ts b/website/sidebars.ts index 771bd07a7..a1633f229 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -92,6 +92,7 @@ const sidebars: SidebarsConfig = { label: 'Skills', items: [ 'user-guide/skills/godmode', + 'user-guide/skills/google-workspace', ], }, ], @@ -118,7 +119,6 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/wecom-callback', 'user-guide/messaging/weixin', 'user-guide/messaging/bluebubbles', - 'user-guide/messaging/qqbot', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], @@ -153,7 +153,6 @@ const sidebars: SidebarsConfig = { 'guides/use-voice-mode-with-hermes', 'guides/build-a-hermes-plugin', 'guides/automate-with-cron', - 'guides/automation-templates', 'guides/cron-troubleshooting', 'guides/work-with-skills', 'guides/delegation-patterns',