
Google Calendar
- 62 installs
- 364 repo stars
- Updated July 9, 2026
- sanjay3290/postgres-skill
This is a copy of google-calendar by sanjay3290 - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
google-calendar is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- google-calendar
- AI & Agent Building
- AI-coding skill
Google Calendar by the numbers
- 62 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/postgres-skill --skill google-calendarAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 364 |
| Last updated | July 9, 2026 |
| Repository | sanjay3290/postgres-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Google Calendar
Lightweight Google Calendar integration with standalone OAuth authentication. No MCP server required.
⚠️ Requires Google Workspace account. Personal Gmail accounts are not supported.
First-Time Setup
Authenticate with Google (opens browser):
python scripts/auth.py loginCheck authentication status:
python scripts/auth.py statusLogout when needed:
python scripts/auth.py logoutCommands
All operations via scripts/gcal.py. Auto-authenticates on first use if not logged in.
List Calendars
python scripts/gcal.py list-calendarsList Events
# List events from primary calendar (default: next 30 days)
python scripts/gcal.py list-events
# List events with specific time range
python scripts/gcal.py list-events --time-min 2024-01-15T00:00:00Z --time-max 2024-01-31T23:59:59Z
# List events from a specific calendar
python scripts/gcal.py list-events --calendar "work@example.com"
# Limit results
python scripts/gcal.py list-events --max-results 10Get Event Details
python scripts/gcal.py get-event EVENT_ID
python scripts/gcal.py get-event EVENT_ID --calendar "work@example.com"Create Event
# Basic event
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z"
# Event with description and location
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" \
--description "Weekly sync" --location "Conference Room A"
# Event with attendees
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" \
--attendees user1@example.com user2@example.com
# Event on specific calendar
python scripts/gcal.py create-event "Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" \
--calendar "work@example.com"Update Event
# Update event title
python scripts/gcal.py update-event EVENT_ID --summary "New Title"
# Update event time
python scripts/gcal.py update-event EVENT_ID --start "2024-01-15T14:00:00Z" --end "2024-01-15T15:00:00Z"
# Update multiple fields
python scripts/gcal.py update-event EVENT_ID \
--summary "Updated Meeting" --description "New agenda" --location "Room B"
# Update attendees
python scripts/gcal.py update-event EVENT_ID --attendees user1@example.com user3@example.comDelete Event
python scripts/gcal.py delete-event EVENT_ID
python scripts/gcal.py delete-event EVENT_ID --calendar "work@example.com"Find Free Time
Find the first available slot for a meeting with specified attendees:
# Find 30-minute slot for yourself
python scripts/gcal.py find-free-time \
--attendees me \
--time-min "2024-01-15T09:00:00Z" \
--time-max "2024-01-15T17:00:00Z" \
--duration 30
# Find 60-minute slot with multiple attendees
python scripts/gcal.py find-free-time \
--attendees me user1@example.com user2@example.com \
--time-min "2024-01-15T09:00:00Z" \
--time-max "2024-01-19T17:00:00Z" \
--duration 60Respond to Event Invitation
# Accept an invitation
python scripts/gcal.py respond-to-event EVENT_ID accepted
# Decline an invitation
python scripts/gcal.py respond-to-event EVENT_ID declined
# Mark as tentative
python scripts/gcal.py respond-to-event EVENT_ID tentative
# Respond without notifying organizer
python scripts/gcal.py respond-to-event EVENT_ID accepted --no-notifyDate/Time Format
All times use ISO 8601 format with timezone:
- UTC:
2024-01-15T10:30:00Z - With offset:
2024-01-15T10:30:00-05:00(EST)
Calendar ID Format
- Primary calendar: Use
primaryor omit the--calendarflag - Other calendars: Use the calendar ID from
list-calendars(usually an email address)
Token Management
Tokens stored securely using the system keyring:
- macOS: Keychain
- Windows: Windows Credential Locker
- Linux: Secret Service API (GNOME Keyring, KDE Wallet, etc.)
Service name: google-calendar-skill-oauth
Tokens are automatically refreshed when expired using Google's cloud function.
Google Calendar Skill
An AI agent skill for interacting with Google Calendar - list events, create/update/delete events, find free time, and respond to invitations. Works with Claude Code, Gemini CLI, Cursor, OpenAI Codex, Goose, and other AI clients supporting the Agent Skills Standard.
Features
- List Events - View upcoming events with time range filtering
- Create Events - Schedule meetings with attendees
- Update Events - Modify existing events
- Delete Events - Remove calendar entries
- Find Free Time - Discover available slots across attendees
- Respond to Invitations - Accept, decline, or mark tentative
Lightweight alternative to the full Google Workspace MCP server.
⚠️ Requires Google Workspace account. Personal Gmail accounts are not supported.
Quick Start
1. Install dependencies
pip install keyring2. Authenticate
python scripts/auth.py loginThis opens a browser for Google OAuth. Tokens are stored securely in your system keyring.
3. Test connection
python scripts/auth.py statusUsage Examples
List Events
# List events from primary calendar (default: next 30 days)
python scripts/gcal.py list-events
# List events with time range
python scripts/gcal.py list-events --time-min 2024-01-15T00:00:00Z --time-max 2024-01-31T23:59:59Z
# List from specific calendar
python scripts/gcal.py list-events --calendar "work@example.com"Create Events
# Basic event
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z"
# With description and location
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" \
--description "Weekly sync" --location "Conference Room A"
# With attendees
python scripts/gcal.py create-event "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" \
--attendees user1@example.com user2@example.comUpdate Events
python scripts/gcal.py update-event EVENT_ID --summary "New Title"
python scripts/gcal.py update-event EVENT_ID --start "2024-01-15T14:00:00Z" --end "2024-01-15T15:00:00Z"Find Free Time
# Find 30-minute slot for yourself
python scripts/gcal.py find-free-time \
--attendees me \
--time-min "2024-01-15T09:00:00Z" \
--time-max "2024-01-15T17:00:00Z" \
--duration 30
# Find 60-minute slot with multiple attendees
python scripts/gcal.py find-free-time \
--attendees me user1@example.com user2@example.com \
--time-min "2024-01-15T09:00:00Z" \
--time-max "2024-01-19T17:00:00Z" \
--duration 60Respond to Invitations
python scripts/gcal.py respond-to-event EVENT_ID accepted
python scripts/gcal.py respond-to-event EVENT_ID declined
python scripts/gcal.py respond-to-event EVENT_ID tentativeCommand Reference
| Command | Description | Arguments |
|---|---|---|
list-calendars | List all calendars | - |
list-events | List events | --calendar, --time-min, --time-max |
get-event <id> | Get event details | event ID |
create-event <title> <start> <end> | Create event | title, times, --attendees |
update-event <id> | Update event | event ID, fields to update |
delete-event <id> | Delete event | event ID |
find-free-time | Find available slots | --attendees, --duration |
respond-to-event <id> <response> | Respond to invite | event ID, accepted/declined/tentative |
Date/Time Format
All times use ISO 8601 format:
- UTC:
2024-01-15T10:30:00Z - With offset:
2024-01-15T10:30:00-05:00 - Local time (offset added automatically):
2024-01-15T10:30:00
Token Management
Tokens stored securely using the system keyring:
- macOS: Keychain
- Windows: Windows Credential Locker
- Linux: Secret Service API (GNOME Keyring, KDE Wallet)
Service name: google-calendar-skill-oauth
Troubleshooting
"Failed to get access token"
Run python scripts/auth.py login to authenticate.
"Event not found"
Check the event ID and your calendar access.
"No free time found"
Try a wider time range or longer duration.
License
Apache 2.0
keyring>=24.0.0
#!/usr/bin/env python3
"""
OAuth token management for Google Calendar API.
Standalone authentication - does not require the MCP server.
Cross-platform token storage using keyring.
"""
import http.server
import json
import secrets
import socket
import sys
import time
import webbrowser
from base64 import b64encode
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlencode, parse_qs, urlparse
import urllib.request
import urllib.error
import keyring
# OAuth Configuration - uses same cloud function as MCP server
CLIENT_ID = "338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com"
CLOUD_FUNCTION_URL = "https://google-workspace-extension.geminicli.com"
REFRESH_ENDPOINT = f"{CLOUD_FUNCTION_URL}/refreshToken"
# Keychain/keyring configuration
KEYCHAIN_SERVICE = "google-calendar-skill-oauth"
KEYCHAIN_ACCOUNT = "main-account"
# Google Calendar requires these scopes
SCOPES = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/userinfo.profile",
]
TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000 # 5 minutes
@dataclass
class TokenInfo:
access_token: str
refresh_token: Optional[str]
expires_at: Optional[int]
scope: Optional[str]
# =============================================================================
# Keyring-Based Token Storage
# =============================================================================
def get_tokens_from_keychain() -> Optional[TokenInfo]:
"""Read OAuth tokens from keyring."""
try:
data_str = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
if not data_str:
return None
data = json.loads(data_str)
token = data.get("token", {})
return TokenInfo(
access_token=token.get("accessToken", ""),
refresh_token=token.get("refreshToken"),
expires_at=token.get("expiresAt"),
scope=token.get("scope")
)
except (json.JSONDecodeError, keyring.errors.KeyringError):
return None
def save_tokens_to_keychain(token_info: TokenInfo) -> bool:
"""Save OAuth tokens to keyring."""
data = {
"serverName": KEYCHAIN_ACCOUNT,
"token": {
"accessToken": token_info.access_token,
"refreshToken": token_info.refresh_token,
"tokenType": "Bearer",
"scope": token_info.scope,
"expiresAt": token_info.expires_at
},
"updatedAt": int(time.time() * 1000)
}
try:
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, json.dumps(data))
return True
except keyring.errors.KeyringError:
return False
def clear_tokens_from_keychain() -> bool:
"""Clear OAuth tokens from keyring."""
try:
keyring.delete_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
return True
except (keyring.errors.PasswordDeleteError, keyring.errors.KeyringError):
return False
# =============================================================================
# Token Storage API
# =============================================================================
def get_stored_tokens() -> Optional[TokenInfo]:
"""Get OAuth tokens from keyring storage."""
return get_tokens_from_keychain()
def save_tokens(token_info: TokenInfo) -> bool:
"""Save OAuth tokens to keyring storage."""
return save_tokens_to_keychain(token_info)
def clear_tokens() -> bool:
"""Clear OAuth tokens from keyring storage."""
return clear_tokens_from_keychain()
# =============================================================================
# Utility Functions
# =============================================================================
def get_available_port() -> int:
"""Find an available port for the OAuth callback server."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('localhost', 0))
return s.getsockname()[1]
def is_token_expired(token_info: TokenInfo) -> bool:
"""Check if the access token is expired or expiring soon."""
if not token_info.expires_at:
return False
current_time_ms = int(time.time() * 1000)
return token_info.expires_at < (current_time_ms + TOKEN_EXPIRY_BUFFER_MS)
def refresh_access_token(refresh_token: str) -> Optional[dict]:
"""Refresh access token using the cloud function."""
try:
data = json.dumps({"refresh_token": refresh_token}).encode('utf-8')
req = urllib.request.Request(
REFRESH_ENDPOINT,
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as response:
return json.loads(response.read().decode('utf-8'))
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as e:
print(f"Error refreshing token: {e}", file=sys.stderr)
return None
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP request handler for OAuth callback."""
token_info: Optional[TokenInfo] = None
csrf_token: str = ""
error: Optional[str] = None
def log_message(self, format: str, *args) -> None: # noqa: A002
"""Suppress default logging."""
_ = format, args # Unused
def do_GET(self):
"""Handle the OAuth callback."""
parsed = urlparse(self.path)
if not parsed.path.startswith("/oauth2callback"):
self.send_error(404)
return
params = parse_qs(parsed.query)
# Validate CSRF token
state = params.get("state", [""])[0]
if state != OAuthCallbackHandler.csrf_token:
OAuthCallbackHandler.error = "State mismatch - possible CSRF attack"
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h1>Error: State mismatch</h1></body></html>")
return
# Check for errors
if "error" in params:
OAuthCallbackHandler.error = params.get("error_description", params["error"])[0]
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(f"<html><body><h1>Error: {OAuthCallbackHandler.error}</h1></body></html>".encode())
return
# Extract tokens
access_token = params.get("access_token", [""])[0]
refresh_token = params.get("refresh_token", [""])[0]
expiry_date = params.get("expiry_date", [""])[0]
scope = params.get("scope", [""])[0]
if access_token:
OAuthCallbackHandler.token_info = TokenInfo(
access_token=access_token,
refresh_token=refresh_token or None,
expires_at=int(expiry_date) if expiry_date else None,
scope=scope or None
)
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"""<html><body>
<h1>Authentication successful!</h1>
<p>You can close this window and return to the terminal.</p>
<script>window.close();</script>
</body></html>""")
else:
OAuthCallbackHandler.error = "No access token received"
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h1>Error: No access token received</h1></body></html>")
def perform_oauth_flow() -> Optional[TokenInfo]:
"""Perform the full OAuth flow with browser-based authentication."""
port = get_available_port()
redirect_uri = f"http://localhost:{port}/oauth2callback"
csrf_token = secrets.token_hex(32)
# Build state payload for cloud function
state_payload = {
"uri": redirect_uri,
"manual": False,
"csrf": csrf_token
}
state = b64encode(json.dumps(state_payload).encode()).decode()
# Build auth URL
auth_params = {
"client_id": CLIENT_ID,
"redirect_uri": CLOUD_FUNCTION_URL, # Cloud function handles the secret
"response_type": "code",
"scope": " ".join(SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state
}
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}"
# Set up callback handler
OAuthCallbackHandler.csrf_token = csrf_token
OAuthCallbackHandler.token_info = None
OAuthCallbackHandler.error = None
# Start local server
server = http.server.HTTPServer(('localhost', port), OAuthCallbackHandler)
server.timeout = 300 # 5 minute timeout
print(f"Opening browser for authentication...", file=sys.stderr)
print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
webbrowser.open(auth_url)
# Wait for callback
print("Waiting for authentication...", file=sys.stderr)
while OAuthCallbackHandler.token_info is None and OAuthCallbackHandler.error is None:
server.handle_request()
server.server_close()
if OAuthCallbackHandler.error:
print(f"Authentication failed: {OAuthCallbackHandler.error}", file=sys.stderr)
return None
return OAuthCallbackHandler.token_info
def get_valid_access_token(interactive: bool = True) -> Optional[str]:
"""
Get a valid access token.
Args:
interactive: If True, will prompt for OAuth flow if no tokens exist.
If False, will return None if no valid tokens.
"""
token_info = get_stored_tokens()
# No tokens - need to authenticate
if not token_info or not token_info.access_token:
if not interactive:
return None
print("No OAuth tokens found. Starting authentication...", file=sys.stderr)
token_info = perform_oauth_flow()
if not token_info:
return None
save_tokens(token_info)
print("Authentication successful!", file=sys.stderr)
return token_info.access_token
# Check if token needs refresh
if is_token_expired(token_info) and token_info.refresh_token:
print("Access token expired, refreshing...", file=sys.stderr)
new_tokens = refresh_access_token(token_info.refresh_token)
if new_tokens:
token_info.access_token = new_tokens.get("access_token", token_info.access_token)
token_info.expires_at = new_tokens.get("expiry_date", token_info.expires_at)
save_tokens(token_info)
print("Token refreshed successfully.", file=sys.stderr)
else:
# Refresh failed - try re-authenticating if interactive
if interactive:
print("Token refresh failed. Re-authenticating...", file=sys.stderr)
token_info = perform_oauth_flow()
if token_info:
save_tokens(token_info)
else:
return None
else:
print("Warning: Failed to refresh token.", file=sys.stderr)
return token_info.access_token if token_info else None
def logout() -> bool:
"""Clear stored OAuth tokens."""
return clear_tokens()
def _print_storage_location():
"""Print information about where tokens are stored."""
print(f"Storage: System keyring ({KEYCHAIN_SERVICE})")
def main():
"""CLI for auth operations."""
import argparse
parser = argparse.ArgumentParser(description="Google Calendar OAuth management")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("login", help="Authenticate with Google")
subparsers.add_parser("logout", help="Clear stored tokens")
subparsers.add_parser("token", help="Print current access token")
subparsers.add_parser("status", help="Check authentication status")
args = parser.parse_args()
if args.command == "login":
token_info = perform_oauth_flow()
if token_info:
save_tokens(token_info)
print("Login successful!")
_print_storage_location()
else:
print("Login failed.")
sys.exit(1)
elif args.command == "logout":
if logout():
print("Logged out successfully.")
else:
print("No tokens to clear.")
elif args.command == "token":
token = get_valid_access_token(interactive=False)
if token:
print(token)
else:
print("Not authenticated. Run: python auth.py login", file=sys.stderr)
sys.exit(1)
elif args.command == "status":
token_info = get_stored_tokens()
if token_info and token_info.access_token:
expired = is_token_expired(token_info)
print(f"Status: Authenticated")
print(f"Token expired: {expired}")
if token_info.expires_at:
expires_in = (token_info.expires_at - int(time.time() * 1000)) / 1000 / 60
print(f"Expires in: {expires_in:.1f} minutes")
_print_storage_location()
else:
print("Status: Not authenticated")
print("Run: python auth.py login")
sys.exit(1)
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Google Calendar API operations.
Lightweight alternative to the full Google Workspace MCP server.
"""
import argparse
import json
import sys
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Optional
from auth import get_valid_access_token
CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"
def get_local_timezone() -> str:
"""Get the local timezone as an IANA timezone string."""
try:
# Python 3.9+ has zoneinfo, try to get local timezone
local_offset = datetime.now().astimezone().strftime('%z')
# Return offset in format like +05:30 or -08:00
return local_offset[:3] + ':' + local_offset[3:]
except Exception:
return '+00:00' # Fallback to UTC
def to_utc_iso(dt_string: str) -> str:
"""
Convert a datetime string to UTC ISO format.
Handles: ISO with timezone, ISO without timezone (assumes local), or already UTC.
"""
if not dt_string:
return dt_string
# Already has Z suffix - it's UTC
if dt_string.endswith('Z'):
return dt_string
# Has timezone offset already
if '+' in dt_string[-6:] or (dt_string[-6:].count('-') > 0 and ':' in dt_string[-6:]):
return dt_string
# No timezone - assume local and add offset
local_offset = get_local_timezone()
return f"{dt_string}{local_offset}"
def api_request(method: str, endpoint: str, data: Optional[dict] = None, params: Optional[dict] = None) -> dict:
"""Make an authenticated request to the Google Calendar API."""
token = get_valid_access_token()
if not token:
return {"error": "Failed to get access token"}
url = f"{CALENDAR_API_BASE}/{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
body = json.dumps(data).encode('utf-8') if data else None
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
response_text = response.read().decode('utf-8')
if response_text:
return json.loads(response_text)
return {"success": True}
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else str(e)
try:
error_json = json.loads(error_body)
error_message = error_json.get("error", {}).get("message", error_body)
except json.JSONDecodeError:
error_message = error_body
return {"error": f"HTTP {e.code}: {error_message}"}
except urllib.error.URLError as e:
return {"error": f"Request failed: {e.reason}"}
except json.JSONDecodeError:
return {"error": "Invalid JSON response"}
def list_calendars() -> dict:
"""List all calendars the user has access to."""
result = api_request("GET", "users/me/calendarList")
if "error" in result:
return result
calendars = result.get("items", [])
return [{"id": c.get("id"), "summary": c.get("summary"), "primary": c.get("primary", False)} for c in calendars]
def get_primary_calendar_id() -> str:
"""Get the primary calendar ID."""
calendars = list_calendars()
if isinstance(calendars, dict) and "error" in calendars:
return "primary"
for cal in calendars:
if cal.get("primary"):
return cal.get("id", "primary")
return "primary"
def list_events(calendar_id: Optional[str] = None, time_min: Optional[str] = None,
time_max: Optional[str] = None, max_results: int = 50) -> dict:
"""List events from a calendar."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
# Default time range: now to 30 days from now (using timezone-aware datetime)
if not time_min:
time_min = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
else:
time_min = to_utc_iso(time_min)
if not time_max:
time_max = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat().replace('+00:00', 'Z')
else:
time_max = to_utc_iso(time_max)
params = {
"timeMin": time_min,
"timeMax": time_max,
"maxResults": max_results,
"singleEvents": "true",
"orderBy": "startTime"
}
result = api_request("GET", f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events", params=params)
if "error" in result:
return result
events = result.get("items", [])
return [
{
"id": e.get("id"),
"summary": e.get("summary"),
"start": e.get("start"),
"end": e.get("end"),
"description": e.get("description"),
"location": e.get("location"),
"attendees": e.get("attendees"),
"htmlLink": e.get("htmlLink"),
"status": e.get("status")
}
for e in events if e.get("status") != "cancelled"
]
def get_event(event_id: str, calendar_id: Optional[str] = None) -> dict:
"""Get details of a specific event."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
return api_request("GET", f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events/{event_id}")
def create_event(summary: str, start: str, end: str, calendar_id: Optional[str] = None,
description: Optional[str] = None, location: Optional[str] = None,
attendees: Optional[list] = None) -> dict:
"""Create a new calendar event. Times without timezone are assumed to be local."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
# Ensure times have timezone info (assume local if not specified)
start_dt = to_utc_iso(start)
end_dt = to_utc_iso(end)
event_data = {
"summary": summary,
"start": {"dateTime": start_dt},
"end": {"dateTime": end_dt}
}
if description:
event_data["description"] = description
if location:
event_data["location"] = location
if attendees:
event_data["attendees"] = [{"email": email} for email in attendees]
return api_request("POST", f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events", data=event_data)
def update_event(event_id: str, calendar_id: Optional[str] = None, summary: Optional[str] = None,
start: Optional[str] = None, end: Optional[str] = None,
description: Optional[str] = None, location: Optional[str] = None,
attendees: Optional[list] = None) -> dict:
"""Update an existing calendar event. Times without timezone are assumed to be local."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
# First get the current event to preserve fields
current = get_event(event_id, calendar_id)
if "error" in current:
return current
# Build update data
event_data = {}
if summary is not None:
event_data["summary"] = summary
else:
event_data["summary"] = current.get("summary")
if start is not None:
event_data["start"] = {"dateTime": to_utc_iso(start)}
else:
event_data["start"] = current.get("start")
if end is not None:
event_data["end"] = {"dateTime": to_utc_iso(end)}
else:
event_data["end"] = current.get("end")
if description is not None:
event_data["description"] = description
elif current.get("description"):
event_data["description"] = current.get("description")
if location is not None:
event_data["location"] = location
elif current.get("location"):
event_data["location"] = current.get("location")
if attendees is not None:
event_data["attendees"] = [{"email": email} for email in attendees]
elif current.get("attendees"):
event_data["attendees"] = current.get("attendees")
return api_request("PUT", f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events/{event_id}", data=event_data)
def delete_event(event_id: str, calendar_id: Optional[str] = None) -> dict:
"""Delete a calendar event."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
result = api_request("DELETE", f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events/{event_id}")
if "error" not in result:
return {"message": f"Successfully deleted event {event_id}"}
return result
def find_free_time(attendees: list, time_min: str, time_max: str, duration_minutes: int) -> dict:
"""Find free time slots for the given attendees."""
# Build freebusy request
items = []
for email in attendees:
if email.lower() == "me":
items.append({"id": get_primary_calendar_id()})
else:
items.append({"id": email})
request_data = {
"timeMin": time_min,
"timeMax": time_max,
"items": items
}
result = api_request("POST", "freeBusy", data=request_data)
if "error" in result:
return result
# Collect all busy times
busy_times = []
calendars = result.get("calendars", {})
for cal_id, cal_data in calendars.items():
for busy in cal_data.get("busy", []):
busy_times.append({
"start": datetime.fromisoformat(busy["start"].replace("Z", "+00:00")),
"end": datetime.fromisoformat(busy["end"].replace("Z", "+00:00"))
})
# Sort busy times by start
busy_times.sort(key=lambda x: x["start"])
# Merge overlapping intervals
merged = []
for busy in busy_times:
if not merged:
merged.append(busy)
elif busy["start"] <= merged[-1]["end"]:
merged[-1]["end"] = max(merged[-1]["end"], busy["end"])
else:
merged.append(busy)
# Find first available slot
start_time = datetime.fromisoformat(time_min.replace("Z", "+00:00"))
end_time = datetime.fromisoformat(time_max.replace("Z", "+00:00"))
duration = timedelta(minutes=duration_minutes)
# Check before first busy period
if not merged or start_time + duration <= merged[0]["start"]:
slot_end = start_time + duration
return {
"start": start_time.isoformat().replace("+00:00", "Z"),
"end": slot_end.isoformat().replace("+00:00", "Z")
}
# Check gaps between busy periods
for i in range(len(merged) - 1):
gap_start = merged[i]["end"]
gap_end = merged[i + 1]["start"]
if gap_end - gap_start >= duration:
slot_end = gap_start + duration
return {
"start": gap_start.isoformat().replace("+00:00", "Z"),
"end": slot_end.isoformat().replace("+00:00", "Z")
}
# Check after last busy period
if merged:
last_end = merged[-1]["end"]
if last_end + duration <= end_time:
slot_end = last_end + duration
return {
"start": last_end.isoformat().replace("+00:00", "Z"),
"end": slot_end.isoformat().replace("+00:00", "Z")
}
return {"error": "No available free time found in the specified range"}
def respond_to_event(event_id: str, response_status: str, calendar_id: Optional[str] = None,
send_notification: bool = True) -> dict:
"""Respond to a calendar event invitation (accept, decline, tentative)."""
if not calendar_id:
calendar_id = get_primary_calendar_id()
if response_status not in ["accepted", "declined", "tentative"]:
return {"error": "Invalid response status. Use: accepted, declined, or tentative"}
# Get the current event
event = get_event(event_id, calendar_id)
if "error" in event:
return event
attendees = event.get("attendees", [])
if not attendees:
return {"error": "Event has no attendees"}
# Find self in attendees and update response
self_found = False
for attendee in attendees:
if attendee.get("self"):
attendee["responseStatus"] = response_status
self_found = True
break
if not self_found:
return {"error": "You are not an attendee of this event"}
# Update the event
params = {"sendNotifications": str(send_notification).lower()}
result = api_request(
"PATCH",
f"calendars/{urllib.parse.quote(calendar_id, safe='')}/events/{event_id}?" + urllib.parse.urlencode(params),
data={"attendees": attendees}
)
if "error" not in result:
return {
"eventId": result.get("id"),
"summary": result.get("summary"),
"responseStatus": response_status,
"message": f"Successfully {response_status} the meeting invitation"
}
return result
def main():
parser = argparse.ArgumentParser(description="Google Calendar API operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list-calendars
subparsers.add_parser("list-calendars", help="List all calendars")
# list-events
list_events_parser = subparsers.add_parser("list-events", help="List events from a calendar")
list_events_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
list_events_parser.add_argument("--time-min", help="Start time (ISO 8601)")
list_events_parser.add_argument("--time-max", help="End time (ISO 8601)")
list_events_parser.add_argument("--max-results", type=int, default=50, help="Max events to return")
# get-event
get_event_parser = subparsers.add_parser("get-event", help="Get details of a specific event")
get_event_parser.add_argument("event_id", help="Event ID")
get_event_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
# create-event
create_event_parser = subparsers.add_parser("create-event", help="Create a new event")
create_event_parser.add_argument("summary", help="Event title")
create_event_parser.add_argument("start", help="Start time (ISO 8601, e.g., 2024-01-15T10:30:00Z)")
create_event_parser.add_argument("end", help="End time (ISO 8601)")
create_event_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
create_event_parser.add_argument("--description", help="Event description")
create_event_parser.add_argument("--location", help="Event location")
create_event_parser.add_argument("--attendees", nargs="+", help="Attendee email addresses")
# update-event
update_event_parser = subparsers.add_parser("update-event", help="Update an existing event")
update_event_parser.add_argument("event_id", help="Event ID")
update_event_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
update_event_parser.add_argument("--summary", help="New event title")
update_event_parser.add_argument("--start", help="New start time (ISO 8601)")
update_event_parser.add_argument("--end", help="New end time (ISO 8601)")
update_event_parser.add_argument("--description", help="New description")
update_event_parser.add_argument("--location", help="New location")
update_event_parser.add_argument("--attendees", nargs="+", help="New attendee email addresses")
# delete-event
delete_event_parser = subparsers.add_parser("delete-event", help="Delete an event")
delete_event_parser.add_argument("event_id", help="Event ID")
delete_event_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
# find-free-time
find_free_parser = subparsers.add_parser("find-free-time", help="Find free time slots")
find_free_parser.add_argument("--attendees", nargs="+", required=True, help="Attendee emails (use 'me' for yourself)")
find_free_parser.add_argument("--time-min", required=True, help="Start of search range (ISO 8601)")
find_free_parser.add_argument("--time-max", required=True, help="End of search range (ISO 8601)")
find_free_parser.add_argument("--duration", type=int, required=True, help="Meeting duration in minutes")
# respond-to-event
respond_parser = subparsers.add_parser("respond-to-event", help="Respond to an event invitation")
respond_parser.add_argument("event_id", help="Event ID")
respond_parser.add_argument("response", choices=["accepted", "declined", "tentative"], help="Response status")
respond_parser.add_argument("--calendar", help="Calendar ID (default: primary)")
respond_parser.add_argument("--no-notify", action="store_true", help="Don't send notification to organizer")
args = parser.parse_args()
if args.command == "list-calendars":
result = list_calendars()
elif args.command == "list-events":
result = list_events(args.calendar, args.time_min, args.time_max, args.max_results)
elif args.command == "get-event":
result = get_event(args.event_id, args.calendar)
elif args.command == "create-event":
result = create_event(args.summary, args.start, args.end, args.calendar,
args.description, args.location, args.attendees)
elif args.command == "update-event":
result = update_event(args.event_id, args.calendar, args.summary, args.start, args.end,
args.description, args.location, args.attendees)
elif args.command == "delete-event":
result = delete_event(args.event_id, args.calendar)
elif args.command == "find-free-time":
result = find_free_time(args.attendees, args.time_min, args.time_max, args.duration)
elif args.command == "respond-to-event":
result = respond_to_event(args.event_id, args.response, args.calendar, not args.no_notify)
else:
result = {"error": f"Unknown command: {args.command}"}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
if __name__ == "__main__":
main()
keyring>=24.0.0