CtrlK
BlogDocsLog inGet started
Tessl Logo

odyssey4me/google-sheets

Read, write, and format Google Sheets spreadsheets. Manage cell values, ranges, formulas, pivot tables, and charts. Use when asked to update a gsheet, edit a Google spreadsheet, add formulas, or work with spreadsheet data.

89

Quality

89%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Skills
Evals
Files

google-sheets.pyscripts/

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

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

Usage:
    python google-sheets.py check
    python google-sheets.py auth setup --client-id ID --client-secret SECRET
    python google-sheets.py spreadsheets create --title "My Spreadsheet"
    python google-sheets.py spreadsheets get SPREADSHEET_ID
    python google-sheets.py values read SPREADSHEET_ID --range "Sheet1!A1:D5"
    python google-sheets.py values write SPREADSHEET_ID --range "Sheet1!A1" --values '[[1,2,3]]'
    python google-sheets.py values append SPREADSHEET_ID --range "Sheet1" --values '[[4,5,6]]'

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 Sheets API scopes - granular scopes for different operations
SHEETS_SCOPES_READONLY = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
SHEETS_SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]

# Minimal read-only scope (default)
SHEETS_SCOPES_DEFAULT = SHEETS_SCOPES_READONLY


# ============================================================================
# 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-sheets-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-sheets").

    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-sheets.py auth setup --client-id YOUR_ID --client-secret YOUR_SECRET\n"
        f"  2. Service env vars: Set GOOGLE_SHEETS_CLIENT_ID and GOOGLE_SHEETS_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-sheets").
        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-sheets").
        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_sheets_service(scopes: list[str] | None = None):
    """Build and return Google Sheets API service.

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

    Returns:
        Google Sheets API service object.

    Raises:
        AuthenticationError: If authentication fails.
    """
    if scopes is None:
        scopes = SHEETS_SCOPES_DEFAULT
    creds = get_google_credentials("google-sheets", scopes)
    return build("sheets", "v4", credentials=creds)


# ============================================================================
# GOOGLE SHEETS API ERROR HANDLING
# ============================================================================


class SheetsAPIError(Exception):
    """Exception raised for Google Sheets 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 SheetsAPIError.

    Args:
        error: HttpError from Google API.

    Raises:
        SheetsAPIError: 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-sheets.py auth reset\n"
            "  2. Re-run: python scripts/google-sheets.py check\n\n"
            "For setup help, see: docs/google-oauth-setup.md\n"
        )
        message = f"{message}{scope_help}"

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


# ============================================================================
# SPREADSHEET OPERATIONS
# ============================================================================


def create_spreadsheet(service, title: str, sheet_names: list[str] | None = None) -> dict[str, Any]:
    """Create a new Google Sheets spreadsheet.

    Args:
        service: Google Sheets API service object.
        title: Spreadsheet title.
        sheet_names: Optional list of sheet names. Default creates one sheet.

    Returns:
        Created spreadsheet dictionary with spreadsheetId.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        body: dict[str, Any] = {"properties": {"title": title}}

        if sheet_names:
            body["sheets"] = [{"properties": {"title": name}} for name in sheet_names]

        spreadsheet = service.spreadsheets().create(body=body).execute()
        return spreadsheet
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def get_spreadsheet(service, spreadsheet_id: str) -> dict[str, Any]:
    """Get a spreadsheet by ID.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.

    Returns:
        Spreadsheet dictionary with metadata and sheets.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
        return spreadsheet
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def read_values(
    service, spreadsheet_id: str, range_name: str, value_render_option: str = "FORMATTED_VALUE"
) -> dict[str, Any]:
    """Read cell values from a spreadsheet.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        range_name: The range in A1 notation (e.g., "Sheet1!A1:D5").
        value_render_option: How values should be represented (FORMATTED_VALUE, UNFORMATTED_VALUE, FORMULA).

    Returns:
        Result dictionary with values array.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        result = (
            service.spreadsheets()
            .values()
            .get(
                spreadsheetId=spreadsheet_id,
                range=range_name,
                valueRenderOption=value_render_option,
            )
            .execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def write_values(
    service, spreadsheet_id: str, range_name: str, values: list[list[Any]]
) -> dict[str, Any]:
    """Write values to a spreadsheet range.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        range_name: The range in A1 notation (e.g., "Sheet1!A1").
        values: 2D array of values to write.

    Returns:
        Update response from the API.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        body = {"values": values}
        result = (
            service.spreadsheets()
            .values()
            .update(
                spreadsheetId=spreadsheet_id,
                range=range_name,
                valueInputOption="USER_ENTERED",
                body=body,
            )
            .execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def append_values(
    service, spreadsheet_id: str, range_name: str, values: list[list[Any]]
) -> dict[str, Any]:
    """Append rows to a spreadsheet.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        range_name: The range/sheet name (e.g., "Sheet1").
        values: 2D array of values to append.

    Returns:
        Append response from the API.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        body = {"values": values}
        result = (
            service.spreadsheets()
            .values()
            .append(
                spreadsheetId=spreadsheet_id,
                range=range_name,
                valueInputOption="USER_ENTERED",
                body=body,
            )
            .execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def clear_values(service, spreadsheet_id: str, range_name: str) -> dict[str, Any]:
    """Clear values in a spreadsheet range.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        range_name: The range in A1 notation.

    Returns:
        Clear response from the API.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        result = (
            service.spreadsheets()
            .values()
            .clear(spreadsheetId=spreadsheet_id, range=range_name)
            .execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def create_sheet(service, spreadsheet_id: str, title: str) -> dict[str, Any]:
    """Add a new sheet to a spreadsheet.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        title: Sheet title.

    Returns:
        Batch update response from the API.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        requests = [{"addSheet": {"properties": {"title": title}}}]

        body = {"requests": requests}
        result = (
            service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


def delete_sheet(service, spreadsheet_id: str, sheet_id: int) -> dict[str, Any]:
    """Delete a sheet from a spreadsheet.

    Args:
        service: Google Sheets API service object.
        spreadsheet_id: The spreadsheet ID.
        sheet_id: The sheet ID (not the title).

    Returns:
        Batch update response from the API.

    Raises:
        SheetsAPIError: If the API call fails.
    """
    try:
        requests = [{"deleteSheet": {"sheetId": sheet_id}}]

        body = {"requests": requests}
        result = (
            service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        )
        return result
    except HttpError as e:
        handle_api_error(e)
        return {}  # Unreachable


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


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

    Args:
        spreadsheet: Spreadsheet dictionary from Google Sheets API.

    Returns:
        Formatted string.
    """
    title = spreadsheet.get("properties", {}).get("title", "(Untitled)")
    spreadsheet_id = spreadsheet.get("spreadsheetId", "(Unknown)")
    sheets = spreadsheet.get("sheets", [])
    sheet_names = [s.get("properties", {}).get("title", "Unknown") for s in sheets]

    return (
        f"### {title}\n"
        f"- **Spreadsheet ID:** {spreadsheet_id}\n"
        f"- **Sheets:** {len(sheets)} ({', '.join(sheet_names)})\n"
        f"- **URL:** https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
    )


def format_values_output(values: list[list[Any]]) -> str:
    """Format values for human-readable display.

    Args:
        values: 2D array of cell values.

    Returns:
        Formatted string.
    """
    if not values:
        return "(No data)"

    # Calculate column widths
    col_widths = []
    for col_idx in range(max(len(row) for row in values)):
        max_width = 0
        for row in values:
            if col_idx < len(row):
                max_width = max(max_width, len(str(row[col_idx])))
        col_widths.append(max_width)

    # Format rows
    lines = []
    for row in values:
        formatted_cells = []
        for col_idx, cell in enumerate(row):
            cell_str = str(cell) if cell else ""
            formatted_cells.append(cell_str.ljust(col_widths[col_idx]))
        lines.append(" | ".join(formatted_cells))

    return "\n".join(lines)


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


def check_sheets_connectivity() -> dict[str, Any]:
    """Check Google Sheets 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-sheets", SHEETS_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("sheets", "v4", credentials=creds)

        # Try a simple API call to verify connectivity
        # Create a test spreadsheet
        test_ss = (
            service.spreadsheets()
            .create(body={"properties": {"title": "_test_connectivity"}})
            .execute()
        )
        test_ss_id = test_ss.get("spreadsheetId")

        result["authenticated"] = True
        result["test_spreadsheet_id"] = test_ss_id
        result["scopes"] = {
            "readonly": any("spreadsheets.readonly" in s for s in available_scopes),
            "write": any("spreadsheets" 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 Sheets connectivity...")
    result = check_sheets_connectivity()

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

        # Display scope information
        scopes = result.get("scopes", {})
        if scopes:
            print("\nGranted OAuth Scopes:")
            print(f"  Read-only (spreadsheets.readonly): {'✓' if scopes.get('readonly') else '✗'}")
            print(f"  Write (spreadsheets):               {'✓' 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-sheets.py auth reset")
                print("   2. Re-run: python scripts/google-sheets.py check")
                print()
                print("   See: docs/google-oauth-setup.md")

        print(f"\nTest spreadsheet created: {result.get('test_spreadsheet_id')}")
        print("(You can delete this test spreadsheet 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-sheets.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-sheets") or {}
    config["oauth_client"] = {
        "client_id": args.client_id,
        "client_secret": args.client_secret,
    }
    save_config("google-sheets", config)
    print("✓ OAuth client credentials saved to config file")
    print(f"  Config location: {CONFIG_DIR / 'google-sheets.yaml'}")
    print("\nNext step: Run any Google Sheets command to initiate OAuth flow")
    return 0


def cmd_auth_reset(_args):
    """Handle 'auth reset' command."""
    delete_credential("google-sheets-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-sheets-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_spreadsheets_create(args):
    """Handle 'spreadsheets create' command."""
    service = build_sheets_service(SHEETS_SCOPES)
    sheet_names = args.sheets.split(",") if args.sheets else None
    spreadsheet = create_spreadsheet(service, args.title, sheet_names)

    if args.json:
        print(json.dumps(spreadsheet, indent=2))
    else:
        print("✓ Spreadsheet created successfully")
        print(format_spreadsheet_summary(spreadsheet))

    return 0


def cmd_spreadsheets_get(args):
    """Handle 'spreadsheets get' command."""
    service = build_sheets_service(SHEETS_SCOPES_READONLY)
    spreadsheet = get_spreadsheet(service, args.spreadsheet_id)

    if args.json:
        print(json.dumps(spreadsheet, indent=2))
    else:
        print(format_spreadsheet_summary(spreadsheet))

    return 0


def cmd_values_read(args):
    """Handle 'values read' command."""
    service = build_sheets_service(SHEETS_SCOPES_READONLY)
    result = read_values(service, args.spreadsheet_id, args.range, args.format)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        values = result.get("values", [])
        if values:
            print(format_values_output(values))
        else:
            print("(No data found in range)")

    return 0


def cmd_values_write(args):
    """Handle 'values write' command."""
    service = build_sheets_service(SHEETS_SCOPES)

    # Parse JSON values
    try:
        values = json.loads(args.values)
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON for --values: {e}", file=sys.stderr)
        return 1

    result = write_values(service, args.spreadsheet_id, args.range, values)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Values written successfully")
        print(f"  Updated cells: {result.get('updatedCells', 0)}")
        print(f"  Updated range: {result.get('updatedRange', 'N/A')}")

    return 0


def cmd_values_append(args):
    """Handle 'values append' command."""
    service = build_sheets_service(SHEETS_SCOPES)

    # Parse JSON values
    try:
        values = json.loads(args.values)
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON for --values: {e}", file=sys.stderr)
        return 1

    result = append_values(service, args.spreadsheet_id, args.range, values)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        updates = result.get("updates", {})
        print("✓ Values appended successfully")
        print(f"  Updated cells: {updates.get('updatedCells', 0)}")
        print(f"  Updated range: {updates.get('updatedRange', 'N/A')}")

    return 0


def cmd_values_clear(args):
    """Handle 'values clear' command."""
    service = build_sheets_service(SHEETS_SCOPES)
    result = clear_values(service, args.spreadsheet_id, args.range)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print("✓ Values cleared successfully")
        print(f"  Cleared range: {result.get('clearedRange', 'N/A')}")

    return 0


def cmd_sheets_create(args):
    """Handle 'sheets create' command."""
    service = build_sheets_service(SHEETS_SCOPES)
    result = create_sheet(service, args.spreadsheet_id, args.title)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        # Extract the new sheet info from the reply
        reply = result.get("replies", [{}])[0]
        new_sheet = reply.get("addSheet", {}).get("properties", {})
        print("✓ Sheet created successfully")
        print(f"  Title: {new_sheet.get('title', args.title)}")
        print(f"  Sheet ID: {new_sheet.get('sheetId', 'N/A')}")

    return 0


def cmd_sheets_delete(args):
    """Handle 'sheets delete' command."""
    service = build_sheets_service(SHEETS_SCOPES)
    result = delete_sheet(service, args.spreadsheet_id, args.sheet_id)

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

    return 0


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


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        description="Google Sheets 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 Sheets 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")

    # spreadsheets commands
    spreadsheets_parser = subparsers.add_parser("spreadsheets", help="Spreadsheet operations")
    spreadsheets_subparsers = spreadsheets_parser.add_subparsers(dest="spreadsheets_command")

    create_parser = spreadsheets_subparsers.add_parser("create", help="Create a new spreadsheet")
    create_parser.add_argument("--title", required=True, help="Spreadsheet title")
    create_parser.add_argument("--sheets", help="Comma-separated sheet names")
    create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    get_parser = spreadsheets_subparsers.add_parser("get", help="Get spreadsheet metadata")
    get_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    get_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # values commands
    values_parser = subparsers.add_parser("values", help="Cell value operations")
    values_subparsers = values_parser.add_subparsers(dest="values_command")

    read_parser = values_subparsers.add_parser("read", help="Read cell values")
    read_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    read_parser.add_argument(
        "--range", required=True, help="Range in A1 notation (e.g., Sheet1!A1:D5)"
    )
    read_parser.add_argument(
        "--format",
        choices=["FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA"],
        default="FORMATTED_VALUE",
        help="Value rendering format",
    )
    read_parser.add_argument("--json", action="store_true", help="Output as JSON")

    write_parser = values_subparsers.add_parser("write", help="Write values to cells")
    write_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    write_parser.add_argument("--range", required=True, help="Range in A1 notation")
    write_parser.add_argument(
        "--values", required=True, help="Values as JSON array (e.g., [[1,2,3]])"
    )
    write_parser.add_argument("--json", action="store_true", help="Output as JSON")

    append_parser = values_subparsers.add_parser("append", help="Append rows to sheet")
    append_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    append_parser.add_argument("--range", required=True, help="Sheet name or range")
    append_parser.add_argument(
        "--values", required=True, help="Values as JSON array (e.g., [[4,5,6]])"
    )
    append_parser.add_argument("--json", action="store_true", help="Output as JSON")

    clear_parser = values_subparsers.add_parser("clear", help="Clear cell values")
    clear_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    clear_parser.add_argument("--range", required=True, help="Range in A1 notation")
    clear_parser.add_argument("--json", action="store_true", help="Output as JSON")

    # sheets commands
    sheets_parser = subparsers.add_parser("sheets", help="Sheet management operations")
    sheets_subparsers = sheets_parser.add_subparsers(dest="sheets_command")

    sheets_create_parser = sheets_subparsers.add_parser("create", help="Add new sheet")
    sheets_create_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    sheets_create_parser.add_argument("--title", required=True, help="Sheet title")
    sheets_create_parser.add_argument("--json", action="store_true", help="Output as JSON")

    sheets_delete_parser = sheets_subparsers.add_parser("delete", help="Delete a sheet")
    sheets_delete_parser.add_argument("spreadsheet_id", help="Spreadsheet ID")
    sheets_delete_parser.add_argument(
        "--sheet-id", type=int, required=True, help="Sheet ID to delete"
    )
    sheets_delete_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 == "spreadsheets":
            if args.spreadsheets_command == "create":
                return cmd_spreadsheets_create(args)
            elif args.spreadsheets_command == "get":
                return cmd_spreadsheets_get(args)
        elif args.command == "values":
            if args.values_command == "read":
                return cmd_values_read(args)
            elif args.values_command == "write":
                return cmd_values_write(args)
            elif args.values_command == "append":
                return cmd_values_append(args)
            elif args.values_command == "clear":
                return cmd_values_clear(args)
        elif args.command == "sheets":
            if args.sheets_command == "create":
                return cmd_sheets_create(args)
            elif args.sheets_command == "delete":
                return cmd_sheets_delete(args)

        parser.print_help()
        return 1

    except (SheetsAPIError, 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-sheets@0.1.3

SKILL.md

tile.json