Skill: araa47/ez-google (version 1.0.2)

--- name: ez-google description: Use when asked to send email, check inbox, read emails, check calendar, schedule meetings, create events, search Google Drive, create Google Docs, read or write spreadsheets, find contacts, or any task involving Gmail, Google Calendar, Drive, Docs, Sheets, Slides, or Contacts. Agent-friendly with hosted OAuth - no API keys needed. metadata: {"openclaw":{"emoji":"📧"}} --- # ez-google Agent-friendly Google Workspace tools. Simple CLI scripts with hosted OAuth - users just click a link and paste back a token. No API keys or credentials needed. **Run all commands with:** `uv run scripts/<script>.py <command> [args]` ## Auth (do this first) ```bash auth.py status # Check: AUTHENTICATED or NOT_AUTHENTICATED auth.py login # Get URL → send to user auth.py save '<TOKEN>' # Save token from hosted OAuth ``` **Auth flow:** `status` → if not authenticated → `login` → user clicks link, copies token → `save '<TOKEN>'` --- ## Gmail ```bash gmail.py list [-n 10] [-q "query"] # List emails gmail.py search "query" # Search emails gmail.py get MESSAGE_ID # Read email gmail.py send "to" "subject" "body" # Send email gmail.py draft "to" "subject" "body" # Create draft gmail.py labels # List labels # Bulk operations (up to 1000 messages per API call) gmail.py bulk-label "query" --add LABEL --remove LABEL # Add/remove labels gmail.py bulk-trash "query" [-y] # Move to trash (use -y to skip confirmation) ``` **Bulk examples:** ```bash gmail.py bulk-label "from:[REDACTED_SECRET]" --add ARCHIVE --remove INBOX gmail.py bulk-trash "subject:alert older_than:30d" -y gmail.py bulk-label "category:promotions" --add Label_3 # Use label IDs from `labels` ``` ## Calendar ```bash gcal.py list [DATE] # List events (DATE: YYYY-MM-DD or "today") gcal.py create "title" "START" "END" # Create event (START/END: YYYY-MM-DDTHH:MM) gcal.py get EVENT_ID # Event details gcal.py delete EVENT_ID # Delete event gcal.py calendars # List calendars ``` ## Drive ```bash drive.py list [-n 20] # List files drive.py search "query" # Search by name drive.py get FILE_ID # File metadata drive.py download FILE_ID # File content drive.py create-folder "name" # Create folder ``` ## Docs ```bash docs.py create "title" # Create doc docs.py get DOC_ID # Read content docs.py find "query" # Find by title docs.py append DOC_ID "text" # Append text docs.py insert DOC_ID "text" # Insert at start docs.py replace DOC_ID "old" "new" # Replace text ``` ## Sheets ```bash sheets.py create "title" # Create spreadsheet sheets.py get ID "Sheet!A1:D10" # Read data sheets.py info ID # Sheet metadata sheets.py find "query" # Find by name sheets.py write ID "Sheet!A1" "a,b;c,d" # Write (rows separated by ;) sheets.py append ID "Sheet!A:B" "a,b;c,d" # Append rows ``` ## Slides ```bash slides.py find "query" # Find presentations slides.py get PRESENTATION_ID # Get slides info slides.py text PRESENTATION_ID # Extract all text slides.py create "title" # Create presentation ``` ## People/Contacts ```bash people.py me # Current user profile people.py contacts [-n 100] # List contacts people.py search "name" # Search contacts people.py get CONTACT_ID # Contact details ``` ## Chat (Workspace only) ```bash chat.py spaces # List spaces chat.py messages SPACE_ID [-n 20] # List messages chat.py send SPACE_ID "text" # Send message chat.py get SPACE_ID # Space details ``` --- Note: After adding new services, run `auth.py logout` then `login` again to grant new permissions.

path: scripts/auth.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "google-auth-oauthlib>=1.0.0",
#     "google-auth>=2.0.0",
# ]
# ///
"""
Google Workspace OAuth - Simple Auth for AI Agents

Commands:
    uv run auth.py login              # Opens browser to hosted OAuth (easiest)
    uv run auth.py save <TOKEN>       # Save token from hosted OAuth
    uv run auth.py status             # Check if authenticated
    uv run auth.py logout             # Remove credentials

Advanced (bring your own OAuth app):
    uv run auth.py login --local      # Returns auth URL for local OAuth
    uv run auth.py complete <URL>     # Complete local auth with redirect URL
"""

# Hosted OAuth URL - deploy your own or use the default
OAUTH_WORKER_URL = "https://ezagentauth.com"

import base64
import json
import os
import sys
import webbrowser
from pathlib import Path
from urllib.parse import urlparse, parse_qs

from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow

SCOPES = [
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/gmail.modify",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/presentations",
    "https://www.googleapis.com/auth/contacts.readonly",
    "https://www.googleapis.com/auth/chat.spaces.readonly",
    "https://www.googleapis.com/auth/chat.messages",
]

CREDS_DIR = Path.home() / ".simple-google-workspace"
TOKEN_FILE = CREDS_DIR / "token.json"
CLIENT_SECRETS_FILE = CREDS_DIR / "client_secrets.json"
FLOW_STATE_FILE = CREDS_DIR / "flow_state.json"


def get_client_config():
    """Get OAuth client configuration."""
    if CLIENT_SECRETS_FILE.exists():
        return str(CLIENT_SECRETS_FILE)

    client_id = os.environ.get("GOOGLE_CLIENT_ID")
    client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")

    if client_id and client_secret:
        return {
            "installed": {
                "client_id": client_id,
                "client_secret": client_secret,
                "auth_uri": "https://accounts.google.com/o/oauth2/auth",
                "token_uri": "https://oauth2.googleapis.com/token",
                "redirect_uris": ["http://localhost"],
            }
        }

    print("SETUP_REQUIRED")
    print(f"Save OAuth credentials to: {CLIENT_SECRETS_FILE}")
    print("Or set: GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET")
    sys.exit(1)


def login(local: bool = False):
    """Start OAuth flow."""
    CREDS_DIR.mkdir(exist_ok=True)

    if not local:
        # Hosted OAuth - open browser to worker
        login_url = f"{OAUTH_WORKER_URL}/login"
        print(f"Opening browser to: {login_url}")
        print("After authenticating, copy the token and run:")
        print('  uv run auth.py save "<TOKEN>"')
        webbrowser.open(login_url)
        return

    # Local OAuth - requires CLIENT_ID/SECRET
    client_config = get_client_config()

    if isinstance(client_config, str):
        flow = InstalledAppFlow.from_client_secrets_file(client_config, SCOPES)
    else:
        flow = InstalledAppFlow.from_client_config(client_config, SCOPES)

    flow.redirect_uri = "http://localhost:8085/"
    auth_url, state = flow.authorization_url(
        access_type="offline",
        include_granted_scopes="true",
        prompt="consent",
    )

    # Save state for complete step
    flow_data = {
        "client_config": client_config if isinstance(client_config, dict) else None,
        "client_secrets_file": client_config if isinstance(client_config, str) else None,
        "redirect_uri": flow.redirect_uri,
    }
    with open(FLOW_STATE_FILE, "w") as f:
        json.dump(flow_data, f)

    # Simple output - just the URL
    print(auth_url)


def save(token: str):
    """Save token from hosted OAuth."""
    CREDS_DIR.mkdir(exist_ok=True)

    try:
        # Decode base64 token
        creds_json = base64.b64decode(token).decode("utf-8")
        creds_data = json.loads(creds_json)

        # Validate required fields
        required = ["token", "refresh_token", "client_id", "client_secret"]
        missing = [f for f in required if f not in creds_data]
        if missing:
            print(f"ERROR: Invalid token (missing: {', '.join(missing)})")
            sys.exit(1)

        # Save in google-auth format
        with open(TOKEN_FILE, "w") as f:
            json.dump(creds_data, f, indent=2)

        print("SUCCESS")
        print(f"Credentials saved to: {TOKEN_FILE}")

    except Exception as e:
        print(f"ERROR: {e}")
        print("Make sure you copied the full token from the OAuth page.")
        sys.exit(1)


def complete(redirect_url: str):
    """Complete auth with the redirect URL user pasted."""
    if not FLOW_STATE_FILE.exists():
        print("ERROR: Run login first")
        sys.exit(1)

    with open(FLOW_STATE_FILE) as f:
        flow_data = json.load(f)

    if flow_data.get("client_secrets_file"):
        flow = InstalledAppFlow.from_client_secrets_file(flow_data["client_secrets_file"], SCOPES)
    else:
        flow = InstalledAppFlow.from_client_config(flow_data["client_config"], SCOPES)

    flow.redirect_uri = flow_data["redirect_uri"]

    try:
        parsed = urlparse(redirect_url)
        params = parse_qs(parsed.query)
        code = params.get("code", [None])[0]

        if not code:
            print("ERROR: No code in URL")
            sys.exit(1)

        flow.fetch_token(code=code)

        with open(TOKEN_FILE, "w") as f:
            f.write(flow.credentials.to_json())

        FLOW_STATE_FILE.unlink(missing_ok=True)
        print("SUCCESS")

    except Exception as e:
        print(f"ERROR: {e}")
        sys.exit(1)


def status():
    """Check auth status."""
    if not TOKEN_FILE.exists():
        print("NOT_AUTHENTICATED")
        sys.exit(1)

    with open(TOKEN_FILE) as f:
        data = json.load(f)

    creds = Credentials.from_authorized_user_info(data, SCOPES)

    if creds.expired and creds.refresh_token:
        from google.auth.transport.requests import Request
        try:
            creds.refresh(Request())
            with open(TOKEN_FILE, "w") as f:
                f.write(creds.to_json())
        except Exception:
            print("NOT_AUTHENTICATED")
            sys.exit(1)

    if creds.valid:
        print("AUTHENTICATED")
    else:
        print("NOT_AUTHENTICATED")
        sys.exit(1)


def logout():
    """Remove credentials."""
    for f in [TOKEN_FILE, FLOW_STATE_FILE]:
        if f.exists():
            f.unlink()
    print("LOGGED_OUT")


def main():
    if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
        print(__doc__)
        sys.exit(0)

    cmd = sys.argv[1].lower()

    if cmd == "login":
        # Check for --local flag
        local = "--local" in sys.argv
        login(local=local)
    elif cmd == "save":
        if len(sys.argv) < 3:
            print("ERROR: Provide token from OAuth page")
            sys.exit(1)
        save(sys.argv[2])
    elif cmd == "complete":
        if len(sys.argv) < 3:
            print("ERROR: Provide redirect URL")
            sys.exit(1)
        complete(sys.argv[2])
    elif cmd == "status":
        status()
    elif cmd == "logout":
        logout()
    else:
        print(f"Unknown: {cmd}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/sheets.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Sheets commands."""

from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("sheets", "v4", credentials=creds)


def get_drive_service():
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("drive", "v3", credentials=creds)


@click.group()
def cli():
    """Google Sheets commands."""


@cli.command()
@click.argument("spreadsheet_id")
@click.argument("range", default="Sheet1!A1:Z100")
def get(spreadsheet_id: str, range: str):
    """Read data from spreadsheet."""
    service = get_service()
    result = service.spreadsheets().values().get(
        spreadsheetId=spreadsheet_id, range=range
    ).execute()

    values = result.get("values", [])
    if not values:
        click.echo("No data found.")
        return

    for row in values:
        click.echo("\t".join(str(cell) for cell in row))


@cli.command()
@click.argument("spreadsheet_id")
def info(spreadsheet_id: str):
    """Get spreadsheet metadata."""
    service = get_service()
    result = service.spreadsheets().get(
        spreadsheetId=spreadsheet_id, fields="properties,sheets.properties"
    ).execute()

    props = result.get("properties", {})
    click.echo(f"Title: {props.get('title')}")
    click.echo(f"Locale: {props.get('locale')}")
    click.echo("Sheets:")
    for sheet in result.get("sheets", []):
        sp = sheet.get("properties", {})
        click.echo(f"  - {sp.get('title')} (ID: {sp.get('sheetId')})")


@cli.command()
@click.argument("title")
def create(title: str):
    """Create a new spreadsheet."""
    service = get_service()
    spreadsheet = {"properties": {"title": title}}
    result = service.spreadsheets().create(body=spreadsheet, fields="spreadsheetId,spreadsheetUrl").execute()
    click.echo(f"Created: {title}")
    click.echo(f"ID: {result.get('spreadsheetId')}")
    click.echo(f"URL: {result.get('spreadsheetUrl')}")


@cli.command()
@click.argument("query")
def find(query: str):
    """Find spreadsheets by name."""
    drive = get_drive_service()
    results = drive.files().list(
        q=f"name contains '{query}' and mimeType = 'application/vnd.google-apps.spreadsheet'",
        fields="files(id,name)",
    ).execute()

    files = results.get("files", [])
    if not files:
        click.echo("No spreadsheets found.")
        return

    for f in files:
        click.echo(f"[{f['id']}] {f['name']}")


@cli.command()
@click.argument("spreadsheet_id")
@click.argument("range")
@click.argument("values")
def write(spreadsheet_id: str, range: str, values: str):
    """Write data to spreadsheet. VALUES: comma-separated, rows separated by semicolon.

    Example: write ID "Sheet1!A1:B2" "a,b;c,d" writes a 2x2 grid.
    """
    service = get_service()
    # Parse values: "a,b;c,d" -> [["a","b"],["c","d"]]
    rows = [row.split(",") for row in values.split(";")]

    service.spreadsheets().values().update(
        spreadsheetId=spreadsheet_id,
        range=range,
        valueInputOption="USER_ENTERED",
        body={"values": rows}
    ).execute()
    click.echo(f"Data written to {range}")


@cli.command()
@click.argument("spreadsheet_id")
@click.argument("range")
@click.argument("values")
def append(spreadsheet_id: str, range: str, values: str):
    """Append rows to spreadsheet. VALUES: comma-separated, rows separated by semicolon.

    Example: append ID "Sheet1!A:B" "a,b;c,d" appends 2 rows.
    """
    service = get_service()
    rows = [row.split(",") for row in values.split(";")]

    service.spreadsheets().values().append(
        spreadsheetId=spreadsheet_id,
        range=range,
        valueInputOption="USER_ENTERED",
        insertDataOption="INSERT_ROWS",
        body={"values": rows}
    ).execute()
    click.echo(f"Data appended to {range}")


if __name__ == "__main__":
    cli()


path: scripts/docs.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Docs commands."""

from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("docs", "v1", credentials=creds)


def get_drive_service():
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("drive", "v3", credentials=creds)


def extract_text(content):
    """Extract plain text from document content."""
    text = ""
    for element in content:
        if "paragraph" in element:
            for pe in element["paragraph"].get("elements", []):
                if "textRun" in pe:
                    text += pe["textRun"].get("content", "")
    return text


@click.group()
def cli():
    """Google Docs commands."""


@cli.command()
@click.argument("title")
def create(title: str):
    """Create a new Google Doc."""
    service = get_service()
    doc = service.documents().create(body={"title": title}).execute()
    click.echo(f"Created: {doc.get('title')}")
    click.echo(f"ID: {doc.get('documentId')}")


@cli.command()
@click.argument("doc_id")
def get(doc_id: str):
    """Get document content."""
    service = get_service()
    doc = service.documents().get(documentId=doc_id).execute()

    click.echo(f"Title: {doc.get('title')}")
    click.echo("-" * 40)

    content = doc.get("body", {}).get("content", [])
    text = extract_text(content)
    click.echo(text)


@cli.command()
@click.argument("doc_id")
@click.argument("text")
def insert(doc_id: str, text: str):
    """Insert text at beginning of document."""
    service = get_service()
    requests = [{"insertText": {"location": {"index": 1}, "text": text}}]
    service.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute()
    click.echo("Text inserted.")


@cli.command()
@click.argument("doc_id")
@click.argument("text")
def append(doc_id: str, text: str):
    """Append text to end of document."""
    service = get_service()

    # Get document length
    doc = service.documents().get(documentId=doc_id).execute()
    end_index = doc.get("body", {}).get("content", [{}])[-1].get("endIndex", 1) - 1

    requests = [{"insertText": {"location": {"index": end_index}, "text": text}}]
    service.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute()
    click.echo("Text appended.")


@cli.command()
@click.argument("doc_id")
@click.argument("find_text")
@click.argument("replace_text")
def replace(doc_id: str, find_text: str, replace_text: str):
    """Replace text in document."""
    service = get_service()
    requests = [{"replaceAllText": {"containsText": {"text": find_text, "matchCase": True}, "replaceText": replace_text}}]
    result = service.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute()
    count = result.get("replies", [{}])[0].get("replaceAllText", {}).get("occurrencesChanged", 0)
    click.echo(f"Replaced {count} occurrences.")


@cli.command()
@click.argument("query")
def find(query: str):
    """Find Google Docs by title."""
    drive = get_drive_service()
    results = drive.files().list(
        q=f"name contains '{query}' and mimeType = 'application/vnd.google-apps.document'",
        fields="files(id,name)",
    ).execute()

    files = results.get("files", [])
    if not files:
        click.echo("No documents found.")
        return

    for f in files:
        click.echo(f"[{f['id']}] {f['name']}")


@cli.command()
@click.argument("doc_id")
@click.argument("folder_id")
def move(doc_id: str, folder_id: str):
    """Move document to folder."""
    drive = get_drive_service()

    # Get current parents
    file = drive.files().get(fileId=doc_id, fields="parents").execute()
    previous_parents = ",".join(file.get("parents", []))

    # Move to new folder
    drive.files().update(
        fileId=doc_id,
        addParents=folder_id,
        removeParents=previous_parents,
        fields="id,parents",
    ).execute()
    click.echo(f"Moved document to folder {folder_id}")


if __name__ == "__main__":
    cli()


path: scripts/gmail.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Gmail commands."""

import base64
import math
from email.mime.text import MIMEText
from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"

# Gmail API batch limit
BATCH_SIZE = 1000


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("gmail", "v1", credentials=creds)


def collect_message_ids(service, query: str, max_results: int | None = None):
    """Collect all message IDs matching a query, with pagination."""
    ids = []
    page_token = None

    while True:
        results = service.users().messages().list(
            userId="me",
            q=query,
            maxResults=min(500, max_results - len(ids)) if max_results else 500,
            pageToken=page_token,
        ).execute()

        messages = results.get("messages", [])
        ids.extend(m["id"] for m in messages)

        if max_results and len(ids) >= max_results:
            ids = ids[:max_results]
            break

        page_token = results.get("nextPageToken")
        if not page_token:
            break

    return ids


def batch_modify(service, message_ids: list[str], add_labels: list[str] | None = None,
                 remove_labels: list[str] | None = None):
    """Apply batchModify to messages in chunks of up to 1000."""
    if not message_ids:
        return 0

    total_batches = math.ceil(len(message_ids) / BATCH_SIZE)
    processed = 0

    for batch_num in range(total_batches):
        start = batch_num * BATCH_SIZE
        end = start + BATCH_SIZE
        batch_ids = message_ids[start:end]

        click.echo(f"Processing batch {batch_num + 1} of {total_batches}... ({len(batch_ids)} messages)")

        body = {"ids": batch_ids}
        if add_labels:
            body["addLabelIds"] = add_labels
        if remove_labels:
            body["removeLabelIds"] = remove_labels

        service.users().messages().batchModify(userId="me", body=body).execute()
        processed += len(batch_ids)

    return processed


def get_headers(msg):
    return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}


@click.group()
def cli():
    """Gmail commands."""


@cli.command("list")
@click.option("-q", "--query", help="Gmail search query")
@click.option("-n", "--limit", default=10, help="Max results")
def list_messages(query: str, limit: int):
    """List recent emails."""
    service = get_service()
    results = service.users().messages().list(
        userId="me", q=query, maxResults=limit
    ).execute()

    messages = results.get("messages", [])
    if not messages:
        click.echo("No messages found.")
        return

    for m in messages:
        msg = service.users().messages().get(
            userId="me", id=m["id"], format="metadata", metadataHeaders=["From", "Subject"]
        ).execute()
        h = get_headers(msg)
        click.echo(f"[{m['id']}] {h.get('Subject', '(No subject)')}")
        click.echo(f"  From: {h.get('From', 'Unknown')}")


@cli.command()
@click.argument("query")
def search(query: str):
    """Search emails."""
    service = get_service()
    results = service.users().messages().list(userId="me", q=query, maxResults=10).execute()

    messages = results.get("messages", [])
    if not messages:
        click.echo("No messages found.")
        return

    for m in messages:
        msg = service.users().messages().get(
            userId="me", id=m["id"], format="metadata", metadataHeaders=["From", "Subject"]
        ).execute()
        h = get_headers(msg)
        click.echo(f"[{m['id']}] {h.get('Subject', '(No subject)')}")


@cli.command()
@click.argument("message_id")
def get(message_id: str):
    """Read email content."""
    service = get_service()
    msg = service.users().messages().get(userId="me", id=message_id, format="full").execute()
    h = get_headers(msg)

    click.echo(f"From: {h.get('From', 'Unknown')}")
    click.echo(f"To: {h.get('To', 'Unknown')}")
    click.echo(f"Subject: {h.get('Subject', '(No subject)')}")
    click.echo("-" * 40)

    payload = msg.get("payload", {})
    body = ""
    if "body" in payload and payload["body"].get("data"):
        body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8")
    elif "parts" in payload:
        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")
                break

    click.echo(body or "(No text body)")


@cli.command()
@click.argument("to")
@click.argument("subject")
@click.argument("body")
def send(to: str, subject: str, body: str):
    """Send email."""
    service = get_service()

    message = MIMEText(body)
    message["to"] = to
    message["subject"] = subject
    raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")

    service.users().messages().send(userId="me", body={"raw": raw}).execute()
    click.echo(f"Email sent to {to}")


@cli.command()
@click.argument("to")
@click.argument("subject")
@click.argument("body")
def draft(to: str, subject: str, body: str):
    """Create draft email."""
    service = get_service()

    message = MIMEText(body)
    message["to"] = to
    message["subject"] = subject
    raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")

    result = service.users().drafts().create(
        userId="me", body={"message": {"raw": raw}}
    ).execute()
    click.echo(f"Draft created: {result.get('id')}")


@cli.command()
def labels():
    """List Gmail labels."""
    service = get_service()
    results = service.users().labels().list(userId="me").execute()
    for label in results.get("labels", []):
        click.echo(f"- {label['name']} [{label['id']}]")


@cli.command("bulk-label")
@click.argument("query")
@click.option("--add", "-a", "add_labels", multiple=True, help="Label IDs to add")
@click.option("--remove", "-r", "remove_labels", multiple=True, help="Label IDs to remove")
@click.option("--max", "-n", "max_results", type=int, help="Max messages to process")
def bulk_label(query: str, add_labels: tuple, remove_labels: tuple, max_results: int | None):
    """Bulk add/remove labels for messages matching a query.

    Uses batchModify to process up to 1000 messages per API call.
    Use 'labels' command to see available label IDs.

    Example: gmail.py bulk-label "from:[REDACTED_SECRET]" --add TRASH
    """
    if not add_labels and not remove_labels:
        raise click.ClickException("Must specify at least one --add or --remove label")

    service = get_service()

    click.echo(f"Collecting messages matching: {query}")
    message_ids = collect_message_ids(service, query, max_results)

    if not message_ids:
        click.echo("No messages found.")
        return

    click.echo(f"Found {len(message_ids)} messages to modify.")
    processed = batch_modify(service, message_ids, list(add_labels) or None, list(remove_labels) or None)
    click.echo(f"Done. Modified {processed} messages.")


@cli.command("bulk-trash")
@click.argument("query")
@click.option("--max", "-n", "max_results", type=int, help="Max messages to trash")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
def bulk_trash(query: str, max_results: int | None, yes: bool):
    """Trash all messages matching a query.

    Uses batchModify to efficiently trash up to 1000 messages per API call.
    Messages go to Trash and can be recovered within 30 days.

    Example: gmail.py bulk-trash "from:[REDACTED_SECRET] older_than:30d"
    """
    service = get_service()

    click.echo(f"Collecting messages matching: {query}")
    message_ids = collect_message_ids(service, query, max_results)

    if not message_ids:
        click.echo("No messages found.")
        return

    click.echo(f"Found {len(message_ids)} messages to trash.")

    if not yes:
        click.confirm("Proceed with trashing these messages?", abort=True)

    processed = batch_modify(service, message_ids, add_labels=["TRASH"])
    click.echo(f"Done. Trashed {processed} messages.")


if __name__ == "__main__":
    cli()


path: scripts/chat.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Chat commands.

Note: Google Chat API requires a Google Workspace account.
Personal Gmail accounts may have limited access.
"""

from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("chat", "v1", credentials=creds)


@click.group()
def cli():
    """Google Chat commands."""


@cli.command()
def spaces():
    """List chat spaces (rooms, DMs)."""
    service = get_service()
    try:
        results = service.spaces().list().execute()
        spaces = results.get("spaces", [])

        if not spaces:
            click.echo("No spaces found.")
            return

        for space in spaces:
            space_type = space.get("spaceType", "UNKNOWN")
            name = space.get("displayName") or space.get("name", "")
            space_id = space.get("name", "").replace("spaces/", "")
            click.echo(f"[{space_id}] {name} ({space_type})")

    except Exception as e:
        if "403" in str(e) or "404" in str(e):
            click.echo("Error: Google Chat API requires a Google Workspace account.")
            click.echo("Personal Gmail accounts may not have access.")
        else:
            raise


@cli.command()
@click.argument("space_id")
@click.option("-n", "--limit", default=20, help="Max messages")
def messages(space_id: str, limit: int):
    """List messages in a space."""
    service = get_service()

    if not space_id.startswith("spaces/"):
        space_id = f"spaces/{space_id}"

    try:
        results = service.spaces().messages().list(
            parent=space_id,
            pageSize=limit
        ).execute()

        messages = results.get("messages", [])
        if not messages:
            click.echo("No messages found.")
            return

        for msg in messages:
            sender = msg.get("sender", {}).get("displayName", "Unknown")
            text = msg.get("text", "(no text)")
            msg_id = msg.get("name", "").split("/")[-1]
            click.echo(f"[{msg_id}] {sender}: {text[:100]}")

    except Exception as e:
        if "403" in str(e) or "404" in str(e):
            click.echo("Error: Google Chat API requires a Google Workspace account.")
        else:
            raise


@cli.command()
@click.argument("space_id")
@click.argument("text")
def send(space_id: str, text: str):
    """Send a message to a space."""
    service = get_service()

    if not space_id.startswith("spaces/"):
        space_id = f"spaces/{space_id}"

    try:
        result = service.spaces().messages().create(
            parent=space_id,
            body={"text": text}
        ).execute()

        click.echo(f"Message sent: {result.get('name')}")

    except Exception as e:
        if "403" in str(e) or "404" in str(e):
            click.echo("Error: Google Chat API requires a Google Workspace account.")
        else:
            raise


@cli.command()
@click.argument("space_id")
def get(space_id: str):
    """Get space details."""
    service = get_service()

    if not space_id.startswith("spaces/"):
        space_id = f"spaces/{space_id}"

    try:
        space = service.spaces().get(name=space_id).execute()

        click.echo(f"Name: {space.get('displayName', space.get('name'))}")
        click.echo(f"Type: {space.get('spaceType')}")
        click.echo(f"ID: {space.get('name')}")

    except Exception as e:
        if "403" in str(e) or "404" in str(e):
            click.echo("Error: Google Chat API requires a Google Workspace account.")
        else:
            raise


if __name__ == "__main__":
    cli()


path: scripts/people.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google People/Contacts commands."""

from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("people", "v1", credentials=creds)


@click.group()
def cli():
    """Google People/Contacts commands."""


@cli.command()
def me():
    """Get current user's profile."""
    service = get_service()
    profile = service.people().get(
        resourceName="people/me",
        personFields="names,emailAddresses,phoneNumbers,photos"
    ).execute()

    names = profile.get("names", [])
    if names:
        click.echo(f"Name: {names[0].get('displayName')}")

    emails = profile.get("emailAddresses", [])
    for email in emails:
        click.echo(f"Email: {email.get('value')}")

    phones = profile.get("phoneNumbers", [])
    for phone in phones:
        click.echo(f"Phone: {phone.get('value')}")


@cli.command()
@click.option("-n", "--limit", default=100, help="Max results")
def contacts(limit: int):
    """List contacts."""
    service = get_service()
    results = service.people().connections().list(
        resourceName="people/me",
        pageSize=limit,
        personFields="names,emailAddresses,phoneNumbers"
    ).execute()

    connections = results.get("connections", [])
    if not connections:
        click.echo("No contacts found.")
        return

    for person in connections:
        name = "Unknown"
        names = person.get("names", [])
        if names:
            name = names[0].get("displayName", "Unknown")

        emails = person.get("emailAddresses", [])
        email = emails[0].get("value") if emails else ""

        phones = person.get("phoneNumbers", [])
        phone = phones[0].get("value") if phones else ""

        resource_id = person.get("resourceName", "").replace("people/", "")
        click.echo(f"[{resource_id}] {name}")
        if email:
            click.echo(f"  Email: {email}")
        if phone:
            click.echo(f"  Phone: {phone}")


@cli.command()
@click.argument("query")
def search(query: str):
    """Search contacts by name."""
    service = get_service()
    results = service.people().searchContacts(
        query=query,
        readMask="names,emailAddresses,phoneNumbers"
    ).execute()

    contacts = results.get("results", [])
    if not contacts:
        click.echo("No contacts found.")
        return

    for result in contacts:
        person = result.get("person", {})
        name = "Unknown"
        names = person.get("names", [])
        if names:
            name = names[0].get("displayName", "Unknown")

        emails = person.get("emailAddresses", [])
        email = emails[0].get("value") if emails else ""

        click.echo(f"{name}")
        if email:
            click.echo(f"  Email: {email}")


@cli.command()
@click.argument("resource_id")
def get(resource_id: str):
    """Get contact details by ID."""
    service = get_service()

    # Add people/ prefix if not present
    if not resource_id.startswith("people/"):
        resource_id = f"people/{resource_id}"

    person = service.people().get(
        resourceName=resource_id,
        personFields="names,emailAddresses,phoneNumbers,organizations,addresses"
    ).execute()

    names = person.get("names", [])
    if names:
        click.echo(f"Name: {names[0].get('displayName')}")

    emails = person.get("emailAddresses", [])
    for email in emails:
        click.echo(f"Email: {email.get('value')}")

    phones = person.get("phoneNumbers", [])
    for phone in phones:
        click.echo(f"Phone: {phone.get('value')}")

    orgs = person.get("organizations", [])
    for org in orgs:
        click.echo(f"Org: {org.get('name', '')} - {org.get('title', '')}")


if __name__ == "__main__":
    cli()


path: scripts/slides.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Slides commands."""

from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("slides", "v1", credentials=creds)


def get_drive_service():
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("drive", "v3", credentials=creds)


@click.group()
def cli():
    """Google Slides commands."""


@cli.command()
@click.argument("query")
def find(query: str):
    """Find presentations by name."""
    drive = get_drive_service()
    results = drive.files().list(
        q=f"name contains '{query}' and mimeType = 'application/vnd.google-apps.presentation'",
        fields="files(id,name)",
    ).execute()

    files = results.get("files", [])
    if not files:
        click.echo("No presentations found.")
        return

    for f in files:
        click.echo(f"[{f['id']}] {f['name']}")


@cli.command()
@click.argument("presentation_id")
def get(presentation_id: str):
    """Get presentation metadata and slide titles."""
    service = get_service()
    presentation = service.presentations().get(presentationId=presentation_id).execute()

    click.echo(f"Title: {presentation.get('title')}")
    click.echo(f"Slides: {len(presentation.get('slides', []))}")
    click.echo("---")

    for i, slide in enumerate(presentation.get("slides", []), 1):
        # Try to get slide title from shapes
        title = "(No title)"
        for element in slide.get("pageElements", []):
            if "shape" in element:
                shape = element["shape"]
                if shape.get("placeholder", {}).get("type") == "TITLE":
                    text_elements = shape.get("text", {}).get("textElements", [])
                    for te in text_elements:
                        if "textRun" in te:
                            title = te["textRun"].get("content", "").strip()
                            break
        click.echo(f"Slide {i}: {title}")


@cli.command()
@click.argument("presentation_id")
def text(presentation_id: str):
    """Extract all text from presentation."""
    service = get_service()
    presentation = service.presentations().get(presentationId=presentation_id).execute()

    click.echo(f"# {presentation.get('title')}\n")

    for i, slide in enumerate(presentation.get("slides", []), 1):
        click.echo(f"## Slide {i}")
        for element in slide.get("pageElements", []):
            if "shape" in element and "text" in element.get("shape", {}):
                for te in element["shape"]["text"].get("textElements", []):
                    if "textRun" in te:
                        content = te["textRun"].get("content", "").strip()
                        if content:
                            click.echo(content)
        click.echo()


@cli.command()
@click.argument("title")
def create(title: str):
    """Create a new presentation."""
    service = get_service()
    presentation = service.presentations().create(body={"title": title}).execute()

    click.echo(f"Created: {presentation.get('title')}")
    click.echo(f"ID: {presentation.get('presentationId')}")
    click.echo(f"Link: https://docs.google.com/presentation/d/{presentation.get('presentationId')}")


if __name__ == "__main__":
    cli()


path: scripts/gcal.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Calendar commands."""

from datetime import datetime
from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("calendar", "v3", credentials=creds)


@click.group()
def cli():
    """Google Calendar commands."""


@cli.command("list")
@click.argument("date", default="today")
def list_events(date: str):
    """List events for DATE (YYYY-MM-DD or 'today')."""
    service = get_service()
    d = datetime.now() if date == "today" else datetime.strptime(date, "%Y-%m-%d")

    events = service.events().list(
        calendarId="primary",
        timeMin=d.replace(hour=0, minute=0, second=0).isoformat() + "Z",
        timeMax=d.replace(hour=23, minute=59, second=59).isoformat() + "Z",
        singleEvents=True,
        orderBy="startTime",
    ).execute().get("items", [])

    if not events:
        click.echo("No events found.")
        return

    for e in events:
        start = e["start"].get("dateTime", e["start"].get("date"))
        click.echo(f"- {start}: {e.get('summary', '(No title)')} [ID: {e['id']}]")


def normalize_datetime(dt_str: str) -> str:
    """Normalize datetime to ISO format with seconds."""
    # Add :00 seconds if missing (YYYY-MM-DDTHH:MM -> YYYY-MM-DDTHH:MM:00)
    if len(dt_str) == 16:  # YYYY-MM-DDTHH:MM
        return dt_str + ":00"
    return dt_str


@cli.command()
@click.argument("title")
@click.argument("start")
@click.argument("end")
@click.option("--tz", default="America/Los_Angeles", help="Timezone")
@click.option("--attendee", "-a", multiple=True, help="Attendee email")
def create(title: str, start: str, end: str, tz: str, attendee: tuple):
    """Create event. START/END: YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS"""
    service = get_service()
    event = {
        "summary": title,
        "start": {"dateTime": normalize_datetime(start), "timeZone": tz},
        "end": {"dateTime": normalize_datetime(end), "timeZone": tz},
    }
    if attendee:
        event["attendees"] = [{"email": a} for a in attendee]

    result = service.events().insert(calendarId="primary", body=event).execute()
    click.echo(f"Created: {result.get('summary')}")
    click.echo(f"Link: {result.get('htmlLink')}")


@cli.command()
@click.argument("event_id")
def get(event_id: str):
    """Get event details."""
    service = get_service()
    e = service.events().get(calendarId="primary", eventId=event_id).execute()
    click.echo(f"Title: {e.get('summary')}")
    click.echo(f"Start: {e['start'].get('dateTime', e['start'].get('date'))}")
    click.echo(f"End: {e['end'].get('dateTime', e['end'].get('date'))}")
    if e.get("attendees"):
        click.echo("Attendees:")
        for a in e["attendees"]:
            click.echo(f"  - {a['email']} ({a.get('responseStatus', 'unknown')})")


@cli.command()
@click.argument("event_id")
def delete(event_id: str):
    """Delete event."""
    service = get_service()
    service.events().delete(calendarId="primary", eventId=event_id).execute()
    click.echo("Event deleted.")


@cli.command()
@click.argument("event_id")
@click.argument("response", type=click.Choice(["accepted", "declined", "tentative"]))
def respond(event_id: str, response: str):
    """Respond to event invitation."""
    service = get_service()
    event = service.events().get(calendarId="primary", eventId=event_id).execute()
    attendees = event.get("attendees", [])
    if not attendees:
        raise click.ClickException("Cannot respond to event without attendees.")
    for a in attendees:
        if a.get("self"):
            a["responseStatus"] = response
    service.events().patch(calendarId="primary", eventId=event_id, body={"attendees": attendees}).execute()
    click.echo(f"Responded: {response}")


@cli.command("calendars")
def list_calendars():
    """List all calendars."""
    service = get_service()
    for cal in service.calendarList().list().execute().get("items", []):
        click.echo(f"- {cal.get('summary')} [{cal.get('id')}]")


if __name__ == "__main__":
    cli()


path: scripts/drive.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-api-python-client>=2.0.0", "google-auth>=2.0.0", "click>=8.0.0"]
# ///
"""Google Drive commands."""

import io
from pathlib import Path

import click
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload

TOKEN_FILE = Path.home() / ".simple-google-workspace" / "token.json"


def get_service():
    if not TOKEN_FILE.exists():
        raise click.ClickException("Not authenticated. Run: uv run scripts/auth.py login")
    creds = Credentials.from_authorized_user_file(TOKEN_FILE)
    return build("drive", "v3", credentials=creds)


@click.group()
def cli():
    """Google Drive commands."""


@cli.command("list")
@click.option("-q", "--query", help="Drive search query")
@click.option("-n", "--limit", default=20, help="Max results")
def list_files(query: str, limit: int):
    """List files in Drive."""
    service = get_service()
    results = service.files().list(
        pageSize=limit,
        q=query,
        fields="files(id,name,mimeType,modifiedTime)",
    ).execute()

    files = results.get("files", [])
    if not files:
        click.echo("No files found.")
        return

    for f in files:
        click.echo(f"[{f['id']}] {f['name']} ({f['mimeType'].split('.')[-1]})")


@cli.command()
@click.argument("query")
def search(query: str):
    """Search files by name."""
    service = get_service()
    results = service.files().list(
        q=f"name contains '{query}'",
        fields="files(id,name,mimeType)",
    ).execute()

    files = results.get("files", [])
    if not files:
        click.echo("No files found.")
        return

    for f in files:
        click.echo(f"[{f['id']}] {f['name']}")


@cli.command()
@click.argument("file_id")
def get(file_id: str):
    """Get file metadata."""
    service = get_service()
    f = service.files().get(
        fileId=file_id,
        fields="id,name,mimeType,size,modifiedTime,webViewLink",
    ).execute()

    click.echo(f"Name: {f.get('name')}")
    click.echo(f"ID: {f.get('id')}")
    click.echo(f"Type: {f.get('mimeType')}")
    click.echo(f"Size: {f.get('size', 'N/A')} bytes")
    click.echo(f"Link: {f.get('webViewLink')}")


@cli.command()
@click.argument("file_id")
def download(file_id: str):
    """Download file content (prints to stdout)."""
    service = get_service()

    # Get file type
    meta = service.files().get(fileId=file_id, fields="mimeType").execute()
    mime = meta.get("mimeType", "")

    # Export Google Docs types
    if "google-apps" in mime:
        export = "text/csv" if "spreadsheet" in mime else "text/plain"
        content = service.files().export(fileId=file_id, mimeType=export).execute()
        click.echo(content.decode("utf-8"))
    else:
        fh = io.BytesIO()
        request = service.files().get_media(fileId=file_id)
        downloader = MediaIoBaseDownload(fh, request)
        done = False
        while not done:
            _, done = downloader.next_chunk()
        click.echo(fh.getvalue().decode("utf-8", errors="replace"))


@cli.command()
@click.argument("name")
@click.option("--parent", help="Parent folder ID")
def create_folder(name: str, parent: str):
    """Create a folder."""
    service = get_service()
    metadata = {"name": name, "mimeType": "application/vnd.google-apps.folder"}
    if parent:
        metadata["parents"] = [parent]

    folder = service.files().create(body=metadata, fields="id,webViewLink").execute()
    click.echo(f"Created folder: {name}")
    click.echo(f"ID: {folder.get('id')}")
    click.echo(f"Link: {folder.get('webViewLink')}")


@cli.command()
@click.argument("name")
def find_folder(name: str):
    """Find folder by name."""
    service = get_service()
    results = service.files().list(
        q=f"name = '{name}' and mimeType = 'application/vnd.google-apps.folder'",
        fields="files(id,name)",
    ).execute()

    folders = results.get("files", [])
    if not folders:
        click.echo("No folders found.")
        return

    for f in folders:
        click.echo(f"[{f['id']}] {f['name']}")


if __name__ == "__main__":
    cli()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)