{"repo":"NousResearch/hermes-agent","public_id":"411","display_label":"Google Workspace skill expansion and CLI migration","summary":"These issues and pull requests concern migrating the Google Workspace skill to a CLI backend (gws) and adding new capabilities including Gmail attachments, Google Tasks, Google Slides, Docs/Drive creation, and service-account authentication.","subsystem":"skills","counts":{"members":8,"open_issues":4,"open_issues_distinct":4,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":6788},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-09T21:28:36+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15857142857142859},"fix_edges":[{"issue_number":411,"pr_number":6788,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":411,"title":"Feature: Migrate Google Workspace Skill to Official Google Workspace CLI (gws)","url":null,"state":"closed"},{"kind":"issue","number":9189,"title":"[Feature]:  Add Google Tasks tool to Google Workspace integration","url":null,"state":"open"},{"kind":"issue","number":15600,"title":"Feature: Add Google Slides support to google-workspace skill","url":null,"state":"open"},{"kind":"issue","number":17272,"title":"[Feature]: Service-account auth + Docs/Drive create endpoints in google-workspace skill (for autonomous deployments)","url":null,"state":"open"},{"kind":"issue","number":22872,"title":"productivity/google-workspace: CLI wrapper missing attachment download","url":null,"state":"open"},{"kind":"pr","number":6713,"title":"feat(skills): migrate google-workspace to gws CLI backend","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6788","number":6788}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":6788,"title":"feat(skills): migrate google-workspace to gws CLI backend","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["skills/productivity/google-workspace/SKILL.md","skills/productivity/google-workspace/scripts/google_api.py","skills/productivity/google-workspace/scripts/gws_bridge.py","skills/productivity/google-workspace/scripts/setup.py","tests/skills/test_google_oauth_setup.py","tests/skills/test_google_workspace_api.py"],"fix_hunks":[{"file":"skills/productivity/google-workspace/SKILL.md","hunks":["@@ -1,7 +1,7 @@\n ---\n name: google-workspace\n-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.\n-version: 1.0.0\n+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.\n+version: 2.0.0\n author: Nous Research\n license: MIT\n required_credential_files:","@@ -11,14 +11,25 @@ required_credential_files:\n     [redacted:secret-assignment] Google OAuth2 client credentials (downloaded from Google Cloud Console)\n metadata:\n   hermes:\n-    tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]\n+    tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws]\n     homepage: https://github.com/NousResearch/hermes-agent\n     related_skills: [himalaya]\n ---\n \n # Google Workspace\n \n-Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python scripts in this skill. No external binaries to install.\n+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`.\n+\n+## Architecture\n+\n+```\n+google_api.py  →  gws_bridge.py  →  gws CLI\n+(argparse compat)  (token refresh)    (Google APIs)\n+```\n+\n+- `setup.py` handles OAuth2 (headless-compatible, works on CLI/Telegram/Discord)\n+- `gws_bridge.py` refreshes the Hermes token and injects it into `gws` via `GOOGLE_WORKSPACE_CLI_TOKEN`\n+- `google_api.py` provides the same CLI interface as v1 but delegates to `gws`\n \n ## References\n ","@@ -27,7 +38,22 @@ Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python script\n ## Scripts\n \n - `scripts/setup.py` — OAuth2 setup (run once to authorize)\n-- `scripts/google_api.py` — API wrapper CLI (agent uses this for all operations)\n+- `scripts/gws_bridge.py` — Token refresh bridge to gws CLI\n+- `scripts/google_api.py` — Backward-compatible API wrapper (delegates to gws)\n+\n+## Prerequisites\n+\n+Install `gws`:\n+\n+```bash\n+cargo install google-workspace-cli\n+# or via npm (recommended, downloads prebuilt binary):\n+npm install -g @googleworkspace/cli\n+# or via Homebrew:\n+brew install googleworkspace-cli\n+```\n+\n+Verify: `gws --version`\n \n ## First-Time Setup\n ","@@ -56,42 +82,29 @@ If it prints `AUTHENTICATED`, skip to Usage — setup is already done.\n \n ### Step 1: Triage — ask the user what they need\n \n-Before starting OAuth setup, ask the user TWO questions:\n-\n **Question 1: \"What Google services do you need? Just email, or also\n Calendar/Drive/Sheets/Docs?\"**\n \n-- **Email only** → They don't need this skill at all. Use the `himalaya` skill\n-  instead — it works with a Gmail App Password (Settings → Security → App\n-  Passwords) and takes 2 minutes to set up. No Google Cloud project needed.\n-  Load the himalaya skill and follow its setup instructions.\n+- **Email only** → Use the `himalaya` skill instead — simpler setup.\n+- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below.\n \n-- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue with this\n-  skill's OAuth setup below.\n+**Partial scopes**: Users can authorize only a subset of services. The setup\n+script accepts partial scopes and warns about missing ones.\n \n-**Question 2: \"Does your Google account use Advanced Protection (hardware\n-security keys required to sign in)? If you're not sure, you probably don't\n-— it's something you would have explicitly enrolled in.\"**\n+**Question 2: \"Does your Google account use Advanced Protection?\"**\n \n-- **No / Not sure** → Normal setup. Continue below.\n-- **Yes** → Their Workspace admin must add the OAuth client ID to the org's\n-  allowed apps list before Step 4 will work. Let them know upfront.\n+- **No / Not sure** → Normal setup.\n+- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first.\n \n ### Step 2: Create OAuth credentials (one-time, ~5 minutes)\n \n Tell the user:\n \n-> You need a Google Cloud OAuth client. This is a one-time setup:\n->\n > 1. Go to https://console.cloud.google.com/apis/credentials\n > 2. Create a project (or use an existing one)\n-> 3. Click \"Enable APIs\" and enable: Gmail API, Google Calendar API,\n->    Google Drive API, Google Sheets API, Google Docs API, People API\n-> 4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID\n-> 5. Application type: \"Desktop app\" → Create\n-> 6. Click \"Download JSON\" and tell me the file path\n-\n-Once they provide the path:\n+> 3. Enable the APIs you need (Gmail, Calendar, Drive, Sheets, Docs, People)\n+> 4. Credentials → Create Credentials → OAuth 2.0 Client ID → Desktop app\n+> 5. Download JSON and tell me the file path\n \n ```bash\n $GSETUP --client-secret /path/to/client_secret.json","@@ -103,20 +116,10 @@ $GSETUP --client-secret /path/to/client_secret.json\n $GSETUP --auth-url\n ```\n \n-This prints a URL. **Send the URL to the user** and tell them:\n-\n-> Open this link in your browser, sign in with your Google account, and\n-> authorize access. After authorizing, you'll be redirected to a page that\n-> may show an error — that's expected. Copy the ENTIRE URL from your\n-> browser's address bar and paste it back to me.\n+Send the URL to the user. After authorizing, they paste back the redirect URL or code.\n \n ### Step 4: Exchange the code\n \n-The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...`\n-or just the code string. Either works. The `--auth-url` step stores a temporary\n-pending OAuth session locally so `--auth-code` can complete the PKCE exchange\n-later, even on headless systems:\n-\n ```bash\n $GSETUP --auth-code \"THE_URL_OR_CODE_THE_USER_PASTED\"\n ```","@@ -127,18 +130,11 @@ $GSETUP --auth-code \"THE_URL_OR_CODE_THE_USER_PASTED\"\n $GSETUP --check\n ```\n \n-Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on.\n-\n-### Notes\n-\n-- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes.\n-- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes.\n-- 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.\n-- To revoke: `$GSETUP --revoke`\n+Should print `AUTHENTICATED`. Token refreshes automatically from now on.\n \n ## Usage\n \n-All commands go through the API script. Set `GAPI` as a shorthand:\n+All commands go through the API script:\n \n ```bash\n HERMES_HOME=\"${HERMES_HOME:-$HOME/.hermes}\"","@@ -153,40 +149,21 @@ GAPI=\"$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py\"\n ### Gmail\n \n ```bash\n-# Search (returns JSON array with id, from, subject, date, snippet)\n $GAPI gmail search \"is:unread\" --max 10\n-$GAPI gmail search \"from:[redacted:email] newer_than:1d\"\n-$GAPI gmail search \"has:attachment filename:pdf newer_than:7d\"\n-\n-# Read full message (returns JSON with body text)\n $GAPI gmail get MESSAGE_ID\n-\n-# Send\n $GAPI gmail send --to [redacted:email] --subject \"Hello\" --body \"Message text\"\n-$GAPI gmail send --to [redacted:email] --subject \"Report\" --body \"<h1>Q4</h1><p>Details...</p>\" --html\n-\n-# Reply (automatically threads and sets In-Reply-To)\n+$GAPI gmail send --to [redacted:email] --subject \"Report\" --body \"<h1>Q4</h1>\" --html\n $GAPI gmail reply MESSAGE_ID --body \"Thanks, that works for me.\"\n-\n-# Labels\n $GAPI gmail labels\n $GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID\n-$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD\n ```\n \n ### Calendar\n \n ```bash\n-# List events (defaults to next 7 days)\n $GAPI calendar list\n-$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z\n-\n-# Create event (ISO 8601 with timezone required)\n-$GAPI calendar create --summary \"Team Standup\" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00\n-$GAPI calendar create --summary \"Lunch\" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location \"Cafe\"\n-$GAPI calendar create --summary \"Review\" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees \"[redacted:email],[redacted:email]\"\n-\n-# Delete event\n+$GAPI calendar create --summary \"Standup\" --start 2026-03-01T10:00:00+01:00 --end 2026-03-01T10:30:00+01:00\n+$GAPI calendar create --summary \"Review\" --start ... --end ... --attendees \"[redacted:email],[redacted:email]\"\n $GAPI calendar delete EVENT_ID\n ```\n ","@@ -206,13 +183,8 @@ $GAPI contacts list --max 20\n ### Sheets\n \n ```bash\n-# Read\n $GAPI sheets get SHEET_ID \"Sheet1!A1:D10\"\n-\n-# Write\n $GAPI sheets update SHEET_ID \"Sheet1!A1:B2\" --values '[[\"Name\",\"Score\"],[\"Alice\",\"95\"]]'\n-\n-# Append rows\n $GAPI sheets append SHEET_ID \"Sheet1!A:C\" --values '[[\"new\",\"row\",\"data\"]]'\n ```\n ","@@ -222,37 +194,52 @@ $GAPI sheets append SHEET_ID \"Sheet1!A:C\" --values '[[\"new\",\"row\",\"data\"]]'\n $GAPI docs get DOC_ID\n ```\n \n+### Direct gws access (advanced)\n+\n+For operations not covered by the wrapper, use `gws_bridge.py` directly:\n+\n+```bash\n+GBRIDGE=\"$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/gws_bridge.py\"\n+$GBRIDGE calendar +agenda --today --format table\n+$GBRIDGE gmail +triage --labels --format json\n+$GBRIDGE drive +upload ./report.pdf\n+$GBRIDGE sheets +read --spreadsheet SHEET_ID --range \"Sheet1!A1:D10\"\n+```\n+\n ## Output Format\n \n-All commands return JSON. Parse with `jq` or read directly. Key fields:\n+All commands return JSON via `gws --format json`. Key output shapes:\n+\n+- **Gmail search/triage**: Array of message summaries (sender, subject, date, snippet)\n+- **Gmail get/read**: Message object with headers and body text\n+- **Gmail send/reply**: Confirmation with message ID\n+- **Calendar list/agenda**: Array of event objects (summary, start, end, location)\n+- **Calendar create**: Confirmation with event ID and htmlLink\n+- **Drive search**: Array of file objects (id, name, mimeType, webViewLink)\n+- **Sheets get/read**: 2D array of cell values\n+- **Docs get**: Full document JSON (use `body.content` for text extraction)\n+- **Contacts list**: Array of person objects with names, emails, phones\n \n-- **Gmail search**: `[{id, threadId, from, to, subject, date, snippet, labels}]`\n-- **Gmail get**: `{id, threadId, from, to, subject, date, labels, body}`\n-- **Gmail send/reply**: `{status: \"sent\", id, threadId}`\n-- **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]`\n-- **Calendar create**: `{status: \"created\", id, summary, htmlLink}`\n-- **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]`\n-- **Contacts list**: `[{name, emails: [...], phones: [...]}]`\n-- **Sheets get**: `[[cell, cell, ...], ...]`\n+Parse output with `jq` or read JSON directly.\n \n ## Rules\n \n-1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval.\n-2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup.\n-3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view(\"google-workspace\", file_path=\"references/gmail-search-syntax.md\")`.\n-4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`).\n-5. **Respect rate limits** — avoid rapid-fire sequential API calls. Batch reads when possible.\n+1. **Never send email or create/delete events without confirming with the user first.**\n+2. **Check auth before first use** — run `setup.py --check`.\n+3. **Use the Gmail search syntax reference** for complex queries.\n+4. **Calendar times must include timezone** — ISO 8601 with offset or UTC.\n+5. **Respect rate limits** — avoid rapid-fire sequential API calls.\n \n ## Troubleshooting\n \n | Problem | Fix |\n |---------|-----|\n-| `NOT_AUTHENTICATED` | Run setup Steps 2-5 above |\n-| `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 |\n-| `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 |\n-| `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console |\n-| `ModuleNotFoundError` | Run `$GSETUP --install-deps` |\n-| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID |\n+| `NOT_AUTHENTICATED` | Run setup Steps 2-5 |\n+| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 |\n+| `gws: command not found` | Install: `npm install -g @googleworkspace/cli` |\n+| `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 |\n+| `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console |\n+| Advanced Protection blocks auth | Admin must allowlist the OAuth client ID |\n \n ## Revoking Access\n "]},{"file":"skills/productivity/google-workspace/scripts/google_api.py","hunks":["@@ -1,16 +1,17 @@\n #!/usr/bin/env python3\n \"\"\"Google Workspace API CLI for Hermes Agent.\n \n-A thin CLI wrapper around Google's Python client libraries.\n-Authenticates using the token stored by setup.py.\n+Thin wrapper that delegates to gws (googleworkspace/cli) via gws_bridge.py.\n+Maintains the same CLI interface for backward compatibility with Hermes skills.\n \n Usage:\n   python google_api.py gmail search \"is:unread\" [--max 10]\n   python google_api.py gmail get MESSAGE_ID\n   python google_api.py gmail send --to [redacted:email] --subject \"Hi\" --body \"Hello\"\n   python google_api.py gmail reply MESSAGE_ID --body \"Thanks\"\n-  python google_api.py calendar list [--from DATE] [--to DATE] [--calendar primary]\n+  python google_api.py calendar list [--start DATE] [--end DATE] [--calendar primary]\n   python google_api.py calendar create --summary \"Meeting\" --start DATETIME --end DATETIME\n+  python google_api.py calendar delete EVENT_ID\n   python google_api.py drive search \"budget report\" [--max 10]\n   python google_api.py contacts list [--max 20]\n   python google_api.py sheets get SHEET_ID RANGE","@@ -20,386 +21,193 @@\n \"\"\"\n \n import argparse\n-import base64\n import json\n+import os\n+import subprocess\n import sys\n-from datetime import datetime, timedelta, timezone\n-from email.mime.text import MIMEText\n from pathlib import Path\n \n-try:\n-    from hermes_constants import display_hermes_home, get_hermes_home\n-except ModuleNotFoundError:\n-    HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4]\n-    if HERMES_AGENT_ROOT.exists():\n-        sys.path.insert(0, str(HERMES_AGENT_ROOT))\n-    from hermes_constants import display_hermes_home, get_hermes_home\n-\n-HERMES_HOME = get_hermes_home()\n-TOKEN_PATH = [redacted:secret-assignment] / \"google_token.json\"\n-\n-SCOPES = [\n-    \"https://www.googleapis.com/auth/gmail.readonly\",\n-    \"https://www.googleapis.com/auth/gmail.send\",\n-    \"https://www.googleapis.com/auth/gmail.modify\",\n-    \"https://www.googleapis.com/auth/calendar\",\n-    \"https://www.googleapis.com/auth/drive.readonly\",\n-    \"https://www.googleapis.com/auth/contacts.readonly\",\n-    \"https://www.googleapis.com/auth/spreadsheets\",\n-    \"https://www.googleapis.com/auth/documents.readonly\",\n-]\n-\n-\n-def _missing_scopes() -> list[str]:\n-    try:\n-        payload = json.loads(TOKEN_PATH.read_text())\n-    except Exception:\n-        return []\n-    raw = payload.get(\"scopes\") or payload.get(\"scope\")\n-    if not raw:\n-        return []\n-    granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}\n-    return sorted(scope for scope in SCOPES if scope not in granted)\n-\n-\n-def get_credentials():\n-    \"\"\"Load and refresh credentials from token file.\"\"\"\n-    if not TOKEN_PATH.exists():\n-        print(\"Not authenticated. Run the setup script first:\", file=sys.stderr)\n-        print(f\"  python {Path(__file__).parent / 'setup.py'}\", file=sys.stderr)\n-        sys.exit(1)\n-\n-    from google.oauth2.credentials import Credentials\n-    from google.auth.transport.requests import Request\n-\n-    creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)\n-    if creds.expired and creds.refresh_token:\n-        creds.refresh(Request())\n-        TOKEN_PATH.write_text(creds.to_json())\n-    if not creds.valid:\n-        print(\"Token is invalid. Re-run setup.\", file=sys.stderr)\n-        sys.exit(1)\n-\n-    missing_scopes = _missing_scopes()\n-    if missing_scopes:\n-        print(\n-            \"Token is valid but missing Google Workspace scopes required by this skill.\",\n-            file=sys.stderr,\n-        )\n-        for scope in missing_scopes:\n-            print(f\"  - {scope}\", file=sys.stderr)\n-        print(\n-            f\"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.\",\n-            file=sys.stderr,\n-        )\n-        sys.exit(1)\n-    return creds\n+BRIDGE = Path(__file__).parent / \"gws_bridge.py\"\n+PYTHON = sys.executable\n \n \n-def build_service(api, version):\n-    from googleapiclient.discovery import build\n-    return build(api, version, credentials=[redacted:secret-assignment]\n+def gws(*args: str) -> None:\n+    \"\"\"Call gws via the bridge and exit with its return code.\"\"\"\n+    result = subprocess.run(\n+        [PYTHON, str(BRIDGE)] + list(args),\n+        env={**os.environ, \"HERMES_HOME\": os.environ.get(\"HERMES_HOME\", str(Path.home() / \".hermes\"))},\n+    )\n+    sys.exit(result.returncode)\n \n \n-# =========================================================================\n-# Gmail\n-# =========================================================================\n+# -- Gmail --\n \n def gmail_search(args):\n-    service = build_service(\"gmail\", \"v1\")\n-    results = service.users().messages().list(\n-        userId=\"me\", q=args.query, maxResults=args.max\n-    ).execute()\n-    messages = results.get(\"messages\", [])\n-    if not messages:\n-        print(\"No messages found.\")\n-        return\n-\n-    output = []\n-    for msg_meta in messages:\n-        msg = service.users().messages().get(\n-            userId=\"me\", id=msg_meta[\"id\"], format=\"metadata\",\n-            metadataHeaders=[\"From\", \"To\", \"Subject\", \"Date\"],\n-        ).execute()\n-        headers = {h[\"name\"]: h[\"value\"] for h in msg.get(\"payload\", {}).get(\"headers\", [])}\n-        output.append({\n-            \"id\": msg[\"id\"],\n-            \"threadId\": msg[\"threadId\"],\n-            \"from\": headers.get(\"From\", \"\"),\n-            \"to\": headers.get(\"To\", \"\"),\n-            \"subject\": headers.get(\"Subject\", \"\"),\n-            \"date\": headers.get(\"Date\", \"\"),\n-            \"snippet\": msg.get(\"snippet\", \"\"),\n-            \"labels\": msg.get(\"labelIds\", []),\n-        })\n-    print(json.dumps(output, indent=2, ensure_ascii=False))\n-\n+    cmd = [\"gmail\", \"+triage\", \"--query\", args.query, \"--max\", str(args.max), \"--format\", \"json\"]\n+    gws(*cmd)\n \n def gmail_get(args):\n-    service = build_service(\"gmail\", \"v1\")\n-    msg = service.users().messages().get(\n-        userId=\"me\", id=args.message_id, format=\"full\"\n-    ).execute()\n-\n-    headers = {h[\"name\"]: h[\"value\"] for h in msg.get(\"payload\", {}).get(\"headers\", [])}\n-\n-    # Extract body text\n-    body = \"\"\n-    payload = msg.get(\"payload\", {})\n-    if payload.get(\"body\", {}).get(\"data\"):\n-        body = base64.urlsafe_b64decode(payload[\"body\"][\"data\"]).decode(\"utf-8\", errors=\"replace\")\n-    elif payload.get(\"parts\"):\n-        for part in payload[\"parts\"]:\n-            if part.get(\"mimeType\") == \"text/plain\" and part.get(\"body\", {}).get(\"data\"):\n-                body = base64.urlsafe_b64decode(part[\"body\"][\"data\"]).decode(\"utf-8\", errors=\"replace\")\n-                break\n-        if not body:\n-            for part in payload[\"parts\"]:\n-                if part.get(\"mimeType\") == \"text/html\" and part.get(\"body\", {}).get(\"data\"):\n-                    body = base64.urlsafe_b64decode(part[\"body\"][\"data\"]).decode(\"utf-8\", errors=\"replace\")\n-                    break\n-\n-    result = {\n-        \"id\": msg[\"id\"],\n-        \"threadId\": msg[\"threadId\"],\n-        \"from\": headers.get(\"From\", \"\"),\n-        \"to\": headers.get(\"To\", \"\"),\n-        \"subject\": headers.get(\"Subject\", \"\"),\n-        \"date\": headers.get(\"Date\", \"\"),\n-        \"labels\": msg.get(\"labelIds\", []),\n-        \"body\": body,\n-    }\n-    print(json.dumps(result, indent=2, ensure_ascii=False))\n-\n+    gws(\"gmail\", \"+read\", \"--id\", args.message_id, \"--headers\", \"--format\", \"json\")\n \n def gmail_send(args):\n-    service = build_service(\"gmail\", \"v1\")\n-    message = MIMEText(args.body, \"html\" if args.html else \"plain\")\n-    message[\"to\"] = args.to\n-    message[\"subject\"] = args.subject\n+    cmd = [\"gmail\", \"+send\", \"--to\", args.to, \"--subject\", args.subject, \"--body\", args.body, \"--format\", \"json\"]\n     if args.cc:\n-        message[\"cc\"] = args.cc\n-\n-    raw = base64.urlsafe_b64encode(message.as_bytes()).decode()\n-    body = {\"raw\": raw}\n-\n-    if args.thread_id:\n-        body[\"threadId\"] = args.thread_id\n-\n-    result = service.users().messages().send(userId=\"me\", body=body).execute()\n-    print(json.dumps({\"status\": \"sent\", \"id\": result[\"id\"], \"threadId\": result.get(\"threadId\", \"\")}, indent=2))\n-\n+        cmd += [\"--cc\", args.cc]\n+    if args.html:\n+        cmd.append(\"--html\")\n+    gws(*cmd)\n \n def gmail_reply(args):\n-    service = build_service(\"gmail\", \"v1\")\n-    # Fetch original to get thread ID and headers\n-    original = service.users().messages().get(\n-        userId=\"me\", id=args.message_id, format=\"metadata\",\n-        metadataHeaders=[\"From\", \"Subject\", \"Message-ID\"],\n-    ).execute()\n-    headers = {h[\"name\"]: h[\"value\"] for h in original.get(\"payload\", {}).get(\"headers\", [])}\n-\n-    subject = headers.get(\"Subject\", \"\")\n-    if not subject.startswith(\"Re:\"):\n-        subject = f\"Re: {subject}\"\n-\n-    message = MIMEText(args.body)\n-    message[\"to\"] = headers.get(\"From\", \"\")\n-    message[\"subject\"] = subject\n-    if headers.get(\"Message-ID\"):\n-        message[\"In-Reply-To\"] = headers[\"Message-ID\"]\n-        message[\"References\"] = headers[\"Message-ID\"]\n-\n-    raw = base64.urlsafe_b64encode(message.as_bytes()).decode()\n-    body = {\"raw\": raw, \"threadId\": original[\"threadId\"]}\n-\n-    result = service.users().messages().send(userId=\"me\", body=body).execute()\n-    print(json.dumps({\"status\": \"sent\", \"id\": result[\"id\"], \"threadId\": result.get(\"threadId\", \"\")}, indent=2))\n-\n+    gws(\"gmail\", \"+reply\", \"--message-id\", args.message_id, \"--body\", args.body, \"--format\", \"json\")\n \n def gmail_labels(args):\n-    service = build_service(\"gmail\", \"v1\")\n-    results = service.users().labels().list(userId=\"me\").execute()\n-    labels = [{\"id\": l[\"id\"], \"name\": l[\"name\"], \"type\": l.get(\"type\", \"\")} for l in results.get(\"labels\", [])]\n-    print(json.dumps(labels, indent=2))\n-\n+    gws(\"gmail\", \"users\", \"labels\", \"list\", \"--params\", json.dumps({\"userId\": \"me\"}), \"--format\", \"json\")\n \n def gmail_modify(args):\n-    service = build_service(\"gmail\", \"v1\")\n     body = {}\n     if args.add_labels:\n         body[\"addLabelIds\"] = args.add_labels.split(\",\")\n     if args.remove_labels:\n         body[\"removeLabelIds\"] = args.remove_labels.split(\",\")\n-    result = service.users().messages().modify(userId=\"me\", id=args.message_id, body=body).execute()\n-    print(json.dumps({\"id\": result[\"id\"], \"labels\": result.get(\"labelIds\", [])}, indent=2))\n+    gws(\n+        \"gmail\", \"users\", \"messages\", \"modify\",\n+        \"--params\", json.dumps({\"userId\": \"me\", \"id\": args.message_id}),\n+        \"--json\", json.dumps(body),\n+        \"--format\", \"json\",\n+    )\n \n \n-# =========================================================================\n-# Calendar\n-# =========================================================================\n+# -- Calendar --\n \n def calendar_list(args):\n-    service = build_service(\"calendar\", \"v3\")\n-    now = datetime.now(timezone.utc)\n-    time_min = args.start or now.isoformat()\n-    time_max = args.end or (now + timedelta(days=7)).isoformat()\n-\n-    # Ensure timezone info\n-    for val in [time_min, time_max]:\n-        if \"T\" in val and \"Z\" not in val and \"+\" not in val and \"-\" not in val[11:]:\n-            val += \"Z\"\n-\n-    results = service.events().list(\n-        calendarId=args.calendar, timeMin=time_min, timeMax=time_max,\n-        maxResults=args.max, singleEvents=True, orderBy=\"startTime\",\n-    ).execute()\n-\n-    events = []\n-    for e in results.get(\"items\", []):\n-        events.append({\n-            \"id\": e[\"id\"],\n-            \"summary\": e.get(\"summary\", \"(no title)\"),\n-            \"start\": e.get(\"start\", {}).get(\"dateTime\", e.get(\"start\", {}).get(\"date\", \"\")),\n-            \"end\": e.get(\"end\", {}).get(\"dateTime\", e.get(\"end\", {}).get(\"date\", \"\")),\n-            \"location\": e.get(\"location\", \"\"),\n-            \"description\": e.get(\"description\", \"\"),\n-            \"status\": e.get(\"status\", \"\"),\n-            \"htmlLink\": e.get(\"htmlLink\", \"\"),\n-        })\n-    print(json.dumps(events, indent=2, ensure_ascii=False))\n-\n+    if args.start or args.end:\n+        # Specific date range — use raw Calendar API for precise timeMin/timeMax\n+        from datetime import datetime, timedelta, timezone as tz\n+        now = datetime.now(tz.utc)\n+        time_min = args.start or now.isoformat()\n+        time_max = args.end or (now + timedelta(days=7)).isoformat()\n+        gws(\n+            \"calendar\", \"events\", \"list\",\n+            \"--params\", json.dumps({\n+                \"calendarId\": args.calendar,\n+                \"timeMin\": time_min,\n+                \"timeMax\": time_max,\n+                \"maxResults\": args.max,\n+                \"singleEvents\": True,\n+                \"orderBy\": \"startTime\",\n+            }),\n+            \"--format\", \"json\",\n+        )\n+    else:\n+        # No date range — use +agenda helper (defaults to 7 days)\n+        cmd = [\"calendar\", \"+agenda\", \"--days\", \"7\", \"--format\", \"json\"]\n+        if args.calendar != \"primary\":\n+            cmd += [\"--calendar\", args.calendar]\n+        gws(*cmd)\n \n def calendar_create(args):\n-    service = build_service(\"calendar\", \"v3\")\n-    event = {\n-        \"summary\": args.summary,\n-        \"start\": {\"dateTime\": args.start},\n-        \"end\": {\"dateTime\": args.end},\n-    }\n+    cmd = [\n+        \"calendar\", \"+insert\",\n+        \"--summary\", args.summary,\n+        \"--start\", args.start,\n+        \"--end\", args.end,\n+        \"--format\", \"json\",\n+    ]\n     if args.location:\n-        event[\"location\"] = args.location\n+        cmd += [\"--location\", args.location]\n     if args.description:\n-        event[\"description\"] = args.description\n+        cmd += [\"--description\", args.description]\n     if args.attendees:\n-        event[\"attendees\"] = [{\"email\": e.strip()} for e in args.attendees.split(\",\")]\n-\n-    result = service.events().insert(calendarId=args.calendar, body=event).execute()\n-    print(json.dumps({\n-        \"status\": \"created\",\n-        \"id\": result[\"id\"],\n-        \"summary\": result.get(\"summary\", \"\"),\n-        \"htmlLink\": result.get(\"htmlLink\", \"\"),\n-    }, indent=2))\n-\n+        for email in args.attendees.split(\",\"):\n+            cmd += [\"--attendee\", email.strip()]\n+    if args.calendar != \"primary\":\n+        cmd += [\"--calendar\", args.calendar]\n+    gws(*cmd)\n \n def calendar_delete(args):\n-    service = build_service(\"calendar\", \"v3\")\n-    service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute()\n-    print(json.dumps({\"status\": \"deleted\", \"eventId\": args.event_id}))\n+    gws(\n+        \"calendar\", \"events\", \"delete\",\n+        \"--params\", json.dumps({\"calendarId\": args.calendar, \"eventId\": args.event_id}),\n+        \"--format\", \"json\",\n+    )\n \n \n-# =========================================================================\n-# Drive\n-# =========================================================================\n+# -- Drive --\n \n def drive_search(args):\n-    service = build_service(\"drive\", \"v3\")\n-    query = f\"fullText contains '{args.query}'\" if not args.raw_query else args.query\n-    results = service.files().list(\n-        q=query, pageSize=args.max, fields=\"files(id, name, mimeType, modifiedTime, webViewLink)\",\n-    ).execute()\n-    files = results.get(\"files\", [])\n-    print(json.dumps(files, indent=2, ensure_ascii=False))\n+    query = args.query if args.raw_query else f\"fullText contains '{args.query}'\"\n+    gws(\n+        \"drive\", \"files\", \"list\",\n+        \"--params\", json.dumps({\n+            \"q\": query,\n+            \"pageSize\": args.max,\n+            \"fields\": \"files(id,name,mimeType,modifiedTime,webViewLink)\",\n+        }),\n+        \"--format\", \"json\",\n+    )\n \n \n-# =========================================================================\n-# Contacts\n-# =========================================================================\n+# -- Contacts --\n \n def contacts_list(args):\n-    service = build_service(\"people\", \"v1\")\n-    results = service.people().connections().list(\n-        resourceName=\"people/me\",\n-        pageSize=args.max,\n-        personFields=\"names,emailAddresses,phoneNumbers\",\n-    ).execute()\n-    contacts = []\n-    for person in results.get(\"connections\", []):\n-        names = person.get(\"names\", [{}])\n-        emails = person.get(\"emailAddresses\", [])\n-        phones = person.get(\"phoneNumbers\", [])\n-        contacts.append({\n-            \"name\": names[0].get(\"displayName\", \"\") if names else \"\",\n-            \"emails\": [e.get(\"value\", \"\") for e in emails],\n-            \"phones\": [p.get(\"value\", \"\") for p in phones],\n-        })\n-    print(json.dumps(contacts, indent=2, ensure_ascii=False))\n-\n-\n-# =========================================================================\n-# Sheets\n-# =========================================================================\n+    gws(\n+        \"people\", \"people\", \"connections\", \"list\",\n+        \"--params\", json.dumps({\n+            \"resourceName\": \"people/me\",\n+            \"pageSize\": args.max,\n+            \"personFields\": \"names,emailAddresses,phoneNumbers\",\n+        }),\n+        \"--format\", \"json\",\n+    )\n \n-def sheets_get(args):\n-    service = build_service(\"sheets\", \"v4\")\n-    result = service.spreadsheets().values().get(\n-        spreadsheetId=args.sheet_id, range=args.range,\n-    ).execute()\n-    print(json.dumps(result.get(\"values\", []), indent=2, ensure_ascii=False))\n \n+# -- Sheets --\n+\n+def sheets_get(args):\n+    gws(\n+        \"sheets\", \"+read\",\n+        \"--spreadsheet\", args.sheet_id,\n+        \"--range\", args.range,\n+        \"--format\", \"json\",\n+    )\n \n def sheets_update(args):\n-    service = build_service(\"sheets\", \"v4\")\n     values = json.loads(args.values)\n-    body = {\"values\": values}\n-    result = service.spreadsheets().values().update(\n-        spreadsheetId=args.sheet_id, range=args.range,\n-        valueInputOption=\"USER_ENTERED\", body=body,\n-    ).execute()\n-    print(json.dumps({\"updatedCells\": result.get(\"updatedCells\", 0), \"updatedRange\": result.get(\"updatedRange\", \"\")}, indent=2))\n-\n+    gws(\n+        \"sheets\", \"spreadsheets\", \"values\", \"update\",\n+        \"--params\", json.dumps({\n+            \"spreadsheetId\": args.sheet_id,\n+            \"range\": args.range,\n+            \"valueInputOption\": \"USER_ENTERED\",\n+        }),\n+        \"--json\", json.dumps({\"values\": values}),\n+        \"--format\", \"json\",\n+    )\n \n def sheets_append(args):\n-    service = build_service(\"sheets\", \"v4\")\n     values = json.loads(args.values)\n-    body = {\"values\": values}\n-    result = service.spreadsheets().values().append(\n-        spreadsheetId=args.sheet_id, range=args.range,\n-        valueInputOption=\"USER_ENTERED\", insertDataOption=\"INSERT_ROWS\", body=body,\n-    ).execute()\n-    print(json.dumps({\"updatedCells\": result.get(\"updates\", {}).get(\"updatedCells\", 0)}, indent=2))\n+    gws(\n+        \"sheets\", \"+append\",\n+        \"--spreadsheet\", args.sheet_id,\n+        \"--json-values\", json.dumps(values),\n+        \"--format\", \"json\",\n+    )\n \n \n-# =========================================================================\n-# Docs\n-# =========================================================================\n+# -- Docs --\n \n def docs_get(args):\n-    service = build_service(\"docs\", \"v1\")\n-    doc = service.documents().get(documentId=args.doc_id).execute()\n-    # Extract plain text from the document structure\n-    text_parts = []\n-    for element in doc.get(\"body\", {}).get(\"content\", []):\n-        paragraph = element.get(\"paragraph\", {})\n-        for pe in paragraph.get(\"elements\", []):\n-            text_run = pe.get(\"textRun\", {})\n-            if text_run.get(\"content\"):\n-                text_parts.append(text_run[\"content\"])\n-    result = {\n-        \"title\": doc.get(\"title\", \"\"),\n-        \"documentId\": doc.get(\"documentId\", \"\"),\n-        \"body\": \"\".join(text_parts),\n-    }\n-    print(json.dumps(result, indent=2, ensure_ascii=False))\n-\n-\n-# =========================================================================\n-# CLI parser\n-# =========================================================================\n+    gws(\n+        \"docs\", \"documents\", \"get\",\n+        \"--params\", json.dumps({\"documentId\": args.doc_id}),\n+        \"--format\", \"json\",\n+    )\n+\n+\n+# -- CLI parser (backward-compatible interface) --\n \n def main():\n-    parser = argparse.ArgumentParser(description=\"Google Workspace API for Hermes Agent\")\n+    parser = argparse.ArgumentParser(description=\"Google Workspace API for Hermes Agent (gws backend)\")\n     sub = parser.add_subparsers(dest=\"service\", required=True)\n \n     # --- Gmail ---","@@ -421,7 +229,7 @@ def main():\n     p.add_argument(\"--body\", required=True)\n     p.add_argument(\"--cc\", default=\"\")\n     p.add_argument(\"--html\", action=\"store_true\", help=\"Send body as HTML\")\n-    p.add_argument(\"--thread-id\", default=\"\", help=\"Thread ID for threading\")\n+    p.add_argument(\"--thread-id\", default=\"\", help=\"Thread ID (unused with gws, kept for compat)\")\n     p.set_defaults(func=gmail_send)\n \n     p = gmail_sub.add_parser(\"reply\")"]},{"file":"skills/productivity/google-workspace/scripts/gws_bridge.py","hunks":["@@ -0,0 +1,89 @@\n+#!/usr/bin/env python3\n+\"\"\"Bridge between Hermes OAuth token and gws CLI.\n+\n+Refreshes the token if expired, then executes gws with the valid access token.\n+\"\"\"\n+import json\n+import os\n+import subprocess\n+import sys\n+from datetime import datetime, timezone\n+from pathlib import Path\n+\n+\n+def get_hermes_home() -> Path:\n+    return Path(os.environ.get(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+\n+\n+def get_token_path() -> Path:\n+    return get_hermes_home() / \"google_token.json\"\n+\n+\n+def refresh_token(token_data: dict) -> dict:\n+    \"\"\"Refresh the access token using the refresh token.\"\"\"\n+    import urllib.error\n+    import urllib.parse\n+    import urllib.request\n+\n+    params = urllib.parse.urlencode({\n+        \"client_id\": token_data[\"client_id\"],\n+        \"client_secret\": token_data[\"client_secret\"],\n+        \"refresh_token\": token_data[\"refresh_token\"],\n+        \"grant_type\": \"refresh_token\",\n+    }).encode()\n+\n+    req = urllib.request.Request(token_data[\"token_uri\"], data=params)\n+    try:\n+        with urllib.request.urlopen(req) as resp:\n+            result = json.loads(resp.read())\n+    except urllib.error.HTTPError as e:\n+        body = e.read().decode(\"utf-8\", errors=\"replace\")\n+        print(f\"ERROR: Token refresh failed (HTTP {e.code}): {body}\", file=sys.stderr)\n+        print(\"Re-run setup.py to re-authenticate.\", file=sys.stderr)\n+        sys.exit(1)\n+\n+    token_data[\"token\"] = result[\"access_token\"]\n+    token_data[\"expiry\"] = datetime.fromtimestamp(\n+        datetime.now(timezone.utc).timestamp() + result[\"expires_in\"],\n+        tz=timezone.utc,\n+    ).isoformat()\n+\n+    get_token_path().write_text(json.dumps(token_data, indent=2))\n+    return token_data\n+\n+\n+def get_valid_token() -> str:\n+    \"\"\"Return a valid access token, refreshing if needed.\"\"\"\n+    token_path = [redacted:secret-assignment]\n+    if not token_path.exists():\n+        print(\"ERROR: No Google token found. Run setup.py --auth-url first.\", file=sys.stderr)\n+        sys.exit(1)\n+\n+    token_data = [redacted:secret-assignment]\n+\n+    expiry = token_data.get(\"expiry\", \"\")\n+    if expiry:\n+        exp_dt = datetime.fromisoformat(expiry.replace(\"Z\", \"+00:00\"))\n+        now = datetime.now(timezone.utc)\n+        if now >= exp_dt:\n+            token_data = [redacted:secret-assignment]\n+\n+    return token_data[\"token\"]\n+\n+\n+def main():\n+    \"\"\"Refresh token if needed, then exec gws with remaining args.\"\"\"\n+    if len(sys.argv) < 2:\n+        print(\"Usage: gws_bridge.py <gws args...>\", file=sys.stderr)\n+        sys.exit(1)\n+\n+    access_token = [redacted:secret-assignment]\n+    env = os.environ.copy()\n+    env[\"GOOGLE_WORKSPACE_CLI_TOKEN\"] = access_token\n+\n+    result = subprocess.run([\"gws\"] + sys.argv[1:], env=env)\n+    sys.exit(result.returncode)\n+\n+\n+if __name__ == \"__main__\":\n+    main()"]},{"file":"skills/productivity/google-workspace/scripts/setup.py","hunks":["@@ -23,6 +23,7 @@\n \n import argparse\n import json\n+import os\n import subprocess\n import sys\n from pathlib import Path","@@ -128,7 +129,11 @@ def check_auth():\n     from google.auth.transport.requests import Request\n \n     try:\n-        creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)\n+        # Don't pass scopes — user may have authorized only a subset.\n+        # Passing scopes forces google-auth to validate them on refresh,\n+        # which fails with invalid_scope if the token has fewer scopes\n+        # than requested.\n+        creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))\n     except Exception as e:\n         print(f\"TOKEN_CORRUPT: {e}\")\n         return False","@@ -137,8 +142,9 @@ def check_auth():\n     if creds.valid:\n         missing_scopes = _missing_scopes_from_payload(payload)\n         if missing_scopes:\n-            print(f\"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}\")\n-            return False\n+            print(f\"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:\")\n+            for s in missing_scopes:\n+                print(f\"  - {s}\")\n         print(f\"AUTHENTICATED: Token valid at {TOKEN_PATH}\")\n         return True\n ","@@ -148,8 +154,9 @@ def check_auth():\n             TOKEN_PATH.write_text(creds.to_json())\n             missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))\n             if missing_scopes:\n-                print(f\"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}\")\n-                return False\n+                print(f\"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:\")\n+                for s in missing_scopes:\n+                    print(f\"  - {s}\")\n             print(f\"AUTHENTICATED: Token refreshed at {TOKEN_PATH}\")\n             return True\n         except Exception as e:","@@ -272,16 +279,33 @@ def exchange_auth_code(code: str):\n \n     _ensure_deps()\n     from google_auth_oauthlib.flow import Flow\n+    from urllib.parse import parse_qs, urlparse\n+\n+    # Extract granted scopes from the callback URL if present\n+    if returned_state and \"scope\" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith(\"http\") else {}):\n+        granted_scopes = parse_qs(urlparse(code).query)[\"scope\"][0].split()\n+    else:\n+        # Try to extract from code_or_url parameter\n+        if isinstance(code, str) and code.startswith(\"http\"):\n+            params = parse_qs(urlparse(code).query)\n+            if \"scope\" in params:\n+                granted_scopes = params[\"scope\"][0].split()\n+            else:\n+                granted_scopes = SCOPES\n+        else:\n+            granted_scopes = SCOPES\n \n     flow = Flow.from_client_secrets_file(\n         str(CLIENT_SECRET_PATH),\n-        scopes=SCOPES,\n+        scopes=granted_scopes,\n         redirect_uri=pending_auth.get(\"redirect_uri\", REDIRECT_URI),\n         state=pending_auth[\"state\"],\n         code_verifier=pending_auth[\"code_verifier\"],\n     )\n \n     try:\n+        # Accept partial scopes — user may deselect some permissions in the consent screen\n+        os.environ[\"OAUTHLIB_RELAX_TOKEN_SCOPE\"] = \"1\"\n         flow.fetch_token(code=code)\n     except Exception as e:\n         print(f\"ERROR: Token exchange failed: {e}\")","@@ -290,11 +314,21 @@ def exchange_auth_code(code: str):\n \n     creds = flow.credentials\n     token_payload = [redacted:secret-assignment]\n+\n+    # Store only the scopes actually granted by the user, not what was requested.\n+    # creds.to_json() writes the requested scopes, which causes refresh to fail\n+    # with invalid_scope if the user only authorized a subset.\n+    actually_granted = list(creds.granted_scopes or []) if hasattr(creds, \"granted_scopes\") and creds.granted_scopes else []\n+    if actually_granted:\n+        token_payload[\"scopes\"] = actually_granted\n+    elif granted_scopes != SCOPES:\n+        # granted_scopes was extracted from the callback URL\n+        token_payload[\"scopes\"] = granted_scopes\n+\n     missing_scopes = _missing_scopes_from_payload(token_payload)\n     if missing_scopes:\n-        print(f\"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}\")\n-        print(f\"Existing token at {TOKEN_PATH} was left unchanged.\")\n-        sys.exit(1)\n+        print(f\"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}\")\n+        print(\"Some services may not be available.\")\n \n     TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))\n     PENDING_AUTH_PATH.unlink(missing_ok=True)"]},{"file":"tests/skills/test_google_oauth_setup.py","hunks":["@@ -211,14 +211,15 @@ def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, caps\n         assert setup_module.PENDING_AUTH_PATH.exists()\n         assert not setup_module.TOKEN_PATH.exists()\n \n-    def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys):\n+    def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):\n+        \"\"\"Partial scopes are accepted with a warning (gws migration: v2.0).\"\"\"\n         setup_module.PENDING_AUTH_PATH.write_text(\n             json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n         )\n-        setup_module.TOKEN_PATH.write_text(json.dumps({\"token\": \"existing-token\", \"scopes\": setup_module.SCOPES}))\n+        setup_module.TOKEN_PATH.write_text(json.dumps({\"token\": \"***\", \"scopes\": setup_module.SCOPES}))\n         FakeFlow.credentials_payload = {\n-            \"token\": \"narrow-token\",\n-            \"refresh_token\": \"refresh-token\",\n+            \"token\": \"***\",\n+            \"refresh_token\": \"***\",\n             \"token_uri\": \"https://oauth2.googleapis.com/token\",\n             \"client_id\": \"client-id\",\n             \"client_secret\": \"client-secret\",","@@ -228,10 +229,12 @@ def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_mo\n             ],\n         }\n \n-        with pytest.raises(SystemExit):\n-            setup_module.exchange_auth_code(\"4/test-auth-code\")\n+        setup_module.exchange_auth_code(\"4/test-auth-code\")\n \n         out = capsys.readouterr().out\n-        assert \"refusing to save incomplete google workspace token\" in out.lower()\n-        assert json.loads(setup_module.TOKEN_PATH.read_text())[\"token\"] == \"existing-token\"\n-        assert setup_module.PENDING_AUTH_PATH.exists()\n+        assert \"warning\" in out.lower()\n+        assert \"missing\" in out.lower()\n+        # Token is saved (partial scopes accepted)\n+        assert setup_module.TOKEN_PATH.exists()\n+        # Pending auth is cleaned up\n+        assert not setup_module.PENDING_AUTH_PATH.exists()"]},{"file":"tests/skills/test_google_workspace_api.py","hunks":["@@ -1,117 +1,175 @@\n-\"\"\"Regression tests for Google Workspace API credential validation.\"\"\"\n+\"\"\"Tests for Google Workspace gws bridge and CLI wrapper.\"\"\"\n \n import importlib.util\n import json\n+import os\n+import subprocess\n import sys\n import types\n+from datetime import datetime, timedelta, timezone\n from pathlib import Path\n+from unittest.mock import MagicMock, patch\n \n import pytest\n \n \n-SCRIPT_PATH = (\n+BRIDGE_PATH = (\n+    Path(__file__).resolve().parents[2]\n+    / \"skills/productivity/google-workspace/scripts/gws_bridge.py\"\n+)\n+API_PATH = (\n     Path(__file__).resolve().parents[2]\n     / \"skills/productivity/google-workspace/scripts/google_api.py\"\n )\n \n \n-class FakeAuthorizedCredentials:\n-    def __init__(self, *, valid=True, expired=False, refresh_token=\"[redacted:secret-assignment]\"):\n-        self.valid = valid\n-        self.expired = expired\n-        self.refresh_token = [redacted:secret-assignment]\n-        self.refresh_calls = 0\n-\n-    def refresh(self, _request):\n-        self.refresh_calls += 1\n-        self.valid = True\n-        self.expired = False\n-\n-    def to_json(self):\n-        return json.dumps({\n-            \"token\": \"refreshed-token\",\n-            \"refresh_token\": self.refresh_token,\n-            \"token_uri\": \"https://oauth2.googleapis.com/token\",\n-            \"client_id\": \"client-id\",\n-            \"client_secret\": \"client-secret\",\n-            \"scopes\": [\n-                \"https://www.googleapis.com/auth/gmail.readonly\",\n-                \"https://www.googleapis.com/auth/gmail.send\",\n-                \"https://www.googleapis.com/auth/gmail.modify\",\n-                \"https://www.googleapis.com/auth/calendar\",\n-                \"https://www.googleapis.com/auth/drive.readonly\",\n-                \"https://www.googleapis.com/auth/contacts.readonly\",\n-                \"https://www.googleapis.com/auth/spreadsheets\",\n-                \"https://www.googleapis.com/auth/documents.readonly\",\n-            ],\n-        })\n-\n-\n-class FakeCredentialsFactory:\n-    creds = FakeAuthorizedCredentials()\n-\n-    @classmethod\n-    def from_authorized_user_file(cls, _path, _scopes):\n-        return cls.creds\n[redacted:email]\n+def bridge_module(monkeypatch, tmp_path):\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    spec = importlib.util.spec_from_file_location(\"gws_bridge_test\", BRIDGE_PATH)\n+    module = importlib.util.module_from_spec(spec)\n+    assert spec.loader is not None\n+    spec.loader.exec_module(module)\n+    return module\n \n \n @pytest.fixture\n-def google_api_module(monkeypatch, tmp_path):\n-    google_module = types.ModuleType(\"google\")\n-    oauth2_module = types.ModuleType(\"google.oauth2\")\n-    credentials_module = [redacted:secret-assignment]\"google.oauth2.credentials\")\n-    credentials_module.Credentials = [redacted:secret-assignment]\n-    auth_module = types.ModuleType(\"google.auth\")\n-    transport_module = types.ModuleType(\"google.auth.transport\")\n-    requests_module = types.ModuleType(\"google.auth.transport.requests\")\n-    requests_module.Request = object\n-\n-    monkeypatch.setitem(sys.modules, \"google\", google_module)\n-    monkeypatch.setitem(sys.modules, \"google.oauth2\", oauth2_module)\n-    monkeypatch.setitem(sys.modules, \"google.oauth2.credentials\", credentials_module)\n-    monkeypatch.setitem(sys.modules, \"google.auth\", auth_module)\n-    monkeypatch.setitem(sys.modules, \"google.auth.transport\", transport_module)\n-    monkeypatch.setitem(sys.modules, \"google.auth.transport.requests\", requests_module)\n-\n-    spec = importlib.util.spec_from_file_location(\"google_workspace_api_test\", SCRIPT_PATH)\n+def api_module(monkeypatch, tmp_path):\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    spec = importlib.util.spec_from_file_location(\"gws_api_test\", API_PATH)\n     module = importlib.util.module_from_spec(spec)\n     assert spec.loader is not None\n     spec.loader.exec_module(module)\n-\n-    monkeypatch.setattr(module, \"TOKEN_PATH\", tmp_path / \"google_token.json\")\n     return module\n \n \n-def _write_token(path: Path, scopes):\n-    path.write_text(json.dumps({\n-        \"token\": \"access-token\",\n-        \"refresh_token\": \"refresh-token\",\n+def _write_token(path: Path, *, token=\"[redacted:secret-assignment]\", expiry=None, **extra):\n+    data = {\n+        \"token\": token,\n+        \"refresh_token\": \"1//refresh\",\n+        \"client_id\": \"123.apps.googleusercontent.com\",\n+        \"client_secret\": \"secret\",\n         \"token_uri\": \"https://oauth2.googleapis.com/token\",\n-        \"client_id\": \"client-id\",\n-        \"client_secret\": \"client-secret\",\n-        \"scopes\": scopes,\n-    }))\n+        **extra,\n+    }\n+    if expiry is not None:\n+        data[\"expiry\"] = expiry\n+    path.write_text(json.dumps(data))\n+\n+\n+def test_bridge_returns_valid_token(bridge_module, tmp_path):\n+    \"\"\"Non-expired token is returned without refresh.\"\"\"\n+    future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()\n+    token_path = [redacted:secret-assignment]\n+    _write_token(token_path, token=\"[redacted:secret-assignment]\", expiry=future)\n+\n+    result = bridge_module.get_valid_token()\n+    assert result == \"ya29.valid\"\n+\n+\n+def test_bridge_refreshes_expired_token(bridge_module, tmp_path):\n+    \"\"\"Expired token triggers a refresh via token_uri.\"\"\"\n+    past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()\n+    token_path = [redacted:secret-assignment]\n+    _write_token(token_path, token=\"[redacted:secret-assignment]\", expiry=past)\n \n+    mock_resp = MagicMock()\n+    mock_resp.read.return_value = json.dumps({\n+        \"access_token\": \"ya29.refreshed\",\n+        \"expires_in\": 3600,\n+    }).encode()\n+    mock_resp.__enter__ = lambda s: s\n+    mock_resp.__exit__ = MagicMock(return_value=False)\n \n-def test_get_credentials_rejects_missing_scopes(google_api_module, capsys):\n-    FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)\n-    _write_token(google_api_module.TOKEN_PATH, [\n-        \"https://www.googleapis.com/auth/drive.readonly\",\n-        \"https://www.googleapis.com/auth/spreadsheets\",\n-    ])\n+    with patch(\"urllib.request.urlopen\", return_value=mock_resp):\n+        result = bridge_module.get_valid_token()\n \n+    assert result == \"ya29.refreshed\"\n+    # Verify persisted\n+    saved = json.loads(token_path.read_text())\n+    assert saved[\"token\"] == \"ya29.refreshed\"\n+\n+\n+def test_bridge_exits_on_missing_token(bridge_module):\n+    \"\"\"Missing token file causes exit with code 1.\"\"\"\n     with pytest.raises(SystemExit):\n-        google_api_module.get_credentials()\n+        bridge_module.get_valid_token()\n+\n+\n+def test_bridge_main_injects_token_env(bridge_module, tmp_path):\n+    \"\"\"main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env.\"\"\"\n+    future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()\n+    token_path = [redacted:secret-assignment]\n+    _write_token(token_path, token=\"[redacted:secret-assignment]\", expiry=future)\n+\n+    captured = {}\n+\n+    def capture_run(cmd, **kwargs):\n+        captured[\"cmd\"] = cmd\n+        captured[\"env\"] = kwargs.get(\"env\", {})\n+        return MagicMock(returncode=0)\n+\n+    with patch.object(sys, \"argv\", [\"gws_bridge.py\", \"gmail\", \"+triage\"]):\n+        with patch.object(subprocess, \"run\", side_effect=capture_run):\n+            with pytest.raises(SystemExit):\n+                bridge_module.main()\n+\n+    assert captured[\"env\"][\"GOOGLE_WORKSPACE_CLI_TOKEN\"] == \"ya29.injected\"\n+    assert captured[\"cmd\"] == [\"gws\", \"gmail\", \"+triage\"]\n+\n+\n+def test_api_calendar_list_uses_agenda_by_default(api_module):\n+    \"\"\"calendar list without dates uses +agenda helper.\"\"\"\n+    captured = {}\n+\n+    def capture_run(cmd, **kwargs):\n+        captured[\"cmd\"] = cmd\n+        return MagicMock(returncode=0)\n+\n+    args = api_module.argparse.Namespace(\n+        start=\"\", end=\"\", max=25, calendar=\"primary\", func=api_module.calendar_list,\n+    )\n+\n+    with patch.object(subprocess, \"run\", side_effect=capture_run):\n+        with pytest.raises(SystemExit):\n+            api_module.calendar_list(args)\n+\n+    gws_args = captured[\"cmd\"][2:]  # skip python + bridge path\n+    assert \"calendar\" in gws_args\n+    assert \"+agenda\" in gws_args\n+    assert \"--days\" in gws_args\n+\n \n-    err = capsys.readouterr().err\n-    assert \"missing google workspace scopes\" in err.lower()\n-    assert \"gmail.send\" in err\n+def test_api_calendar_list_respects_date_range(api_module):\n+    \"\"\"calendar list with --start/--end uses raw events list API.\"\"\"\n+    captured = {}\n \n+    def capture_run(cmd, **kwargs):\n+        captured[\"cmd\"] = cmd\n+        return MagicMock(returncode=0)\n \n-def test_get_credentials_accepts_full_scope_token(google_api_module):\n-    FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)\n-    _write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES))\n+    args = api_module.argparse.Namespace(\n+        start=\"2026-04-01T00:00:00Z\",\n+        end=\"2026-04-07T23:59:59Z\",\n+        max=25,\n+        calendar=\"primary\",\n+        func=api_module.calendar_list,\n+    )\n \n-    creds = google_api_module.get_credentials()\n+    with patch.object(subprocess, \"run\", side_effect=capture_run):\n+        with pytest.raises(SystemExit):\n+            api_module.calendar_list(args)\n \n-    assert creds is FakeCredentialsFactory.creds\n+    gws_args = captured[\"cmd\"][2:]\n+    assert \"events\" in gws_args\n+    assert \"list\" in gws_args\n+    params_idx = gws_args.index(\"--params\")\n+    params = json.loads(gws_args[params_idx + 1])\n+    assert params[\"timeMin\"] == \"2026-04-01T00:00:00Z\"\n+    assert params[\"timeMax\"] == \"2026-04-07T23:59:59Z\"\n"]}]},{"kind":"pr","number":23465,"title":"feat(google-workspace): add gmail attachment list/get CLI verbs (#22872)","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}