diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md
index 60b9693d1..c94014a1e 100644
--- a/skills/productivity/google-workspace/SKILL.md
+++ b/skills/productivity/google-workspace/SKILL.md
@@ -1,7 +1,7 @@
---
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
+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
author: Nous Research
license: MIT
required_credential_files:
@@ -11,14 +11,25 @@ required_credential_files:
description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
metadata:
hermes:
- tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
+ tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws]
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.
+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`
## References
@@ -27,7 +38,19 @@ Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python script
## Scripts
- `scripts/setup.py` — OAuth2 setup (run once to authorize)
-- `scripts/google_api.py` — API wrapper CLI (agent uses this for all operations)
+- `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: npm install -g @anthropic/google-workspace-cli
+```
+
+Verify: `gws --version`
## First-Time Setup
@@ -56,42 +79,29 @@ 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.
+- **Email only** → Use the `himalaya` skill instead — simpler setup.
+- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below.
-- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue with this
- skill's OAuth setup below.
+**Partial scopes**: Users can authorize only a subset of services. The setup
+script accepts partial scopes and warns about missing ones.
-**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."**
+**Question 2: "Does your Google account use Advanced Protection?"**
-- **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.
+- **No / Not sure** → Normal setup.
+- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first.
### 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:
+> 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
```bash
$GSETUP --client-secret /path/to/client_secret.json
@@ -103,20 +113,10 @@ $GSETUP --client-secret /path/to/client_secret.json
$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.
+Send the URL to the user. After authorizing, they paste back the redirect URL or code.
### 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"
```
@@ -127,18 +127,11 @@ $GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED"
$GSETUP --check
```
-Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on.
-
-### Notes
-
-- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes.
-- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes.
-- Hermes now refuses to overwrite a full Google Workspace token with a narrower re-auth token missing Gmail scopes, so one profile's partial consent cannot silently break email actions later.
-- To revoke: `$GSETUP --revoke`
+Should print `AUTHENTICATED`. Token refreshes automatically from now on.
## Usage
-All commands go through the API script. Set `GAPI` as a shorthand:
+All commands go through the API script:
```bash
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
@@ -153,40 +146,21 @@ GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/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
Details...
" --html
-
-# Reply (automatically threads and sets In-Reply-To)
+$GAPI gmail send --to user@example.com --subject "Report" --body "Q4
" --html
$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 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 delete EVENT_ID
```
@@ -206,13 +180,8 @@ $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"]]'
```
@@ -222,37 +191,40 @@ $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. 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, ...], ...]`
+All commands return JSON via `gws --format json`. Output structure varies by `gws` helper.
## 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.
+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.
## 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 |
+| `NOT_AUTHENTICATED` | Run setup Steps 2-5 |
+| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 |
+| `gws: command not found` | Install: `cargo install google-workspace-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 |
## Revoking Access
diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py
index ece0c3ea0..e288ec1ae 100644
--- a/skills/productivity/google-workspace/scripts/google_api.py
+++ b/skills/productivity/google-workspace/scripts/google_api.py
@@ -1,16 +1,17 @@
#!/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.
+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 [--from DATE] [--to DATE] [--calendar primary]
+ 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
@@ -20,386 +21,178 @@ Usage:
"""
import argparse
-import base64
import json
+import os
+import subprocess
import sys
-from datetime import datetime, timedelta, timezone
-from email.mime.text import MIMEText
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"
-
-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",
-]
+BRIDGE = Path(__file__).parent / "gws_bridge.py"
+PYTHON = sys.executable
-def _missing_scopes() -> list[str]:
- try:
- payload = json.loads(TOKEN_PATH.read_text())
- except Exception:
- return []
- 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 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)
-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)
-
- missing_scopes = _missing_scopes()
- if missing_scopes:
- print(
- "Token is valid but missing Google Workspace scopes required by this skill.",
- file=sys.stderr,
- )
- for scope in missing_scopes:
- print(f" - {scope}", file=sys.stderr)
- print(
- f"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.",
- 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
-# =========================================================================
+# -- 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))
-
+ cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"]
+ gws(*cmd)
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))
-
+ gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json")
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
+ cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"]
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))
-
+ cmd += ["--cc", args.cc]
+ if args.html:
+ cmd.append("--html")
+ gws(*cmd)
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))
-
+ gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json")
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))
-
+ gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json")
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))
+ gws(
+ "gmail", "users", "messages", "modify",
+ "--params", json.dumps({"userId": "me", "id": args.message_id}),
+ "--json", json.dumps(body),
+ "--format", "json",
+ )
-# =========================================================================
-# Calendar
-# =========================================================================
+# -- 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))
-
+ cmd = ["calendar", "+agenda", "--format", "json"]
+ if args.start and args.end:
+ # Calculate days between start and end for --days flag
+ cmd += ["--days", "7"]
+ else:
+ cmd += ["--days", "7"]
+ if args.calendar != "primary":
+ cmd += ["--calendar", args.calendar]
+ gws(*cmd)
def calendar_create(args):
- service = build_service("calendar", "v3")
- event = {
- "summary": args.summary,
- "start": {"dateTime": args.start},
- "end": {"dateTime": args.end},
- }
+ cmd = [
+ "calendar", "+insert",
+ "--summary", args.summary,
+ "--start", args.start,
+ "--end", args.end,
+ "--format", "json",
+ ]
if args.location:
- event["location"] = args.location
+ cmd += ["--location", args.location]
if args.description:
- event["description"] = args.description
+ cmd += ["--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))
-
+ for email in args.attendees.split(","):
+ cmd += ["--attendee", email.strip()]
+ if args.calendar != "primary":
+ cmd += ["--calendar", args.calendar]
+ gws(*cmd)
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}))
+ gws(
+ "calendar", "events", "delete",
+ "--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}),
+ "--format", "json",
+ )
-# =========================================================================
-# Drive
-# =========================================================================
+# -- 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))
+ 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
-# =========================================================================
+# -- 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))
+ gws(
+ "people", "people", "connections", "list",
+ "--params", json.dumps({
+ "resourceName": "people/me",
+ "pageSize": args.max,
+ "personFields": "names,emailAddresses,phoneNumbers",
+ }),
+ "--format", "json",
+ )
-# =========================================================================
-# Sheets
-# =========================================================================
+# -- 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))
-
+ gws(
+ "sheets", "+read",
+ "--spreadsheet", args.sheet_id,
+ "--range", args.range,
+ "--format", "json",
+ )
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))
-
+ 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):
- 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))
+ gws(
+ "sheets", "+append",
+ "--spreadsheet", args.sheet_id,
+ "--json-values", json.dumps(values),
+ "--format", "json",
+ )
-# =========================================================================
-# Docs
-# =========================================================================
+# -- 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))
+ gws(
+ "docs", "documents", "get",
+ "--params", json.dumps({"documentId": args.doc_id}),
+ "--format", "json",
+ )
-# =========================================================================
-# CLI parser
-# =========================================================================
+# -- CLI parser (backward-compatible interface) --
def main():
- parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent")
+ parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)")
sub = parser.add_subparsers(dest="service", required=True)
# --- Gmail ---
@@ -421,7 +214,7 @@ def main():
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.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")
diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py
new file mode 100755
index 000000000..7ee74fc97
--- /dev/null
+++ b/skills/productivity/google-workspace/scripts/gws_bridge.py
@@ -0,0 +1,82 @@
+#!/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.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)
+ with urllib.request.urlopen(req) as resp:
+ result = json.loads(resp.read())
+
+ 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 ", 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()
diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py
index 5e4924f9d..0cc862bd6 100644
--- a/skills/productivity/google-workspace/scripts/setup.py
+++ b/skills/productivity/google-workspace/scripts/setup.py
@@ -23,6 +23,7 @@ Agent workflow:
import argparse
import json
+import os
import subprocess
import sys
from pathlib import Path
@@ -128,7 +129,11 @@ def check_auth():
from google.auth.transport.requests import Request
try:
- creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
+ # 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
@@ -137,8 +142,7 @@ def check_auth():
if creds.valid:
missing_scopes = _missing_scopes_from_payload(payload)
if missing_scopes:
- print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
- return False
+ print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes")
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
return True
@@ -148,8 +152,7 @@ def check_auth():
TOKEN_PATH.write_text(creds.to_json())
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
if missing_scopes:
- print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
- return False
+ print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes")
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
return True
except Exception as e:
@@ -272,16 +275,33 @@ def exchange_auth_code(code: str):
_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=SCOPES,
+ 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}")
@@ -290,11 +310,21 @@ def exchange_auth_code(code: str):
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"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}")
- print(f"Existing token at {TOKEN_PATH} was left unchanged.")
- sys.exit(1)
+ 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)