Skip to content

Instantly share code, notes, and snippets.

@parasquid
Last active April 17, 2026 08:44
Show Gist options
  • Select an option

  • Save parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a to your computer and use it in GitHub Desktop.

Select an option

Save parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a to your computer and use it in GitHub Desktop.
Expose your Google Calendar to AI coding agents via the private iCal URL — no MCP, no OAuth, no CLI auth. Single file, AGPL.

calendar-today

A single-file CLI that exposes your Google Calendar to AI coding agents (or just your terminal) via the private iCal URL — no MCP, no OAuth, no CLI auth dance.

TL;DR

Install the script and a config template with one command:

curl -fsSL https://gist.githubusercontent.com/parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a/raw/install.sh | sh

Then edit ~/.local/bin/.env to add your Google Calendar Secret iCal URL, and run:

calendar-today

Requires uv and curl. ~/.local/bin must be on your PATH. The installer checks both and tells you if either is missing.

Don't want to pipe remote scripts into your shell? Manual install:
# 1. Install the script
mkdir -p ~/.local/bin
curl -fsSL https://gist.githubusercontent.com/parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a/raw/calendar-today -o ~/.local/bin/calendar-today
chmod +x ~/.local/bin/calendar-today

# 2. Create the config
cat > ~/.local/bin/.env <<'EOF'
CALENDAR_ICS_URL=
EOF
chmod 600 ~/.local/bin/.env

# 3. Edit ~/.local/bin/.env and set CALENDAR_ICS_URL to your Secret iCal URL

Why

Most ways of giving an AI coding agent access to your calendar are heavy:

  • MCP Google Calendar servers — install, configure, OAuth, permission prompts.
  • CLIs like gcalcli / gws — OAuth flow, scope management, token refresh.
  • Google Calendar API directly — service accounts, credentials, SDK.

For the narrow use case of "let my AI agent see my own calendar so it can help me organize my day," all of that is overkill. Google Calendar exposes a private iCal URL per user — a single secret URL that returns the calendar as .ics. That's enough.

This script:

  1. Reads the private iCal URL from a colocated .env file.
  2. Fetches the feed with curl.
  3. Parses with icalendar (installed on the fly via uv — no virtualenv to manage).
  4. Expands recurring events correctly — RRULE + EXDATE + RECURRENCE-ID overrides. An event moved from 14:00 to 13:30 shows at 13:30, not both.
  5. Prints a clean, grep-friendly list to stdout.

AI agents (Claude Code, Cursor, Aider, etc.) can now run calendar-today as a normal command and see your schedule. No tokens, no refresh, no MCP.

Use cases

  • "What's on my calendar today?" → agent runs calendar-today.
  • "Help me plan tomorrow" → calendar-today --date 2026-04-20.
  • "Summarize my week" → calendar-today --week.
  • Timesheet generation — agent reads your calendar, drafts FreshBooks / Toggl / Harvest entries.
  • Meeting prep — agent scans events and drafts notes or agendas.

Getting the Secret iCal URL

Per Google's official docs:

  1. Open Google Calendar.
  2. Click the gear icon → Settings.
  3. Under "Settings for my calendars," click the calendar you want to expose.
  4. Scroll to Integrate calendar.
  5. Copy the Secret address in iCal format.

⚠️ This URL is a credential. Anyone with it can read your calendar. Don't paste it into chat, issue trackers, or git. If you leak it, click Reset in the same section to invalidate it.

Usage

calendar-today                     # today
calendar-today --date 2026-04-20   # specific day
calendar-today --days 3            # next 3 days starting today
calendar-today --week              # next 7 days

Example output:

Friday 2026-04-17
  09:00–09:30  Morning stand-up
  13:30–14:00  Delivery Stand Up
  14:00–15:30  Investment Time: How I AI  @ Office

Telling your AI agent about it

For Claude Code, add this to ~/.claude/CLAUDE.md (global) or .claude/CLAUDE.md (project):

## Calendar access

Run `calendar-today` to see my schedule. Flags: `--date YYYY-MM-DD`, `--days N`, `--week`.
The script reads my private iCal URL from `~/.local/bin/.env`. Don't echo its contents back.

Other agents — point their instruction file at the same command.

Security

  • The Secret iCal URL is read-only and scoped to a single calendar. It cannot modify your calendar or access other Google services.
  • Treat it like a password. chmod 600 on the .env. Don't commit it.
  • Some work/school Google accounts disable the Secret Address feature — your admin can turn it off.
  • The script has no telemetry, no network traffic beyond the single fetch to calendar.google.com, and no third-party services.

Why uv and not a virtualenv?

The script uses PEP 723 inline script metadata — dependencies (icalendar, python-dateutil) are declared at the top of the file. uv run reads that, downloads to a cache, and reuses on subsequent runs. No requirements.txt, no virtualenv, no pip install. Delete the script and the cache cleans itself up.

License

AGPL-3.0-or-later. Share improvements.

#!/usr/bin/env -S uv run --quiet --with icalendar --with python-dateutil --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["icalendar", "python-dateutil"]
# ///
#
# SPDX-License-Identifier: AGPL-3.0-or-later
# calendar-today — show your Google Calendar in the terminal via the private iCal URL.
# Copyright (C) 2026 Tristan <git@parasquid.com> (https://github.com/parasquid)
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version. See https://www.gnu.org/licenses/agpl-3.0.html.
"""
Show Google Calendar events in a date range (today by default).
Reads the Google Calendar "Secret address in iCal format" URL from a `.env`
file colocated with this script, under the key `CALENDAR_ICS_URL`. Example:
CALENDAR_ICS_URL=https://calendar.google.com/calendar/ical/.../basic.ics
Get the URL from Google Calendar: Settings → your calendar → Integrate
calendar → Secret address in iCal format. Keep the `.env` chmod 600 — anyone
with the URL can read your calendar. If you accidentally share it, use the
"Reset" button in the same Integrate calendar section to invalidate it.
Official instructions: https://support.google.com/calendar/answer/37648?hl=en
Handles recurring events correctly: expands RRULEs, applies EXDATEs, and
suppresses original times for instances that have a RECURRENCE-ID override
(so an event moved from 14:00 to 13:30 shows only at 13:30, not both).
Requires uv (https://docs.astral.sh/uv/) — deps are installed on the fly
via the inline script metadata block at the top of this file.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from typing import Iterable
from dateutil.rrule import rrulestr
from icalendar import Calendar
SCRIPT_DIR = Path(__file__).resolve().parent
ENV_FILE = SCRIPT_DIR / ".env"
LOCAL_TZ = datetime.now(timezone.utc).astimezone().tzinfo # system local tz
def read_env(path: Path) -> dict[str, str]:
"""Minimal KEY=VALUE parser for .env. Supports `#` comments and quoted values."""
env: dict[str, str] = {}
if not path.exists():
return env
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Strip surrounding quotes if present
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
env[key] = value
return env
def fetch_ics() -> bytes:
env = read_env(ENV_FILE)
url = env.get("CALENDAR_ICS_URL", "").strip()
if not url:
sys.exit(
f"error: CALENDAR_ICS_URL not set in {ENV_FILE}.\n"
f"Put your Google Calendar iCal URL there as:\n"
f" CALENDAR_ICS_URL=https://calendar.google.com/calendar/ical/.../basic.ics"
)
out = subprocess.run(
["curl", "-sfL", url],
check=False,
capture_output=True,
)
if out.returncode != 0:
sys.exit(f"error: curl failed ({out.returncode}): {out.stderr.decode(errors='replace')}")
return out.stdout
def as_aware_dt(value) -> datetime:
"""Normalize icalendar date/datetime to aware datetime in local tz."""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=LOCAL_TZ)
return value.astimezone(LOCAL_TZ)
# bare date — treat as midnight local
return datetime.combine(value, time.min, tzinfo=LOCAL_TZ)
def event_occurrences(
comp,
window_start: datetime,
window_end: datetime,
overridden_times: set[datetime] | None = None,
) -> Iterable[tuple[datetime, datetime]]:
"""Yield (start, end) pairs for an event that fall within [window_start, window_end).
`overridden_times` is a set of the original DTSTARTs (in local tz) for this UID
that have been overridden by RECURRENCE-ID events. Those are suppressed from
master expansion so the override's own time is the only one shown.
"""
overridden_times = overridden_times or set()
dtstart = comp.get("DTSTART")
dtend = comp.get("DTEND")
if dtstart is None:
return
start = as_aware_dt(dtstart.dt)
if dtend is not None:
end = as_aware_dt(dtend.dt)
else:
duration = comp.get("DURATION")
if duration is not None:
end = start + duration.dt
else:
end = start + timedelta(hours=1)
duration = end - start
rrule = comp.get("RRULE")
if rrule is None:
if start < window_end and end > window_start:
yield start, end
return
# Expand recurrence
try:
rule_str = rrule.to_ical().decode()
rule = rrulestr(rule_str, dtstart=start)
except Exception:
# Bad rule — fall back to single occurrence
if start < window_end and end > window_start:
yield start, end
return
exdates: set[datetime] = set()
exdate_field = comp.get("EXDATE")
if exdate_field is not None:
items = exdate_field if isinstance(exdate_field, list) else [exdate_field]
for ed in items:
for d in ed.dts:
exdates.add(as_aware_dt(d.dt))
for occ_start in rule.between(
window_start - duration, window_end, inc=True
):
if occ_start in exdates or occ_start in overridden_times:
continue
occ_end = occ_start + duration
if occ_start < window_end and occ_end > window_start:
yield occ_start, occ_end
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Show calendar events in a date range.")
g = p.add_mutually_exclusive_group()
g.add_argument("--date", help="Single day YYYY-MM-DD (default: today).")
g.add_argument("--days", type=int, default=None, help="Next N days starting today.")
g.add_argument("--week", action="store_true", help="Next 7 days.")
return p.parse_args()
def main() -> None:
args = parse_args()
if args.date:
start_date = datetime.strptime(args.date, "%Y-%m-%d").date()
ndays = 1
elif args.week:
start_date = date.today()
ndays = 7
elif args.days is not None:
start_date = date.today()
ndays = max(1, args.days)
else:
start_date = date.today()
ndays = 1
window_start = datetime.combine(start_date, time.min, tzinfo=LOCAL_TZ)
window_end = window_start + timedelta(days=ndays)
cal = Calendar.from_ical(fetch_ics())
# First pass: collect RECURRENCE-ID overrides per UID so master RRULE expansion
# can suppress the original slots.
overridden_by_uid: dict[str, set[datetime]] = defaultdict(set)
for comp in cal.walk("VEVENT"):
rid = comp.get("RECURRENCE-ID")
if rid is None:
continue
uid = str(comp.get("UID") or "")
if not uid:
continue
overridden_by_uid[uid].add(as_aware_dt(rid.dt))
occurrences: list[tuple[datetime, datetime, str, str]] = []
for comp in cal.walk("VEVENT"):
# Skip cancelled events (covers both master-level and override-level cancellations)
status = comp.get("STATUS")
if status is not None and str(status).upper() == "CANCELLED":
continue
summary = str(comp.get("SUMMARY") or "(no title)")
location = str(comp.get("LOCATION") or "")
uid = str(comp.get("UID") or "")
# For the master event, suppress occurrences that have been overridden elsewhere.
# Overrides themselves have a RECURRENCE-ID and don't need suppression.
is_override = comp.get("RECURRENCE-ID") is not None
overridden = set() if is_override else overridden_by_uid.get(uid, set())
for s, e in event_occurrences(comp, window_start, window_end, overridden):
occurrences.append((s, e, summary, location))
occurrences.sort(key=lambda x: x[0])
if not occurrences:
print(f"No events between {window_start:%Y-%m-%d %H:%M %Z} and {window_end:%Y-%m-%d %H:%M %Z}.")
return
current_day = None
for s, e, summary, location in occurrences:
day = s.date()
if day != current_day:
print(f"\n{day:%A %Y-%m-%d}")
current_day = day
# All-day if start is midnight local and duration is whole days
if s.time() == time.min and (e - s) % timedelta(days=1) == timedelta(0):
span = f"(all day)"
else:
span = f"{s:%H:%M}–{e:%H:%M}"
loc = f" @ {location}" if location else ""
print(f" {span} {summary}{loc}")
if __name__ == "__main__":
main()
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# calendar-today installer
#
# Usage:
# curl -fsSL https://gist.githubusercontent.com/parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a/raw/install.sh | sh
#
# Installs `calendar-today` into ~/.local/bin and creates a template .env.
# Will not overwrite an existing .env. Safe to re-run.
set -eu
SCRIPT_URL="${CALENDAR_TODAY_SCRIPT_URL:-https://gist.githubusercontent.com/parasquid/a5e6cfe1e6d8d5864bf7cd2b21bf4d4a/raw/calendar-today}"
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
SCRIPT_PATH="$INSTALL_DIR/calendar-today"
ENV_PATH="$INSTALL_DIR/.env"
printf '==> Installing calendar-today into %s\n' "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
curl -fsSL "$SCRIPT_URL" -o "$SCRIPT_PATH"
chmod +x "$SCRIPT_PATH"
printf ' installed %s\n' "$SCRIPT_PATH"
# Create .env template if missing; never overwrite
if [ -f "$ENV_PATH" ]; then
printf '==> %s already exists — leaving untouched.\n' "$ENV_PATH"
else
printf '==> Creating %s template\n' "$ENV_PATH"
umask 077
cat > "$ENV_PATH" <<'EOF'
# calendar-today configuration.
#
# Get your Secret iCal URL from Google Calendar:
# Settings -> your calendar -> Integrate calendar ->
# "Secret address in iCal format"
# https://support.google.com/calendar/answer/37648
#
# Anyone with this URL can read your calendar. Keep this file chmod 600.
CALENDAR_ICS_URL=
EOF
chmod 600 "$ENV_PATH"
printf ' created %s (chmod 600)\n' "$ENV_PATH"
fi
# PATH check
case ":$PATH:" in
*":$INSTALL_DIR:"*)
;;
*)
printf '\n[!] %s is not on your PATH.\n' "$INSTALL_DIR"
printf ' Add this to your shell rc (.zshrc / .bashrc):\n'
printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR"
;;
esac
# uv check
if ! command -v uv >/dev/null 2>&1; then
printf '\n[!] uv is not installed. calendar-today needs it to run.\n'
printf ' Install uv with:\n'
printf ' curl -LsSf https://astral.sh/uv/install.sh | sh\n'
fi
cat <<EOF
==> Done.
Next steps:
1. Edit $ENV_PATH and set CALENDAR_ICS_URL.
2. Run: calendar-today
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment