Create, update, and organize Google Calendar events and schedules. Check availability, book time, and manage calendars. Use when asked to schedule a meeting, set up an appointment, book a call, check gcal, or manage calendar events.
94
Quality
94%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
#!/usr/bin/env python3
"""Google Calendar integration skill for AI agents.
This is a self-contained script that provides Google Calendar functionality.
Usage:
python google-calendar.py check
python google-calendar.py auth setup --client-id ID --client-secret SECRET
python google-calendar.py calendars list
python google-calendar.py events list --calendar primary --time-min "2026-01-01T00:00:00Z"
python google-calendar.py events get EVENT_ID --calendar primary
python google-calendar.py events create --summary "Meeting" --start "2026-01-24T10:00:00-05:00" --end "2026-01-24T11:00:00-05:00"
python google-calendar.py events update EVENT_ID --summary "Updated Meeting"
python google-calendar.py events delete EVENT_ID --calendar primary
python google-calendar.py freebusy --start "2026-01-24T00:00:00Z" --end "2026-01-25T00:00:00Z"
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 Calendar API scopes - granular scopes for different operations
CALENDAR_SCOPES_READONLY = ["https://www.googleapis.com/auth/calendar.readonly"]
CALENDAR_SCOPES_EVENTS = ["https://www.googleapis.com/auth/calendar.events"]
# Full scope set for maximum functionality
CALENDAR_SCOPES_FULL = CALENDAR_SCOPES_READONLY + CALENDAR_SCOPES_EVENTS
# Minimal read-only scope (default)
CALENDAR_SCOPES_DEFAULT = CALENDAR_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-calendar-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-calendar").
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-calendar.py auth setup --client-id YOUR_ID --client-secret YOUR_SECRET\n"
f" 2. Service env vars: Set GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_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-calendar").
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-calendar").
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_calendar_service(scopes: list[str] | None = None):
"""Build and return Google Calendar API service.
Args:
scopes: List of OAuth scopes to request. Defaults to read-only.
Returns:
Calendar API service object.
Raises:
AuthenticationError: If authentication fails.
"""
if scopes is None:
scopes = CALENDAR_SCOPES_DEFAULT
creds = get_google_credentials("google-calendar", scopes)
return build("calendar", "v3", credentials=creds)
# ============================================================================
# CALENDAR API ERROR HANDLING
# ============================================================================
class CalendarAPIError(Exception):
"""Exception raised for Calendar 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 CalendarAPIError.
Args:
error: HttpError from Google API.
Raises:
CalendarAPIError: 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-calendar.py auth reset\n"
" 2. Re-run: python scripts/google-calendar.py check\n\n"
"For setup help, see: docs/google-oauth-setup.md\n"
)
message = f"{message}{scope_help}"
raise CalendarAPIError(
f"Calendar API error: {message} (HTTP {status_code})",
status_code=status_code,
details=details,
)
# ============================================================================
# CALENDAR OPERATIONS
# ============================================================================
def list_calendars(service) -> list[dict[str, Any]]:
"""List all calendars for the authenticated user.
Args:
service: Calendar API service object.
Returns:
List of calendar dictionaries.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
result = service.calendarList().list().execute()
calendars = result.get("items", [])
return calendars
except HttpError as e:
handle_api_error(e)
return [] # Unreachable
def get_calendar(service, calendar_id: str = "primary") -> dict[str, Any]:
"""Get calendar details.
Args:
service: Calendar API service object.
calendar_id: Calendar ID (default: "primary").
Returns:
Calendar dictionary.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
calendar = service.calendars().get(calendarId=calendar_id).execute()
return calendar
except HttpError as e:
handle_api_error(e)
return {} # Unreachable
# ============================================================================
# EVENT OPERATIONS
# ============================================================================
def list_events(
service,
calendar_id: str = "primary",
time_min: str | None = None,
time_max: str | None = None,
max_results: int = 10,
query: str | None = None,
) -> list[dict[str, Any]]:
"""List calendar events.
Args:
service: Calendar API service object.
calendar_id: Calendar ID (default: "primary").
time_min: Lower bound (RFC3339 timestamp).
time_max: Upper bound (RFC3339 timestamp).
max_results: Maximum number of events to return.
query: Free text search query.
Returns:
List of event dictionaries.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
params: dict[str, Any] = {
"calendarId": calendar_id,
"maxResults": max_results,
"singleEvents": True,
"orderBy": "startTime",
}
if time_min:
params["timeMin"] = time_min
if time_max:
params["timeMax"] = time_max
if query:
params["q"] = query
events: list[dict[str, Any]] = []
page_token = None
while True:
if page_token:
params["pageToken"] = page_token
result = service.events().list(**params).execute()
events.extend(result.get("items", []))
page_token = result.get("nextPageToken")
if not page_token:
break
return events
except HttpError as e:
handle_api_error(e)
return [] # Unreachable
def get_event(service, event_id: str, calendar_id: str = "primary") -> dict[str, Any]:
"""Get event details.
Args:
service: Calendar API service object.
event_id: Event ID.
calendar_id: Calendar ID (default: "primary").
Returns:
Event dictionary.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
event = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
return event
except HttpError as e:
handle_api_error(e)
return {} # Unreachable
def create_event(
service,
summary: str,
start: str,
end: str,
calendar_id: str = "primary",
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
timezone: str | None = None,
) -> dict[str, Any]:
"""Create a calendar event.
Args:
service: Calendar API service object.
summary: Event title.
start: Start time (RFC3339 timestamp or date).
end: End time (RFC3339 timestamp or date).
calendar_id: Calendar ID (default: "primary").
description: Event description.
location: Event location.
attendees: List of attendee email addresses.
timezone: Timezone for date-only events (e.g., "America/New_York").
Returns:
Created event dictionary.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
# Build event body
event_body: dict[str, Any] = {
"summary": summary,
}
# Handle start/end time - support both datetime and date formats
if "T" in start:
event_body["start"] = {"dateTime": start}
event_body["end"] = {"dateTime": end}
else:
# All-day event
event_body["start"] = {"date": start}
event_body["end"] = {"date": end}
if timezone:
event_body["start"]["timeZone"] = timezone
event_body["end"]["timeZone"] = timezone
if description:
event_body["description"] = description
if location:
event_body["location"] = location
if attendees:
event_body["attendees"] = [{"email": email} for email in attendees]
event = service.events().insert(calendarId=calendar_id, body=event_body).execute()
return event
except HttpError as e:
handle_api_error(e)
return {} # Unreachable
def update_event(
service,
event_id: str,
calendar_id: str = "primary",
summary: str | None = None,
start: str | None = None,
end: str | None = None,
description: str | None = None,
location: str | None = None,
) -> dict[str, Any]:
"""Update a calendar event.
Args:
service: Calendar API service object.
event_id: Event ID.
calendar_id: Calendar ID (default: "primary").
summary: New event title.
start: New start time (RFC3339 timestamp or date).
end: New end time (RFC3339 timestamp or date).
description: New event description.
location: New event location.
Returns:
Updated event dictionary.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
# Get existing event
event = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
# Update fields
if summary is not None:
event["summary"] = summary
if start is not None:
if "T" in start:
event["start"] = {"dateTime": start}
else:
event["start"] = {"date": start}
if end is not None:
if "T" in end:
event["end"] = {"dateTime": end}
else:
event["end"] = {"date": end}
if description is not None:
event["description"] = description
if location is not None:
event["location"] = location
# Update event
updated = (
service.events().update(calendarId=calendar_id, eventId=event_id, body=event).execute()
)
return updated
except HttpError as e:
handle_api_error(e)
return {} # Unreachable
def delete_event(service, event_id: str, calendar_id: str = "primary") -> None:
"""Delete a calendar event.
Args:
service: Calendar API service object.
event_id: Event ID.
calendar_id: Calendar ID (default: "primary").
Raises:
CalendarAPIError: If the API call fails.
"""
try:
service.events().delete(calendarId=calendar_id, eventId=event_id).execute()
except HttpError as e:
handle_api_error(e)
# ============================================================================
# FREEBUSY OPERATIONS
# ============================================================================
def check_freebusy(
service,
time_min: str,
time_max: str,
calendar_ids: list[str] | None = None,
) -> dict[str, Any]:
"""Check free/busy information for calendars.
Args:
service: Calendar API service object.
time_min: Start time (RFC3339 timestamp).
time_max: End time (RFC3339 timestamp).
calendar_ids: List of calendar IDs to check (default: ["primary"]).
Returns:
Freebusy query result dictionary.
Raises:
CalendarAPIError: If the API call fails.
"""
try:
if calendar_ids is None:
calendar_ids = ["primary"]
body = {
"timeMin": time_min,
"timeMax": time_max,
"items": [{"id": cal_id} for cal_id in calendar_ids],
}
result = service.freebusy().query(body=body).execute()
return result
except HttpError as e:
handle_api_error(e)
return {} # Unreachable
# ============================================================================
# OUTPUT FORMATTING
# ============================================================================
def format_calendar(calendar: dict[str, Any]) -> str:
"""Format a calendar for display.
Args:
calendar: Calendar dictionary from API.
Returns:
Formatted string.
"""
summary = calendar.get("summary", "(No name)")
cal_id = calendar.get("id", "(Unknown)")
description = calendar.get("description", "")
timezone = calendar.get("timeZone", "")
primary = " [PRIMARY]" if calendar.get("primary", False) else ""
output = f"### {summary}{primary}\n- **ID:** {cal_id}"
if timezone:
output += f"\n- **Timezone:** {timezone}"
if description:
output += f"\n- **Description:** {description}"
return output
def format_event(event: dict[str, Any]) -> str:
"""Format an event for display.
Args:
event: Event dictionary from API.
Returns:
Formatted string.
"""
summary = event.get("summary", "(No title)")
event_id = event.get("id", "(Unknown)")
# Get start/end times
start = event.get("start", {})
end = event.get("end", {})
start_time = start.get("dateTime", start.get("date", "(Unknown)"))
end_time = end.get("dateTime", end.get("date", "(Unknown)"))
output = (
f"### {summary}\n- **ID:** {event_id}\n- **Start:** {start_time}\n- **End:** {end_time}"
)
location = event.get("location")
if location:
output += f"\n- **Location:** {location}"
description = event.get("description")
if description:
# Truncate long descriptions
desc_preview = description[:100] + "..." if len(description) > 100 else description
output += f"\n- **Description:** {desc_preview}"
attendees = event.get("attendees", [])
my_status = None
if attendees:
for a in attendees:
if a.get("self"):
my_status = a.get("responseStatus")
break
emails = [a.get("email", "") for a in attendees]
output += f"\n- **Attendees:** {', '.join(emails)}"
if my_status:
status_display = {
"accepted": "Accepted",
"declined": "Declined",
"tentative": "Tentative",
"needsAction": "Not responded",
}
output += f"\n- **Your response:** {status_display.get(my_status, my_status)}"
return output
# ============================================================================
# HEALTH CHECK
# ============================================================================
def check_calendar_connectivity() -> dict[str, Any]:
"""Check Calendar API connectivity and authentication.
Returns:
Dictionary with status information including available scopes.
"""
result = {
"authenticated": False,
"primary_calendar": None,
"scopes": None,
"error": None,
}
try:
# Get credentials to check scopes
creds = get_google_credentials("google-calendar", CALENDAR_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 and get primary calendar
service = build("calendar", "v3", credentials=creds)
cal_list = service.calendarList().list().execute()
primary = next((c for c in cal_list.get("items", []) if c.get("primary")), None)
result["authenticated"] = True
if primary:
result["primary_calendar"] = {
"summary": primary.get("summary"),
"id": primary.get("id"),
"timezone": primary.get("timeZone"),
}
result["scopes"] = {
"readonly": any("calendar.readonly" in s for s in available_scopes),
"events": any("calendar.events" 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 Calendar connectivity...")
result = check_calendar_connectivity()
if result["authenticated"]:
print("✓ Successfully authenticated to Google Calendar")
if result["primary_calendar"]:
cal = result["primary_calendar"]
print(f" Primary Calendar: {cal['summary']}")
print(f" Calendar ID: {cal['id']}")
print(f" Timezone: {cal['timezone']}")
# Display scope information
scopes = result.get("scopes", {})
if scopes:
print("\nGranted OAuth Scopes:")
print(f" Read-only (calendar.readonly): {'✓' if scopes.get('readonly') else '✗'}")
print(f" Events (calendar.events): {'✓' if scopes.get('events') else '✗'}")
# Check if all scopes are granted
all_granted = all([scopes.get("readonly"), scopes.get("events")])
if not all_granted:
print("\n⚠️ Not all scopes are granted. Some operations may fail.")
print(" To grant full access, reset and re-authenticate:")
print()
print(" 1. Reset token: python scripts/google-calendar.py auth reset")
print(" 2. Re-run: python scripts/google-calendar.py check")
print()
print(" See: docs/google-oauth-setup.md")
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-calendar.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-calendar") or {}
config["oauth_client"] = {
"client_id": args.client_id,
"client_secret": args.client_secret,
}
save_config("google-calendar", config)
print("✓ OAuth client credentials saved to config file")
print(f" Config location: {CONFIG_DIR / 'google-calendar.yaml'}")
print("\nNext step: Run any Calendar command to initiate OAuth flow")
return 0
def cmd_auth_reset(_args):
"""Handle 'auth reset' command."""
delete_credential("google-calendar-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-calendar-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_calendars_list(args):
"""Handle 'calendars list' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY)
calendars = list_calendars(service)
if args.json:
print(json.dumps(calendars, indent=2))
else:
if not calendars:
print("No calendars found")
else:
print(f"Found {len(calendars)} calendar(s):\n")
for cal in calendars:
print(format_calendar(cal))
print()
return 0
def cmd_calendars_get(args):
"""Handle 'calendars get' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY)
calendar = get_calendar(service, args.calendar_id)
if args.json:
print(json.dumps(calendar, indent=2))
else:
print(format_calendar(calendar))
return 0
def cmd_events_list(args):
"""Handle 'events list' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY)
events = list_events(
service,
calendar_id=args.calendar,
time_min=args.time_min,
time_max=args.time_max,
max_results=args.max_results,
query=args.query,
)
if not args.include_declined:
declined = [
e
for e in events
if any(
a.get("self") and a.get("responseStatus") == "declined"
for a in e.get("attendees", [])
)
]
events = [e for e in events if e not in declined]
else:
declined = []
if args.json:
print(json.dumps(events, indent=2))
else:
if not events:
print("No events found")
else:
print(f"Found {len(events)} event(s):\n")
for event in events:
print(format_event(event))
print()
if declined:
print(
f"*({len(declined)} declined invitation(s) not shown — use --include-declined to include)*"
)
return 0
def cmd_events_get(args):
"""Handle 'events get' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY)
event = get_event(service, args.event_id, args.calendar)
if args.json:
print(json.dumps(event, indent=2))
else:
print(format_event(event))
return 0
def cmd_events_create(args):
"""Handle 'events create' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY + CALENDAR_SCOPES_EVENTS)
# Parse attendees if provided
attendees = None
if args.attendees:
attendees = [email.strip() for email in args.attendees.split(",")]
result = create_event(
service,
summary=args.summary,
start=args.start,
end=args.end,
calendar_id=args.calendar,
description=args.description,
location=args.location,
attendees=attendees,
timezone=args.timezone,
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("**Event created successfully**")
print(f"- **Event ID:** {result.get('id')}")
print(f"- **Summary:** {result.get('summary')}")
html_link = result.get("htmlLink")
if html_link:
print(f"- **Link:** {html_link}")
return 0
def cmd_events_update(args):
"""Handle 'events update' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY + CALENDAR_SCOPES_EVENTS)
result = update_event(
service,
event_id=args.event_id,
calendar_id=args.calendar,
summary=args.summary,
start=args.start,
end=args.end,
description=args.description,
location=args.location,
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("**Event updated successfully**")
print(f"- **Event ID:** {result.get('id')}")
print(f"- **Summary:** {result.get('summary')}")
return 0
def cmd_events_delete(args):
"""Handle 'events delete' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY + CALENDAR_SCOPES_EVENTS)
delete_event(service, args.event_id, args.calendar)
if not args.json:
print("✓ Event deleted successfully")
return 0
def cmd_freebusy(args):
"""Handle 'freebusy' command."""
service = build_calendar_service(CALENDAR_SCOPES_READONLY)
# Parse calendar IDs if provided
calendar_ids = None
if args.calendars:
calendar_ids = [cal.strip() for cal in args.calendars.split(",")]
result = check_freebusy(
service,
time_min=args.start,
time_max=args.end,
calendar_ids=calendar_ids,
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("## Free/Busy Information\n")
calendars = result.get("calendars", {})
for cal_id, cal_info in calendars.items():
print(f"### {cal_id}")
busy = cal_info.get("busy", [])
if not busy:
print("No busy times")
else:
print(f"**Busy periods:** {len(busy)}")
for period in busy:
print(f"- {period.get('start')} \u2014 {period.get('end')}")
print()
return 0
# ============================================================================
# CLI ARGUMENT PARSER
# ============================================================================
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
description="Google Calendar 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 Calendar 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")
# calendars commands
calendars_parser = subparsers.add_parser("calendars", help="Calendar operations")
calendars_subparsers = calendars_parser.add_subparsers(dest="calendars_command")
calendars_list_parser = calendars_subparsers.add_parser("list", help="List calendars")
calendars_list_parser.add_argument("--json", action="store_true", help="Output as JSON")
calendars_get_parser = calendars_subparsers.add_parser("get", help="Get calendar details")
calendars_get_parser.add_argument("calendar_id", help="Calendar ID (or 'primary')")
calendars_get_parser.add_argument("--json", action="store_true", help="Output as JSON")
# events commands
events_parser = subparsers.add_parser("events", help="Event operations")
events_subparsers = events_parser.add_subparsers(dest="events_command")
events_list_parser = events_subparsers.add_parser("list", help="List events")
events_list_parser.add_argument("--calendar", default="primary", help="Calendar ID")
events_list_parser.add_argument("--time-min", help="Start time (RFC3339)")
events_list_parser.add_argument("--time-max", help="End time (RFC3339)")
events_list_parser.add_argument("--max-results", type=int, default=10, help="Maximum results")
events_list_parser.add_argument("--query", help="Search query")
events_list_parser.add_argument("--json", action="store_true", help="Output as JSON")
events_list_parser.add_argument(
"--include-declined",
action="store_true",
help="Include events you have declined (excluded by default)",
)
events_get_parser = events_subparsers.add_parser("get", help="Get event by ID")
events_get_parser.add_argument("event_id", help="Event ID")
events_get_parser.add_argument("--calendar", default="primary", help="Calendar ID")
events_get_parser.add_argument("--json", action="store_true", help="Output as JSON")
events_create_parser = events_subparsers.add_parser("create", help="Create an event")
events_create_parser.add_argument("--summary", required=True, help="Event title")
events_create_parser.add_argument(
"--start", required=True, help="Start time (RFC3339 or YYYY-MM-DD)"
)
events_create_parser.add_argument(
"--end", required=True, help="End time (RFC3339 or YYYY-MM-DD)"
)
events_create_parser.add_argument("--calendar", default="primary", help="Calendar ID")
events_create_parser.add_argument("--description", help="Event description")
events_create_parser.add_argument("--location", help="Event location")
events_create_parser.add_argument("--attendees", help="Comma-separated attendee emails")
events_create_parser.add_argument("--timezone", help="Timezone for all-day events")
events_create_parser.add_argument("--json", action="store_true", help="Output as JSON")
events_update_parser = events_subparsers.add_parser("update", help="Update an event")
events_update_parser.add_argument("event_id", help="Event ID")
events_update_parser.add_argument("--calendar", default="primary", help="Calendar ID")
events_update_parser.add_argument("--summary", help="New event title")
events_update_parser.add_argument("--start", help="New start time (RFC3339 or YYYY-MM-DD)")
events_update_parser.add_argument("--end", help="New end time (RFC3339 or YYYY-MM-DD)")
events_update_parser.add_argument("--description", help="New event description")
events_update_parser.add_argument("--location", help="New event location")
events_update_parser.add_argument("--json", action="store_true", help="Output as JSON")
events_delete_parser = events_subparsers.add_parser("delete", help="Delete an event")
events_delete_parser.add_argument("event_id", help="Event ID")
events_delete_parser.add_argument("--calendar", default="primary", help="Calendar ID")
events_delete_parser.add_argument("--json", action="store_true", help="Output as JSON")
# freebusy command
freebusy_parser = subparsers.add_parser("freebusy", help="Check free/busy information")
freebusy_parser.add_argument("--start", required=True, help="Start time (RFC3339)")
freebusy_parser.add_argument("--end", required=True, help="End time (RFC3339)")
freebusy_parser.add_argument(
"--calendars", help="Comma-separated calendar IDs (default: primary)"
)
freebusy_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 == "calendars":
if args.calendars_command == "list":
return cmd_calendars_list(args)
elif args.calendars_command == "get":
return cmd_calendars_get(args)
elif args.command == "events":
if args.events_command == "list":
return cmd_events_list(args)
elif args.events_command == "get":
return cmd_events_get(args)
elif args.events_command == "create":
return cmd_events_create(args)
elif args.events_command == "update":
return cmd_events_update(args)
elif args.events_command == "delete":
return cmd_events_delete(args)
elif args.command == "freebusy":
return cmd_freebusy(args)
parser.print_help()
return 1
except (CalendarAPIError, 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-calendar