CtrlK
BlogDocsLog inGet started
Tessl Logo

odyssey4me/google-slides

Create and edit Google Slides presentations. Add or delete slides, insert text, shapes, and images. Use when asked to build a deck, create a slideshow, update a Google presentation, or edit slides.

94

Quality

94%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Skills
Evals
Files

google-slides.pyscripts/

#!/usr/bin/env python3
"""Google Slides integration skill for AI agents.

This is a self-contained script that provides Google Slides functionality.

Usage:
    python google-slides.py check
    python google-slides.py auth setup --client-id ID --client-secret SECRET
    python google-slides.py presentations create --title "My Presentation"
    python google-slides.py presentations get PRESENTATION_ID
    python google-slides.py slides create PRESENTATION_ID --layout BLANK
    python google-slides.py slides delete PRESENTATION_ID --slide-id SLIDE_ID
    python google-slides.py text insert PRESENTATION_ID --slide-id SLIDE_ID --text "Hello"
    python google-slides.py shapes create PRESENTATION_ID --slide-id SLIDE_ID --shape-type RECTANGLE

Requirements:
    pip install --user google-auth google-auth-oauthlib google-api-python-client keyring pyyaml
"""

from __future__ import annotations

# Standard library imports
import argparse
import contextlib
import json
import os
import sys
from pathlib import Path
from typing import Any

# ============================================================================
# DEPENDENCY CHECKS
# ============================================================================

try:
    from google.auth.transport.requests import Request
    from google.oauth2.credentials import Credentials
    from google_auth_oauthlib.flow import InstalledAppFlow

    GOOGLE_AUTH_AVAILABLE = True
except ImportError:
    GOOGLE_AUTH_AVAILABLE = False

try:
    from googleapiclient.discovery import build
    from googleapiclient.errors import HttpError

    GOOGLE_API_CLIENT_AVAILABLE = True
except ImportError:
    GOOGLE_API_CLIENT_AVAILABLE = False

try:
    import keyring

    KEYRING_AVAILABLE = True
except ImportError:
    KEYRING_AVAILABLE = False

try:
    import yaml

    YAML_AVAILABLE = True
except ImportError:
    YAML_AVAILABLE = False


# ============================================================================
# CONSTANTS
# ============================================================================

SERVICE_NAME = "agent-skills"
CONFIG_DIR = Path.home() / ".config" / "agent-skills"

# Google Slides API scopes - granular scopes for different operations
SLIDES_SCOPES_READONLY = ["https://www.googleapis.com/auth/presentations.readonly"]
SLIDES_SCOPES = ["https://www.googleapis.com/auth/presentations"]

# Minimal read-only scope (default)
SLIDES_SCOPES_DEFAULT = SLIDES_SCOPES_READONLY

# Drive API scope needed for PDF export
DRIVE_SCOPES_READONLY = ["https://www.googleapis.com/auth/drive.readonly"]

# EMU (English Metric Units) conversion - Slides uses EMUs
# 1 inch = 914400 EMUs
# 1 point = 12700 EMUs
EMU_PER_INCH = 914400
EMU_PER_PT = 12700


# ============================================================================
# KEYRING CREDENTIAL STORAGE
# ============================================================================


def get_credential(key: str) -> str | None:
    """Get a credential from the system keyring.

    Args:
        key: The credential key (e.g., "google-slides-token-json").

    Returns:
        The credential value, or None if not found.
    """
    return keyring.get_password(SERVICE_NAME, key)


def set_credential(key: str, value: str) -> None:
    """Store a credential in the system keyring.

    Args:
        key: The credential key.
        value: The credential value.
    """
    keyring.set_password(SERVICE_NAME, key, value)


def delete_credential(key: str) -> None:
    """Delete a credential from the system keyring.

    Args:
        key: The credential key.
    """
    with contextlib.suppress(keyring.errors.PasswordDeleteError):
        keyring.delete_password(SERVICE_NAME, key)


# ============================================================================
# CONFIGURATION MANAGEMENT
# ============================================================================


def load_config(service: str) -> dict[str, Any] | None:
    """Load configuration from file.

    Args:
        service: Service name.

    Returns:
        Configuration dictionary or None if not found.
    """
    config_file = CONFIG_DIR / f"{service}.yaml"
    if config_file.exists():
        with open(config_file) as f:
            return yaml.safe_load(f)
    return None


def save_config(service: str, config: dict[str, Any]) -> None:
    """Save configuration to file.

    Args:
        service: Service name.
        config: Configuration dictionary.
    """
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    config_file = CONFIG_DIR / f"{service}.yaml"
    with open(config_file, "w") as f:
        yaml.safe_dump(config, f, default_flow_style=False)


# ============================================================================
# GOOGLE AUTHENTICATION
# ============================================================================


class AuthenticationError(Exception):
    """Exception raised for authentication errors."""

    pass


def _build_oauth_config(client_id: str, client_secret: str) -> dict[str, Any]:
    """Build OAuth client configuration dict.

    Args:
        client_id: OAuth client ID.
        client_secret: OAuth client secret.

    Returns:
        OAuth client configuration dict.
    """
    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"],
        }
    }


def get_oauth_client_config(service: str) -> dict[str, Any]:
    """Get OAuth 2.0 client configuration from config file or environment.

    Priority:
    1. Service-specific config file (~/.config/agent-skills/{service}.yaml)
    2. Service-specific environment variables ({SERVICE}_CLIENT_ID, {SERVICE}_CLIENT_SECRET)
    3. Shared Google config file (~/.config/agent-skills/google.yaml)
    4. Shared environment variables (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET)

    Args:
        service: Service name (e.g., "google-slides").

    Returns:
        OAuth client configuration dict.

    Raises:
        AuthenticationError: If client configuration is not found.
    """
    # 1. Try service-specific config file first
    config = load_config(service)
    if config and "oauth_client" in config:
        client_id = config["oauth_client"].get("client_id")
        client_secret = config["oauth_client"].get("client_secret")
        if client_id and client_secret:
            return _build_oauth_config(client_id, client_secret)

    # 2. Try service-specific environment variables
    prefix = service.upper().replace("-", "_")
    client_id = os.environ.get(f"{prefix}_CLIENT_ID")
    client_secret = os.environ.get(f"{prefix}_CLIENT_SECRET")
    if client_id and client_secret:
        return _build_oauth_config(client_id, client_secret)

    # 3. Try shared Google config file
    shared_config = load_config("google")
    if shared_config and "oauth_client" in shared_config:
        client_id = shared_config["oauth_client"].get("client_id")
        client_secret = shared_config["oauth_client"].get("client_secret")
        if client_id and client_secret:
            return _build_oauth_config(client_id, client_secret)

    # 4. Try shared environment variables
    client_id = os.environ.get("GOOGLE_CLIENT_ID")
    client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
    if client_id and client_secret:
        return _build_oauth_config(client_id, client_secret)

    raise AuthenticationError(
        f"OAuth client credentials not found for {service}. "
        f"Options:\n"
        f"  1. Service config: Run python google-slides.py auth setup --client-id YOUR_ID --client-secret YOUR_SECRET\n"
        f"  2. Service env vars: Set GOOGLE_SLIDES_CLIENT_ID and GOOGLE_SLIDES_CLIENT_SECRET\n"
        f"  3. Shared config: Create ~/.config/agent-skills/google.yaml with oauth_client credentials\n"
        f"  4. Shared env vars: Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET"
    )


def _run_oauth_flow(service: str, scopes: list[str]) -> Credentials:
    """Run OAuth browser flow and store resulting token.

    Args:
        service: Service name (e.g., "google-slides").
        scopes: List of OAuth scopes required.

    Returns:
        Valid Google credentials.

    Raises:
        AuthenticationError: If OAuth flow fails.
    """
    client_config = get_oauth_client_config(service)
    flow = InstalledAppFlow.from_client_config(client_config, scopes)
    creds = flow.run_local_server(port=0)  # Opens browser for consent
    # Save token to keyring for future use
    set_credential(f"{service}-token-json", creds.to_json())
    return creds


def get_google_credentials(service: str, scopes: list[str]) -> Credentials:
    """Get Google credentials for human-in-the-loop use cases.

    Priority:
    1. Saved OAuth tokens from keyring - from previous OAuth flow
    2. OAuth 2.0 flow - opens browser for user consent

    Note: Service account authentication is NOT supported - this is
    designed for interactive human use cases only.

    Args:
        service: Service name (e.g., "google-slides").
        scopes: List of OAuth scopes required.

    Returns:
        Valid Google credentials.

    Raises:
        AuthenticationError: If authentication fails.
    """
    # 1. Try keyring-stored OAuth token from previous flow
    token_json = get_credential(f"{service}-token-json")
    if token_json:
        try:
            token_data = json.loads(token_json)
            creds = Credentials.from_authorized_user_info(token_data, scopes)
            if creds and creds.valid:
                # Check if stored token has all requested scopes
                granted = set(token_data.get("scopes", []))
                requested = set(scopes)
                if granted and not requested.issubset(granted):
                    # Merge scopes so user doesn't lose existing access
                    merged = list(granted | requested)
                    print(
                        "Current token lacks required scopes. "
                        "Opening browser for re-authentication...",
                        file=sys.stderr,
                    )
                    delete_credential(f"{service}-token-json")
                    return _run_oauth_flow(service, merged)
                return creds
            # Refresh if expired but has refresh token
            if creds and creds.expired and creds.refresh_token:
                creds.refresh(Request())
                # Save refreshed token
                set_credential(f"{service}-token-json", creds.to_json())
                return creds
        except Exception:
            # Invalid or corrupted token, fall through to OAuth flow
            pass

    # 2. Initiate OAuth flow - human interaction required
    try:
        return _run_oauth_flow(service, scopes)
    except Exception as e:
        raise AuthenticationError(f"OAuth flow failed: {e}") from e


def build_slides_service(scopes: list[str] | None = None):
    """Build and return Google Slides API service.

    Args:
        scopes: List of OAuth scopes to request. Defaults to read-only.

    Returns:
        Google Slides API service object.

    Raises:
        AuthenticationError: If authentication fails.
    """
    if scopes is None:
        scopes = SLIDES_SCOPES_DEFAULT
    creds = get_google_credentials("google-slides", scopes)
    return build("slides", "v1", credentials=creds)


def build_drive_service(scopes: list[str] | None = None):
    """Build and return Google Drive API service for export operations.

    Args:
        scopes: List of OAuth scopes to request. Defaults to drive.readonly.

    Returns:
        Google Drive API service object.

    Raises:
        AuthenticationError: If authentication fails.
    """
    if scopes is None:
        scopes = DRIVE_SCOPES_READONLY
    creds = get_google_credentials("google-slides", scopes)
    return build("drive", "v3", credentials=creds)


# ============================================================================
# GOOGLE SLIDES API ERROR HANDLING
# ============================================================================


class SlidesAPIError(Exception):
    """Exception raised for Google Slides API errors."""

    def __init__(self, message: str, status_code: int | None = None, details: Any = None):
        super().__init__(message)
        self.status_code = status_code
        self.details = details


def handle_api_error(error: HttpError) -> None:
    """Convert Google API HttpError to SlidesAPIError.

    Args:
        error: HttpError from Google API.

    Raises:
        SlidesAPIError: With appropriate message and status code.
    """
    status_code = error.resp.status
    reason = error.resp.reason
    details = None

    try:
        error_content = json.loads(error.content.decode("utf-8"))
        details = error_content.get("error", {})
        message = details.get("message", reason)
    except Exception:
        message = reason

    # Check for insufficient scope error (403)
    if status_code == 403 and "insufficient" in message.lower():
        scope_help = (
            "\n\nInsufficient OAuth scope. This operation requires additional permissions.\n"
            "To re-authenticate with the required scopes:\n\n"
            "  1. Reset token: python scripts/google-slides.py auth reset\n"
            "  2. Re-run: python scripts/google-slides.py check\n\n"
            "For setup help, see: docs/google-oauth-setup.md\n"
        )
        message = f"{message}{scope_help}"

    raise SlidesAPIError(
        f"Google Slides API error: {message} (HTTP {status_code})",
        status_code=status_code,
        details=details,
    )


# ============================================================================
# PRESENTATION OPERATIONS
# ============================================================================


def create_presentation(service, title: str) -> dict[str, Any]:
    """Create a new Google Slides presentation.

    Args:
        service: Google Slides API service object.
        title: Presentation title.

    Returns:
        Created presentation dictionary with presentationId.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        body = {"title": title}
        presentation = service.presentations().create(body=body).execute()
        return presentation
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def get_presentation(service, presentation_id: str) -> dict[str, Any]:
    """Get a presentation by ID.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.

    Returns:
        Presentation dictionary with metadata and slides.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        presentation = service.presentations().get(presentationId=presentation_id).execute()
        return presentation
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def read_presentation_content(service, presentation_id: str) -> str:
    """Extract text content from all slides in a presentation.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.

    Returns:
        Text content from all slides, with slide separators.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    presentation = get_presentation(service, presentation_id)
    slides = presentation.get("slides", [])

    output_parts = []
    for idx, slide in enumerate(slides):
        slide_text = _extract_slide_text(slide)
        if slide_text.strip():
            output_parts.append(f"--- Slide {idx + 1} ---\n{slide_text}")

    return "\n\n".join(output_parts)


def _extract_slide_text(slide: dict) -> str:
    """Extract all text from a slide's page elements.

    Args:
        slide: Slide dictionary from the API.

    Returns:
        Combined text from all elements on the slide.
    """
    text_parts = []

    for element in slide.get("pageElements", []):
        # Handle shapes with text (including text boxes)
        if "shape" in element:
            shape = element["shape"]
            if "text" in shape:
                text = _extract_text_from_text_content(shape["text"])
                if text.strip():
                    text_parts.append(text.strip())

        # Handle tables
        if "table" in element:
            table_text = _extract_table_text(element["table"])
            if table_text.strip():
                text_parts.append(table_text)

    return "\n".join(text_parts)


def _extract_text_from_text_content(text_content: dict) -> str:
    """Extract text from a textContent structure.

    Args:
        text_content: Text content dictionary from the API.

    Returns:
        Combined text string.
    """
    parts = []
    for text_elem in text_content.get("textElements", []):
        if "textRun" in text_elem:
            parts.append(text_elem["textRun"].get("content", ""))
    return "".join(parts)


def _extract_table_text(table: dict) -> str:
    """Extract text from a table element as markdown.

    Args:
        table: Table dictionary from the API.

    Returns:
        Markdown-formatted table string.
    """
    rows_text = []
    for row_idx, row in enumerate(table.get("tableRows", [])):
        cell_texts = []
        for cell in row.get("tableCells", []):
            if "text" in cell:
                cell_texts.append(_extract_text_from_text_content(cell["text"]).strip())
            else:
                cell_texts.append("")
        if cell_texts:
            rows_text.append("| " + " | ".join(cell_texts) + " |")
            if row_idx == 0:
                rows_text.append("| " + " | ".join(["---"] * len(cell_texts)) + " |")
    return "\n".join(rows_text)


def export_presentation_as_pdf(presentation_id: str) -> bytes:
    """Export presentation as PDF using Google's native export.

    Uses the Drive API to export the presentation in PDF format.

    Args:
        presentation_id: The Google Slides presentation ID.

    Returns:
        PDF content as bytes.

    Raises:
        SlidesAPIError: If the export fails.
    """
    try:
        service = build_drive_service()
        response = (
            service.files().export(fileId=presentation_id, mimeType="application/pdf").execute()
        )
        return response
    except HttpError as e:
        handle_api_error(e)
        return b""  # Unreachable


# ============================================================================
# SLIDE OPERATIONS
# ============================================================================


def create_slide(
    service, presentation_id: str, layout: str = "BLANK", insert_index: int | None = None
) -> dict[str, Any]:
    """Add a new slide to a presentation.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.
        layout: Slide layout (BLANK, TITLE, TITLE_AND_BODY, etc.). Default is BLANK.
        insert_index: Optional index where to insert the slide. If None, appends to end.

    Returns:
        Batch update response from the API.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        requests = []

        # Map simple layout names to predefined layout types
        layout_map = {
            "BLANK": "BLANK",
            "TITLE": "TITLE",
            "TITLE_AND_BODY": "TITLE_AND_BODY",
            "TITLE_ONLY": "TITLE_ONLY",
            "SECTION_HEADER": "SECTION_HEADER",
            "SECTION_TITLE_AND_DESCRIPTION": "SECTION_TITLE_AND_DESCRIPTION",
            "ONE_COLUMN_TEXT": "ONE_COLUMN_TEXT",
            "MAIN_POINT": "MAIN_POINT",
            "BIG_NUMBER": "BIG_NUMBER",
        }

        predefined_layout = layout_map.get(layout.upper(), "BLANK")

        create_slide_request: dict[str, Any] = {
            "createSlide": {"slideLayoutReference": {"predefinedLayout": predefined_layout}}
        }

        if insert_index is not None:
            create_slide_request["createSlide"]["insertionIndex"] = insert_index

        requests.append(create_slide_request)

        body = {"requests": requests}
        result = (
            service.presentations().batchUpdate(presentationId=presentation_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def delete_slide(service, presentation_id: str, slide_id: str) -> dict[str, Any]:
    """Delete a slide from a presentation.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.
        slide_id: The slide object ID (not index).

    Returns:
        Batch update response from the API.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        requests = [{"deleteObject": {"objectId": slide_id}}]

        body = {"requests": requests}
        result = (
            service.presentations().batchUpdate(presentationId=presentation_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


# ============================================================================
# TEXT OPERATIONS
# ============================================================================


def insert_text(
    service,
    presentation_id: str,
    slide_id: str,
    text: str,
    x: float = 100,
    y: float = 100,
    width: float = 400,
    height: float = 100,
) -> dict[str, Any]:
    """Insert text into a slide.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.
        slide_id: The slide object ID.
        text: Text to insert.
        x: X coordinate in points (default: 100).
        y: Y coordinate in points (default: 100).
        width: Text box width in points (default: 400).
        height: Text box height in points (default: 100).

    Returns:
        Batch update response from the API.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        # Convert points to EMUs
        x_emu = int(x * EMU_PER_PT)
        y_emu = int(y * EMU_PER_PT)
        width_emu = int(width * EMU_PER_PT)
        height_emu = int(height * EMU_PER_PT)

        # Generate a unique ID for the text box
        text_box_id = f"TextBox_{slide_id}_{hash(text) % 1000000}"

        requests = [
            {
                "createShape": {
                    "objectId": text_box_id,
                    "shapeType": "TEXT_BOX",
                    "elementProperties": {
                        "pageObjectId": slide_id,
                        "size": {
                            "width": {"magnitude": width_emu, "unit": "EMU"},
                            "height": {"magnitude": height_emu, "unit": "EMU"},
                        },
                        "transform": {
                            "scaleX": 1,
                            "scaleY": 1,
                            "translateX": x_emu,
                            "translateY": y_emu,
                            "unit": "EMU",
                        },
                    },
                }
            },
            {"insertText": {"objectId": text_box_id, "text": text, "insertionIndex": 0}},
        ]

        body = {"requests": requests}
        result = (
            service.presentations().batchUpdate(presentationId=presentation_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


# ============================================================================
# SHAPE OPERATIONS
# ============================================================================


def create_shape(
    service,
    presentation_id: str,
    slide_id: str,
    shape_type: str,
    x: float = 100,
    y: float = 100,
    width: float = 200,
    height: float = 200,
) -> dict[str, Any]:
    """Create a shape on a slide.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.
        slide_id: The slide object ID.
        shape_type: Shape type (RECTANGLE, ELLIPSE, TRIANGLE, etc.).
        x: X coordinate in points (default: 100).
        y: Y coordinate in points (default: 100).
        width: Shape width in points (default: 200).
        height: Shape height in points (default: 200).

    Returns:
        Batch update response from the API.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        # Convert points to EMUs
        x_emu = int(x * EMU_PER_PT)
        y_emu = int(y * EMU_PER_PT)
        width_emu = int(width * EMU_PER_PT)
        height_emu = int(height * EMU_PER_PT)

        # Generate a unique ID for the shape
        shape_id = f"Shape_{slide_id}_{shape_type}_{hash(f'{x}{y}') % 1000000}"

        requests = [
            {
                "createShape": {
                    "objectId": shape_id,
                    "shapeType": shape_type.upper(),
                    "elementProperties": {
                        "pageObjectId": slide_id,
                        "size": {
                            "width": {"magnitude": width_emu, "unit": "EMU"},
                            "height": {"magnitude": height_emu, "unit": "EMU"},
                        },
                        "transform": {
                            "scaleX": 1,
                            "scaleY": 1,
                            "translateX": x_emu,
                            "translateY": y_emu,
                            "unit": "EMU",
                        },
                    },
                }
            }
        ]

        body = {"requests": requests}
        result = (
            service.presentations().batchUpdate(presentationId=presentation_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def create_image(
    service,
    presentation_id: str,
    slide_id: str,
    image_url: str,
    x: float = 100,
    y: float = 100,
    width: float = 300,
    height: float = 200,
) -> dict[str, Any]:
    """Create an image on a slide.

    Args:
        service: Google Slides API service object.
        presentation_id: The presentation ID.
        slide_id: The slide object ID.
        image_url: URL of the image to insert.
        x: X coordinate in points (default: 100).
        y: Y coordinate in points (default: 100).
        width: Image width in points (default: 300).
        height: Image height in points (default: 200).

    Returns:
        Batch update response from the API.

    Raises:
        SlidesAPIError: If the API call fails.
    """
    try:
        # Convert points to EMUs
        x_emu = int(x * EMU_PER_PT)
        y_emu = int(y * EMU_PER_PT)
        width_emu = int(width * EMU_PER_PT)
        height_emu = int(height * EMU_PER_PT)

        # Generate a unique ID for the image
        image_id = f"Image_{slide_id}_{hash(image_url) % 1000000}"

        requests = [
            {
                "createImage": {
                    "objectId": image_id,
                    "url": image_url,
                    "elementProperties": {
                        "pageObjectId": slide_id,
                        "size": {
                            "width": {"magnitude": width_emu, "unit": "EMU"},
                            "height": {"magnitude": height_emu, "unit": "EMU"},
                        },
                        "transform": {
                            "scaleX": 1,
                            "scaleY": 1,
                            "translateX": x_emu,
                            "translateY": y_emu,
                            "unit": "EMU",
                        },
                    },
                }
            }
        ]

        body = {"requests": requests}
        result = (
            service.presentations().batchUpdate(presentationId=presentation_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


# ============================================================================
# OUTPUT FORMATTING
# ============================================================================


def format_presentation_summary(presentation: dict[str, Any]) -> str:
    """Format a presentation for display.

    Args:
        presentation: Presentation dictionary from Google Slides API.

    Returns:
        Formatted string.
    """
    title = presentation.get("title", "(Untitled)")
    presentation_id = presentation.get("presentationId", "(Unknown)")
    slides = presentation.get("slides", [])

    return (
        f"### {title}\n"
        f"- **Presentation ID:** {presentation_id}\n"
        f"- **Slides:** {len(slides)}\n"
        f"- **URL:** https://docs.google.com/presentation/d/{presentation_id}/edit"
    )


def format_slide_info(slide: dict[str, Any], index: int) -> str:
    """Format slide information for display.

    Args:
        slide: Slide dictionary from Google Slides API.
        index: Slide index (0-based).

    Returns:
        Formatted string.
    """
    slide_id = slide.get("objectId", "(Unknown)")
    layout = slide.get("slideProperties", {}).get("layoutObjectId", "(Unknown)")

    # Count elements on the slide
    elements = slide.get("pageElements", [])
    element_counts = {"shapes": 0, "images": 0, "text": 0, "other": 0}

    for element in elements:
        if "shape" in element:
            if element["shape"].get("shapeType") == "TEXT_BOX":
                element_counts["text"] += 1
            else:
                element_counts["shapes"] += 1
        elif "image" in element:
            element_counts["images"] += 1
        else:
            element_counts["other"] += 1

    total = sum(element_counts.values())
    return (
        f"### Slide {index + 1}\n"
        f"- **ID:** {slide_id}\n"
        f"- **Layout:** {layout}\n"
        f"- **Elements:** {total} ({element_counts['text']} text, "
        f"{element_counts['shapes']} shapes, {element_counts['images']} images, "
        f"{element_counts['other']} other)"
    )


# ============================================================================
# HEALTH CHECK
# ============================================================================


def check_slides_connectivity() -> dict[str, Any]:
    """Check Google Slides API connectivity and authentication.

    Returns:
        Dictionary with status information including available scopes.
    """
    result = {
        "authenticated": False,
        "scopes": None,
        "error": None,
    }

    try:
        # Get credentials to check scopes
        creds = get_google_credentials("google-slides", SLIDES_SCOPES_DEFAULT)

        # Check which scopes are available
        available_scopes = []
        if hasattr(creds, "scopes"):
            available_scopes = creds.scopes
        elif hasattr(creds, "_scopes"):
            available_scopes = creds._scopes

        # Build service - if this works, we're authenticated
        service = build("slides", "v1", credentials=creds)

        # Try a simple API call to verify connectivity
        # Create a test presentation
        test_pres = service.presentations().create(body={"title": "_test_connectivity"}).execute()
        test_pres_id = test_pres.get("presentationId")

        result["authenticated"] = True
        result["test_presentation_id"] = test_pres_id
        result["scopes"] = {
            "readonly": any("presentations.readonly" in s for s in available_scopes),
            "write": any("presentations" in s and "readonly" not in s for s in available_scopes),
            "all_scopes": available_scopes,
        }
    except Exception as e:
        result["error"] = str(e)

    return result


# ============================================================================
# CLI COMMAND HANDLERS
# ============================================================================


def cmd_check(_args):
    """Handle 'check' command."""
    print("Checking Google Slides connectivity...")
    result = check_slides_connectivity()

    if result["authenticated"]:
        print("✓ Successfully authenticated to Google Slides")

        # Display scope information
        scopes = result.get("scopes", {})
        if scopes:
            print("\nGranted OAuth Scopes:")
            print(f"  Read-only (presentations.readonly): {'✓' if scopes.get('readonly') else '✗'}")
            print(f"  Write (presentations):               {'✓' if scopes.get('write') else '✗'}")

            # Check if write scope is granted
            if not scopes.get("write"):
                print("\n⚠️  Write scope not granted. Some operations will fail.")
                print("   To grant full access, reset and re-authenticate:")
                print()
                print("   1. Reset token: python scripts/google-slides.py auth reset")
                print("   2. Re-run: python scripts/google-slides.py check")
                print()
                print("   See: docs/google-oauth-setup.md")

        print(f"\nTest presentation created: {result.get('test_presentation_id')}")
        print("(You can delete this test presentation from Google Drive)")
        return 0
    else:
        print(f"✗ Authentication failed: {result['error']}")
        print()
        print("Setup instructions:")
        print()
        print("  1. Set up a GCP project with OAuth credentials:")
        print("     See: docs/gcp-project-setup.md")
        print()
        print("  2. Configure your credentials:")
        print("     Create ~/.config/agent-skills/google.yaml:")
        print()
        print("     oauth_client:")
        print("       client_id: YOUR_CLIENT_ID.apps.googleusercontent.com")
        print("       client_secret: YOUR_CLIENT_SECRET")
        print()
        print("  3. Run check again to trigger OAuth flow:")
        print("     python scripts/google-slides.py check")
        print()
        print("For detailed setup instructions, see: docs/google-oauth-setup.md")
        return 1


def cmd_auth_setup(args):
    """Handle 'auth setup' command."""
    if not args.client_id or not args.client_secret:
        print("Error: Both --client-id and --client-secret are required", file=sys.stderr)
        return 1

    config = load_config("google-slides") or {}
    config["oauth_client"] = {
        "client_id": args.client_id,
        "client_secret": args.client_secret,
    }
    save_config("google-slides", config)
    print("✓ OAuth client credentials saved to config file")
    print(f"  Config location: {CONFIG_DIR / 'google-slides.yaml'}")
    print("\nNext step: Run any Google Slides command to initiate OAuth flow")
    return 0


def cmd_auth_reset(_args):
    """Handle 'auth reset' command."""
    delete_credential("google-slides-token-json")
    print("OAuth token cleared. Next command will trigger re-authentication.")
    return 0


def cmd_auth_status(_args):
    """Handle 'auth status' command."""
    token_json = get_credential("google-slides-token-json")
    if not token_json:
        print("No OAuth token stored.")
        return 1

    try:
        token_data = json.loads(token_json)
    except json.JSONDecodeError:
        print("Stored token is corrupted.")
        return 1

    print("OAuth token is stored.")

    # Granted scopes
    scopes = token_data.get("scopes", [])
    if scopes:
        print("\nGranted scopes:")
        for scope in scopes:
            print(f"  - {scope}")
    else:
        print("\nGranted scopes: (unknown - legacy token)")

    # Refresh token
    has_refresh = bool(token_data.get("refresh_token"))
    print(f"\nRefresh token: {'present' if has_refresh else 'missing'}")

    # Expiry
    expiry = token_data.get("expiry")
    if expiry:
        print(f"Token expiry: {expiry}")

    # Client ID (truncated)
    client_id = token_data.get("client_id", "")
    if client_id:
        truncated = client_id[:16] + "..." if len(client_id) > 16 else client_id
        print(f"Client ID: {truncated}")

    return 0


def cmd_presentations_create(args):
    """Handle 'presentations create' command."""
    service = build_slides_service(SLIDES_SCOPES)
    presentation = create_presentation(service, args.title)

    if args.json:
        print(json.dumps(presentation, indent=2))
    else:
        print("✓ Presentation created successfully")
        print(format_presentation_summary(presentation))

    return 0


def cmd_presentations_get(args):
    """Handle 'presentations get' command."""
    service = build_slides_service(SLIDES_SCOPES_READONLY)
    presentation = get_presentation(service, args.presentation_id)

    if args.json:
        print(json.dumps(presentation, indent=2))
    else:
        print(format_presentation_summary(presentation))
        print()

        # Show slide details
        slides = presentation.get("slides", [])
        if slides:
            print("Slides:")
            for idx, slide in enumerate(slides):
                print(format_slide_info(slide, idx))

    return 0


def cmd_presentations_read(args):
    """Handle 'presentations read' command."""
    if args.format == "pdf":
        content = export_presentation_as_pdf(args.presentation_id)
        output_file = args.output or f"{args.presentation_id}.pdf"
        with open(output_file, "wb") as f:
            f.write(content)
        print(f"PDF saved to: {output_file}")
        return 0
    else:
        service = build_slides_service(SLIDES_SCOPES_READONLY)
        content = read_presentation_content(service, args.presentation_id)

    if args.json:
        print(json.dumps({"content": content}, indent=2))
    else:
        print(content)

    return 0


def cmd_slides_create(args):
    """Handle 'slides create' command."""
    service = build_slides_service(SLIDES_SCOPES)
    result = create_slide(service, args.presentation_id, args.layout, args.index)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        # Extract the new slide info from the reply
        reply = result.get("replies", [{}])[0]
        new_slide = reply.get("createSlide", {})
        slide_id = new_slide.get("objectId", "N/A")
        print("✓ Slide created successfully")
        print(f"  Slide ID: {slide_id}")
        print(f"  Layout: {args.layout}")

    return 0


def cmd_slides_delete(args):
    """Handle 'slides delete' command."""
    service = build_slides_service(SLIDES_SCOPES)
    result = delete_slide(service, args.presentation_id, args.slide_id)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Slide deleted successfully")

    return 0


def cmd_text_insert(args):
    """Handle 'text insert' command."""
    service = build_slides_service(SLIDES_SCOPES)
    result = insert_text(
        service,
        args.presentation_id,
        args.slide_id,
        args.text,
        args.x,
        args.y,
        args.width,
        args.height,
    )

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Text inserted successfully")
        print(f"  Text: {args.text}")
        print(f"  Position: ({args.x}, {args.y}) points")
        print(f"  Size: {args.width} x {args.height} points")

    return 0


def cmd_shapes_create(args):
    """Handle 'shapes create' command."""
    service = build_slides_service(SLIDES_SCOPES)
    result = create_shape(
        service,
        args.presentation_id,
        args.slide_id,
        args.shape_type,
        args.x,
        args.y,
        args.width,
        args.height,
    )

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Shape created successfully")
        print(f"  Type: {args.shape_type}")
        print(f"  Position: ({args.x}, {args.y}) points")
        print(f"  Size: {args.width} x {args.height} points")

    return 0


def cmd_images_create(args):
    """Handle 'images create' command."""
    service = build_slides_service(SLIDES_SCOPES)
    result = create_image(
        service,
        args.presentation_id,
        args.slide_id,
        args.image_url,
        args.x,
        args.y,
        args.width,
        args.height,
    )

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Image created successfully")
        print(f"  URL: {args.image_url}")
        print(f"  Position: ({args.x}, {args.y}) points")
        print(f"  Size: {args.width} x {args.height} points")

    return 0


# ============================================================================
# CLI ARGUMENT PARSER
# ============================================================================


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        description="Google Slides integration for AI agents",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    subparsers = parser.add_subparsers(dest="command", help="Command to execute")

    # check command
    subparsers.add_parser("check", help="Check Google Slides connectivity and authentication")

    # auth commands
    auth_parser = subparsers.add_parser("auth", help="Authentication management")
    auth_subparsers = auth_parser.add_subparsers(dest="auth_command")

    setup_parser = auth_subparsers.add_parser("setup", help="Setup OAuth client credentials")
    setup_parser.add_argument("--client-id", required=True, help="OAuth client ID")
    setup_parser.add_argument("--client-secret", required=True, help="OAuth client secret")

    auth_subparsers.add_parser("reset", help="Clear stored OAuth token")
    auth_subparsers.add_parser("status", help="Show current token info")

    # presentations commands
    presentations_parser = subparsers.add_parser("presentations", help="Presentation operations")
    presentations_subparsers = presentations_parser.add_subparsers(dest="presentations_command")

    create_parser = presentations_subparsers.add_parser("create", help="Create a new presentation")
    create_parser.add_argument("--title", required=True, help="Presentation title")
    create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    get_parser = presentations_subparsers.add_parser("get", help="Get presentation metadata")
    get_parser.add_argument("presentation_id", help="Presentation ID")
    get_parser.add_argument("--json", action="store_true", help="Output as JSON")

    read_parser = presentations_subparsers.add_parser("read", help="Read presentation text content")
    read_parser.add_argument("presentation_id", help="Presentation ID")
    read_parser.add_argument(
        "--format",
        choices=["text", "pdf"],
        default="text",
        help="Output format: text (extracted text) or pdf (native export)",
    )
    read_parser.add_argument(
        "--output",
        "-o",
        help="Output file path (used with pdf format)",
    )
    read_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # slides commands
    slides_parser = subparsers.add_parser("slides", help="Slide operations")
    slides_subparsers = slides_parser.add_subparsers(dest="slides_command")

    slides_create_parser = slides_subparsers.add_parser("create", help="Add new slide")
    slides_create_parser.add_argument("presentation_id", help="Presentation ID")
    slides_create_parser.add_argument(
        "--layout", default="BLANK", help="Slide layout (BLANK, TITLE, TITLE_AND_BODY, etc.)"
    )
    slides_create_parser.add_argument("--index", type=int, help="Insert index (optional)")
    slides_create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    slides_delete_parser = slides_subparsers.add_parser("delete", help="Delete a slide")
    slides_delete_parser.add_argument("presentation_id", help="Presentation ID")
    slides_delete_parser.add_argument("--slide-id", required=True, help="Slide object ID to delete")
    slides_delete_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # text commands
    text_parser = subparsers.add_parser("text", help="Text operations")
    text_subparsers = text_parser.add_subparsers(dest="text_command")

    text_insert_parser = text_subparsers.add_parser("insert", help="Insert text into slide")
    text_insert_parser.add_argument("presentation_id", help="Presentation ID")
    text_insert_parser.add_argument("--slide-id", required=True, help="Slide object ID")
    text_insert_parser.add_argument("--text", required=True, help="Text to insert")
    text_insert_parser.add_argument(
        "--x", type=float, default=100, help="X position in points (default: 100)"
    )
    text_insert_parser.add_argument(
        "--y", type=float, default=100, help="Y position in points (default: 100)"
    )
    text_insert_parser.add_argument(
        "--width", type=float, default=400, help="Text box width in points (default: 400)"
    )
    text_insert_parser.add_argument(
        "--height", type=float, default=100, help="Text box height in points (default: 100)"
    )
    text_insert_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # shapes commands
    shapes_parser = subparsers.add_parser("shapes", help="Shape operations")
    shapes_subparsers = shapes_parser.add_subparsers(dest="shapes_command")

    shapes_create_parser = shapes_subparsers.add_parser("create", help="Create a shape")
    shapes_create_parser.add_argument("presentation_id", help="Presentation ID")
    shapes_create_parser.add_argument("--slide-id", required=True, help="Slide object ID")
    shapes_create_parser.add_argument(
        "--shape-type", required=True, help="Shape type (RECTANGLE, ELLIPSE, TRIANGLE, etc.)"
    )
    shapes_create_parser.add_argument(
        "--x", type=float, default=100, help="X position in points (default: 100)"
    )
    shapes_create_parser.add_argument(
        "--y", type=float, default=100, help="Y position in points (default: 100)"
    )
    shapes_create_parser.add_argument(
        "--width", type=float, default=200, help="Width in points (default: 200)"
    )
    shapes_create_parser.add_argument(
        "--height", type=float, default=200, help="Height in points (default: 200)"
    )
    shapes_create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # images commands
    images_parser = subparsers.add_parser("images", help="Image operations")
    images_subparsers = images_parser.add_subparsers(dest="images_command")

    images_create_parser = images_subparsers.add_parser("create", help="Create an image")
    images_create_parser.add_argument("presentation_id", help="Presentation ID")
    images_create_parser.add_argument("--slide-id", required=True, help="Slide object ID")
    images_create_parser.add_argument("--image-url", required=True, help="Image URL")
    images_create_parser.add_argument(
        "--x", type=float, default=100, help="X position in points (default: 100)"
    )
    images_create_parser.add_argument(
        "--y", type=float, default=100, help="Y position in points (default: 100)"
    )
    images_create_parser.add_argument(
        "--width", type=float, default=300, help="Width in points (default: 300)"
    )
    images_create_parser.add_argument(
        "--height", type=float, default=200, help="Height in points (default: 200)"
    )
    images_create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    return parser


# ============================================================================
# MAIN
# ============================================================================


def main():
    """Main entry point."""
    # Check dependencies first (allows --help to work even if deps missing)
    parser = build_parser()
    args = parser.parse_args()

    # Now check dependencies if not just showing help
    if not GOOGLE_AUTH_AVAILABLE:
        print(
            "Error: Google auth libraries not found. Install with: "
            "pip install --user google-auth google-auth-oauthlib",
            file=sys.stderr,
        )
        return 1

    if not GOOGLE_API_CLIENT_AVAILABLE:
        print(
            "Error: 'google-api-python-client' not found. Install with: "
            "pip install --user google-api-python-client",
            file=sys.stderr,
        )
        return 1

    if not KEYRING_AVAILABLE:
        print(
            "Error: 'keyring' library not found. Install with: pip install --user keyring",
            file=sys.stderr,
        )
        return 1

    if not YAML_AVAILABLE:
        print(
            "Error: 'pyyaml' library not found. Install with: pip install --user pyyaml",
            file=sys.stderr,
        )
        return 1

    if not args.command:
        parser.print_help()
        return 1

    try:
        # Route to command handlers
        if args.command == "check":
            return cmd_check(args)
        elif args.command == "auth":
            if args.auth_command == "setup":
                return cmd_auth_setup(args)
            elif args.auth_command == "reset":
                return cmd_auth_reset(args)
            elif args.auth_command == "status":
                return cmd_auth_status(args)
        elif args.command == "presentations":
            if args.presentations_command == "create":
                return cmd_presentations_create(args)
            elif args.presentations_command == "get":
                return cmd_presentations_get(args)
            elif args.presentations_command == "read":
                return cmd_presentations_read(args)
        elif args.command == "slides":
            if args.slides_command == "create":
                return cmd_slides_create(args)
            elif args.slides_command == "delete":
                return cmd_slides_delete(args)
        elif args.command == "text":
            if args.text_command == "insert":
                return cmd_text_insert(args)
        elif args.command == "shapes":
            if args.shapes_command == "create":
                return cmd_shapes_create(args)
        elif args.command == "images" and args.images_command == "create":
            return cmd_images_create(args)

        parser.print_help()
        return 1

    except (SlidesAPIError, AuthenticationError) as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\nInterrupted", file=sys.stderr)
        return 130
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

Install with Tessl CLI

npx tessl i odyssey4me/google-slides@0.1.3

SKILL.md

tile.json