diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 00d91de909e..453662540f6 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. version: 1.0.0 author: Nous Research license: MIT @@ -13,7 +13,7 @@ metadata: # 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 — through Hermes-managed OAuth and a thin CLI wrapper. When `gws` is installed, the skill uses it as the execution backend for broader Google Workspace coverage; otherwise it falls back to the bundled Python client implementation. ## References @@ -22,7 +22,7 @@ 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/google_api.py` — compatibility wrapper CLI. It prefers `gws` for operations when available, while preserving Hermes' existing JSON output contract. ## First-Time Setup @@ -122,6 +122,7 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall - Token is stored at `~/.hermes/google_token.json` and auto-refreshes. - Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. +- If `gws` is installed, `google_api.py` points it at the same `~/.hermes/google_token.json` credentials file. Users do not need to run a separate `gws auth login` flow. - To revoke: `$GSETUP --revoke` ## Usage diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 19c1159d264..13affb849d0 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -1,8 +1,9 @@ #!/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. +Uses the Google Workspace CLI (`gws`) when available, but preserves the +existing Hermes-facing JSON contract and falls back to the Python client +libraries if `gws` is not installed. Usage: python google_api.py gmail search "is:unread" [--max 10] @@ -23,6 +24,8 @@ import argparse import base64 import json import os +import shutil +import subprocess import sys from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText @@ -30,6 +33,7 @@ from pathlib import Path HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) TOKEN_PATH = HERMES_HOME / "google_token.json" +CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", @@ -43,13 +47,112 @@ SCOPES = [ ] -def get_credentials(): - """Load and refresh credentials from token file.""" +def _ensure_authenticated(): if not TOKEN_PATH.exists(): print("Not authenticated. Run the setup script first:", file=sys.stderr) print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr) sys.exit(1) + +def _gws_binary() -> str | None: + override = os.getenv("HERMES_GWS_BIN") + if override: + return override + return shutil.which("gws") + + +def _gws_env() -> dict[str, str]: + env = os.environ.copy() + env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH) + return env + + +def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None = None): + binary = _gws_binary() + if not binary: + raise RuntimeError("gws not installed") + + _ensure_authenticated() + + cmd = [binary, *parts] + if params is not None: + cmd.extend(["--params", json.dumps(params)]) + if body is not None: + cmd.extend(["--json", json.dumps(body)]) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=_gws_env(), + ) + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() or "Unknown gws error" + print(err, file=sys.stderr) + sys.exit(result.returncode or 1) + + stdout = result.stdout.strip() + if not stdout: + return {} + + try: + return json.loads(stdout) + except json.JSONDecodeError: + print("ERROR: Unexpected non-JSON output from gws:", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(1) + + +def _headers_dict(msg: dict) -> dict[str, str]: + return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + + +def _extract_message_body(msg: dict) -> str: + body = "" + payload = msg.get("payload", {}) + if payload.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace") + elif payload.get("parts"): + for part in payload["parts"]: + if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + if not body: + for part in payload["parts"]: + if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + return body + + +def _extract_doc_text(doc: dict) -> str: + text_parts = [] + for element in doc.get("body", {}).get("content", []): + paragraph = element.get("paragraph", {}) + for pe in paragraph.get("elements", []): + text_run = pe.get("textRun", {}) + if text_run.get("content"): + text_parts.append(text_run["content"]) + return "".join(text_parts) + + +def _datetime_with_timezone(value: str) -> str: + if not value: + return value + if "T" not in value: + return value + if value.endswith("Z"): + return value + tail = value[10:] + if "+" in tail or "-" in tail: + return value + return value + "Z" + + +def get_credentials(): + """Load and refresh credentials from token file.""" + _ensure_authenticated() + from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request @@ -65,6 +168,7 @@ def get_credentials(): def build_service(api, version): from googleapiclient.discovery import build + return build(api, version, credentials=get_credentials()) @@ -72,7 +176,41 @@ def build_service(api, version): # Gmail # ========================================================================= + def gmail_search(args): + if _gws_binary(): + results = _run_gws( + ["gmail", "users", "messages", "list"], + params={"userId": "me", "q": args.query, "maxResults": args.max}, + ) + messages = results.get("messages", []) + output = [] + for msg_meta in messages: + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": msg_meta["id"], + "format": "metadata", + "metadataHeaders": ["From", "To", "Subject", "Date"], + }, + ) + headers = _headers_dict(msg) + output.append( + { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + "labels": msg.get("labelIds", []), + } + ) + print(json.dumps(output, indent=2, ensure_ascii=False)) + return + service = build_service("gmail", "v1") results = service.users().messages().list( userId="me", q=args.query, maxResults=args.max @@ -88,7 +226,7 @@ def gmail_search(args): 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", [])} + headers = _headers_dict(msg) output.append({ "id": msg["id"], "threadId": msg["threadId"], @@ -102,30 +240,33 @@ def gmail_search(args): print(json.dumps(output, indent=2, ensure_ascii=False)) + def gmail_get(args): + if _gws_binary(): + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={"userId": "me", "id": args.message_id, "format": "full"}, + ) + headers = _headers_dict(msg) + result = { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "labels": msg.get("labelIds", []), + "body": _extract_message_body(msg), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + service = build_service("gmail", "v1") msg = service.users().messages().get( userId="me", id=args.message_id, format="full" ).execute() - headers = {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 - + headers = _headers_dict(msg) result = { "id": msg["id"], "threadId": msg["threadId"], @@ -134,12 +275,33 @@ def gmail_get(args): "subject": headers.get("Subject", ""), "date": headers.get("Date", ""), "labels": msg.get("labelIds", []), - "body": body, + "body": _extract_message_body(msg), } print(json.dumps(result, indent=2, ensure_ascii=False)) + def gmail_send(args): + if _gws_binary(): + message = MIMEText(args.body, "html" if args.html else "plain") + message["to"] = args.to + message["subject"] = args.subject + if args.cc: + message["cc"] = args.cc + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw} + if args.thread_id: + body["threadId"] = args.thread_id + + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body=body, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + service = build_service("gmail", "v1") message = MIMEText(args.body, "html" if args.html else "plain") message["to"] = args.to @@ -157,14 +319,46 @@ def gmail_send(args): print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + def gmail_reply(args): + if _gws_binary(): + original = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": args.message_id, + "format": "metadata", + "metadataHeaders": ["From", "Subject", "Message-ID"], + }, + ) + headers = _headers_dict(original) + + subject = headers.get("Subject", "") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + + message = MIMEText(args.body) + message["to"] = headers.get("From", "") + message["subject"] = subject + if headers.get("Message-ID"): + message["In-Reply-To"] = headers["Message-ID"] + message["References"] = headers["Message-ID"] + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body={"raw": raw, "threadId": original["threadId"]}, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + service = build_service("gmail", "v1") - # 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", [])} + headers = _headers_dict(original) subject = headers.get("Subject", "") if not subject.startswith("Re:"): @@ -184,20 +378,38 @@ def gmail_reply(args): print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + def gmail_labels(args): + if _gws_binary(): + results = _run_gws(["gmail", "users", "labels", "list"], params={"userId": "me"}) + labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] + print(json.dumps(labels, indent=2)) + return + service = build_service("gmail", "v1") results = service.users().labels().list(userId="me").execute() labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] print(json.dumps(labels, indent=2)) + def gmail_modify(args): - 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(",") + + if _gws_binary(): + result = _run_gws( + ["gmail", "users", "messages", "modify"], + params={"userId": "me", "id": args.message_id}, + body=body, + ) + print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) + return + + service = build_service("gmail", "v1") result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute() print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) @@ -206,17 +418,40 @@ def gmail_modify(args): # 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() + time_min = _datetime_with_timezone(args.start or now.isoformat()) + time_max = _datetime_with_timezone(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" + if _gws_binary(): + results = _run_gws( + ["calendar", "events", "list"], + params={ + "calendarId": args.calendar, + "timeMin": time_min, + "timeMax": time_max, + "maxResults": args.max, + "singleEvents": True, + "orderBy": "startTime", + }, + ) + events = [] + for e in results.get("items", []): + events.append({ + "id": e["id"], + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")), + "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")), + "location": e.get("location", ""), + "description": e.get("description", ""), + "status": e.get("status", ""), + "htmlLink": e.get("htmlLink", ""), + }) + print(json.dumps(events, indent=2, ensure_ascii=False)) + return + service = build_service("calendar", "v3") results = service.events().list( calendarId=args.calendar, timeMin=time_min, timeMax=time_max, maxResults=args.max, singleEvents=True, orderBy="startTime", @@ -237,8 +472,8 @@ def calendar_list(args): print(json.dumps(events, indent=2, ensure_ascii=False)) + def calendar_create(args): - service = build_service("calendar", "v3") event = { "summary": args.summary, "start": {"dateTime": args.start}, @@ -249,8 +484,23 @@ def calendar_create(args): if args.description: event["description"] = args.description if args.attendees: - event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",")] + event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",") if e.strip()] + if _gws_binary(): + result = _run_gws( + ["calendar", "events", "insert"], + params={"calendarId": args.calendar}, + body=event, + ) + print(json.dumps({ + "status": "created", + "id": result["id"], + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + }, indent=2)) + return + + service = build_service("calendar", "v3") result = service.events().insert(calendarId=args.calendar, body=event).execute() print(json.dumps({ "status": "created", @@ -260,7 +510,13 @@ def calendar_create(args): }, indent=2)) + def calendar_delete(args): + if _gws_binary(): + _run_gws(["calendar", "events", "delete"], params={"calendarId": args.calendar, "eventId": args.event_id}) + print(json.dumps({"status": "deleted", "eventId": args.event_id})) + return + service = build_service("calendar", "v3") service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute() print(json.dumps({"status": "deleted", "eventId": args.event_id})) @@ -270,9 +526,22 @@ def calendar_delete(args): # Drive # ========================================================================= + def drive_search(args): + query = args.query if args.raw_query else f"fullText contains '{args.query}'" + if _gws_binary(): + results = _run_gws( + ["drive", "files", "list"], + params={ + "q": query, + "pageSize": args.max, + "fields": "files(id, name, mimeType, modifiedTime, webViewLink)", + }, + ) + print(json.dumps(results.get("files", []), indent=2, ensure_ascii=False)) + return + service = build_service("drive", "v3") - 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() @@ -284,7 +553,30 @@ def drive_search(args): # Contacts # ========================================================================= + def contacts_list(args): + if _gws_binary(): + results = _run_gws( + ["people", "people", "connections", "list"], + params={ + "resourceName": "people/me", + "pageSize": args.max, + "personFields": "names,emailAddresses,phoneNumbers", + }, + ) + contacts = [] + for person in results.get("connections", []): + names = person.get("names", [{}]) + emails = person.get("emailAddresses", []) + phones = person.get("phoneNumbers", []) + contacts.append({ + "name": names[0].get("displayName", "") if names else "", + "emails": [e.get("value", "") for e in emails], + "phones": [p.get("value", "") for p in phones], + }) + print(json.dumps(contacts, indent=2, ensure_ascii=False)) + return + service = build_service("people", "v1") results = service.people().connections().list( resourceName="people/me", @@ -308,7 +600,16 @@ def contacts_list(args): # Sheets # ========================================================================= + def sheets_get(args): + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "get"], + params={"spreadsheetId": args.sheet_id, "range": args.range}, + ) + print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + return + service = build_service("sheets", "v4") result = service.spreadsheets().values().get( spreadsheetId=args.sheet_id, range=args.range, @@ -316,10 +617,25 @@ def sheets_get(args): print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + def sheets_update(args): - service = build_service("sheets", "v4") values = json.loads(args.values) body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "update"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + return + + service = build_service("sheets", "v4") result = service.spreadsheets().values().update( spreadsheetId=args.sheet_id, range=args.range, valueInputOption="USER_ENTERED", body=body, @@ -327,10 +643,26 @@ def sheets_update(args): print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + def sheets_append(args): - service = build_service("sheets", "v4") values = json.loads(args.values) body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "append"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + "insertDataOption": "INSERT_ROWS", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) + return + + service = build_service("sheets", "v4") result = service.spreadsheets().values().append( spreadsheetId=args.sheet_id, range=args.range, valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body, @@ -342,21 +674,24 @@ def sheets_append(args): # Docs # ========================================================================= + def docs_get(args): + if _gws_binary(): + doc = _run_gws(["docs", "documents", "get"], params={"documentId": args.doc_id}) + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": _extract_doc_text(doc), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + service = build_service("docs", "v1") doc = service.documents().get(documentId=args.doc_id).execute() - # 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), + "body": _extract_doc_text(doc), } print(json.dumps(result, indent=2, ensure_ascii=False)) @@ -365,6 +700,7 @@ def docs_get(args): # CLI parser # ========================================================================= + def main(): parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent") sub = parser.add_subparsers(dest="service", required=True) diff --git a/tests/skills/test_google_api_cli.py b/tests/skills/test_google_api_cli.py new file mode 100644 index 00000000000..97b2e839557 --- /dev/null +++ b/tests/skills/test_google_api_cli.py @@ -0,0 +1,202 @@ +"""Tests for the Google Workspace skill CLI wrapper. + +These focus on the hybrid backend: prefer the Google Workspace CLI (`gws`) when +available, while preserving the existing Hermes-facing JSON contract. +""" + +import importlib.util +import json +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/google_api.py" +) + + +def _load_module(): + spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _completed(stdout: str = "", stderr: str = "", returncode: int = 0): + return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=returncode) + + +@pytest.fixture +def google_api_module(tmp_path, monkeypatch): + module = _load_module() + token_path = tmp_path / "google_token.json" + token_path.write_text( + json.dumps( + { + "token": "access-token", + "refresh_token": "refresh-token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + } + ) + ) + monkeypatch.setattr(module, "TOKEN_PATH", token_path) + monkeypatch.setattr(module, "_gws_binary", lambda: "/usr/bin/gws", raising=False) + monkeypatch.setattr( + module, + "build_service", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("legacy backend should not be used")), + ) + return module + + +def test_gmail_search_uses_gws_and_normalizes_results(google_api_module, monkeypatch, capsys): + calls = [] + + def fake_run(cmd, capture_output, text, env): + calls.append({"cmd": cmd, "env": env}) + if cmd[1:4] == ["gmail", "users", "messages"] and cmd[4] == "list": + assert json.loads(cmd[6]) == {"userId": "me", "q": "is:unread", "maxResults": 5} + return _completed( + json.dumps({"messages": [{"id": "msg-1", "threadId": "thread-1"}]}) + ) + if cmd[1:4] == ["gmail", "users", "messages"] and cmd[4] == "get": + params = json.loads(cmd[6]) + assert params["id"] == "msg-1" + return _completed( + json.dumps( + { + "id": "msg-1", + "threadId": "thread-1", + "payload": { + "headers": [ + {"name": "From", "value": "alice@example.com"}, + {"name": "To", "value": "bob@example.com"}, + {"name": "Subject", "value": "Hello"}, + {"name": "Date", "value": "Sat, 15 Mar 2026 10:00:00 +0000"}, + ] + }, + "snippet": "preview", + "labelIds": ["UNREAD"], + } + ) + ) + raise AssertionError(f"Unexpected command: {cmd}") + + monkeypatch.setattr(google_api_module.subprocess, "run", fake_run) + + google_api_module.gmail_search(Namespace(query="is:unread", max=5)) + + out = json.loads(capsys.readouterr().out) + assert out == [ + { + "id": "msg-1", + "threadId": "thread-1", + "from": "alice@example.com", + "to": "bob@example.com", + "subject": "Hello", + "date": "Sat, 15 Mar 2026 10:00:00 +0000", + "snippet": "preview", + "labels": ["UNREAD"], + } + ] + assert calls[0]["env"]["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] == str(google_api_module.TOKEN_PATH) + + +def test_calendar_create_uses_gws_insert_and_normalizes_result(google_api_module, monkeypatch, capsys): + def fake_run(cmd, capture_output, text, env): + assert cmd[:5] == ["/usr/bin/gws", "calendar", "events", "insert", "--params"] + assert json.loads(cmd[5]) == {"calendarId": "primary"} + body = json.loads(cmd[7]) + assert body == { + "summary": "Standup", + "start": {"dateTime": "2026-03-15T09:00:00Z"}, + "end": {"dateTime": "2026-03-15T09:30:00Z"}, + "location": "Room 1", + "description": "Daily sync", + "attendees": [{"email": "alice@example.com"}, {"email": "bob@example.com"}], + } + return _completed(json.dumps({"id": "evt-1", "summary": "Standup", "htmlLink": "https://calendar/event"})) + + monkeypatch.setattr(google_api_module.subprocess, "run", fake_run) + + google_api_module.calendar_create( + Namespace( + summary="Standup", + start="2026-03-15T09:00:00Z", + end="2026-03-15T09:30:00Z", + location="Room 1", + description="Daily sync", + attendees="alice@example.com,bob@example.com", + calendar="primary", + ) + ) + + assert json.loads(capsys.readouterr().out) == { + "status": "created", + "id": "evt-1", + "summary": "Standup", + "htmlLink": "https://calendar/event", + } + + +def test_sheets_append_uses_gws_and_returns_updated_cells(google_api_module, monkeypatch, capsys): + def fake_run(cmd, capture_output, text, env): + assert cmd[:6] == ["/usr/bin/gws", "sheets", "spreadsheets", "values", "append", "--params"] + assert json.loads(cmd[6]) == { + "spreadsheetId": "sheet-123", + "range": "Sheet1!A:C", + "valueInputOption": "USER_ENTERED", + "insertDataOption": "INSERT_ROWS", + } + assert json.loads(cmd[8]) == {"values": [["a", "b", "c"]]} + return _completed(json.dumps({"updates": {"updatedCells": 3}})) + + monkeypatch.setattr(google_api_module.subprocess, "run", fake_run) + + google_api_module.sheets_append( + Namespace(sheet_id="sheet-123", range="Sheet1!A:C", values='[["a", "b", "c"]]') + ) + + assert json.loads(capsys.readouterr().out) == {"updatedCells": 3} + + +def test_docs_get_uses_gws_and_extracts_plain_text(google_api_module, monkeypatch, capsys): + def fake_run(cmd, capture_output, text, env): + assert cmd[:6] == ["/usr/bin/gws", "docs", "documents", "get", "--params", '{"documentId": "doc-123"}'] + return _completed( + json.dumps( + { + "title": "Doc Title", + "documentId": "doc-123", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Hello "}}, + {"textRun": {"content": "world"}}, + ] + } + } + ] + }, + } + ) + ) + + monkeypatch.setattr(google_api_module.subprocess, "run", fake_run) + + google_api_module.docs_get(Namespace(doc_id="doc-123")) + + assert json.loads(capsys.readouterr().out) == { + "title": "Doc Title", + "documentId": "doc-123", + "body": "Hello world", + } diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 7e128f11fba..68bc4d98b8f 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -204,7 +204,7 @@ Skills for document creation, presentations, spreadsheets, and other productivit | Skill | Description | Path | |-------|-------------|------| -| `google-workspace` | 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. | `productivity/google-workspace` | +| `google-workspace` | Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. | `productivity/google-workspace` | | `nano-pdf` | Edit PDFs with natural-language instructions using the nano-pdf CLI. Modify text, fix typos, update titles, and make content changes to specific pages without manual editing. | `productivity/nano-pdf` | | `notion` | Notion API for creating and managing pages, databases, and blocks via curl. Search, create, update, and query Notion workspaces directly from the terminal. | `productivity/notion` | | `ocr-and-documents` | Extract text from PDFs and scanned documents. Use web_extract for remote URLs, pymupdf for local text-based PDFs, marker-pdf for OCR/scanned docs. For DOCX use python-docx, for PPTX see the powerpoint skill. | `productivity/ocr-and-documents` |