SLOPSHOPPER

calendar

[Experimental] Upcoming Google Calendar events in-session — /cal toggles a band above the prompt with the next 7 days, /cal N for N days (Claude Mod; needs…

newbandcommandprocesstimer
★ 2v0.1.4MITupdated 2026-10-07musingfox/cc-plugins/calendar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · calendar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cal Unavailable: Google Calendar connector did not answer ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Unavailable: Google Calendar connector did not answer ⟨Claude Code's own drawing⟩
README

calendar

Shows upcoming Google Calendar events inside a Claude Code session, as one Claude Mod (a function-hooks module, hooks/register.ts). Nothing it shows enters the conversation.

  • /cal: toggles a band above the prompt listing the next 7 days of events.
  • /cal N: lists the next N days (1–31), turns the band on, and remembers N.

Needs Claude Code 2.1.287 or later, where Claude Mods are on by default. Built and tested against Claude Code 2.1.287.

Where events come from

Every fetch goes through the claude.ai Google Calendar connector with $.mcp.call: one list_calendars, then one list_events per calendar for the window from now to N days ahead. Every calendar you can see is listed except those named in the plugin option exclude_calendars (names or ids). Claude Code asks for it when the plugin is enabled; a list option has no /config row, so change it later under pluginConfigs["calendar@<marketplace>"].options in ~/.claude/settings.json.

Events are fetched when /cal turns the band on, at session start when the band was left on, and every 15 minutes while it is on. Nothing is fetched while the band is off. A fetch never blocks a prompt or a tool call. The claude.ai connectors are still connecting when a session starts, so a failed fetch with nothing yet on screen is retried every 30 s, at most 10 times.

$.mcp.call asks no permission. Any installed Claude Mod can read your mail and calendar through a connected claude.ai connector this way. Install third-party mods with that in mind.

The band

One line per event, soonest first, in the machine's time zone (TZ when set):

今天      14:00–15:00        Dentist  @Clinic  還有 4h  [ 日曆 ]
明天      全天               中秋節  [ 日曆 ]
09/27 日  22:00–09/28 01:30  Trip  [ 日曆 ]  [ 連結 ]
09/28 一  18:00–21:00        Dinner  @Home  [ 日曆 ]  [ 連結 ]
  • The day reads 今天, 明天, or MM/DD <weekday>, in blue.
  • An all-day event reads 全天; a timed one its start and end, with the end's date when it ends on another day.
  • The first timed event (the one under way, or the next to start) has its title in bold green. Today's timed events end in a green countdown, 還有 3h 45m, or 進行中 once started; the band redraws every minute so it keeps moving.
  • @<location> follows the title, dimmed, when the event has one and it is not a link.
  • Each line ends in buttons that open a page in the browser without printing its URL. [ 日曆 ] opens the event in Google Calendar, or its day when the event has no link. [ 連結 ] opens the event's meeting (Google Meet or another conference's video link), or its location when the location is an https link. On the terminal a button runs open (xdg-open where there is no open); the desktop app draws them as links.
  • Cancelled events are left out, and an event shared into several calendars shows once.
  • A dim notice line says Fetching calendar events, Unavailable: <reason> before any fetch succeeded, Stale: <reason>; showing data from <age> ago after a failed one, or No events in the next N days.
  • More events than the band holds end in a dim … N more. Every line is cut at the band's width.

The band shares its space with any band another mod draws there (/quota, for one) instead of hiding it. The band's on/off state and N are kept in the plugin's store and read at session start.

Tests

claude plugin test calendar

The fixtures in tests/fixtures/world.ts are synthetic; no real calendar data belongs in this repo.

Source 3 files
hooks/register.ts 182 lines
1import type { On } from 'claude-code'
2import { CALENDAR_BUTTON, calModelOf, URL_BUTTON } from './cal-model.ts'
3import type { CalView } from './cal-model.ts'
4import { calendarsOf, eventsOf, payloadOf, wantedOf } from './events.ts'
5import type { CalEvent } from './events.ts'
6
7const SERVER = 'claude.ai Google Calendar'
8const DEFAULT_DAYS = 7
9const MAX_DAYS = 31
10const DAY_MS = 86_400_000
11const POLL_MS = 900_000
12const RETRY_MS = 30_000
13const MAX_RETRIES = 10
14const BAND_KEY = 'band'
15const DAYS_KEY = 'days'
16
17type Reading = { ok: true; events: CalEvent[] } | { ok: false; reason: string }
18
19let view: CalView = { events: null, failure: null, lastGoodAt: null, days: DEFAULT_DAYS }
20let bandOn = false
21let excluded: string[] = []
22let poll: { cancel(): void } | null = null
23let tick: { cancel(): void } | null = null
24let fetchesStarted = 0
25let lastPublished = 0
26let retries = 0
27
28async function call($: any, tool: string, args: Record<string, unknown>): Promise<unknown> {
29  try {
30    return payloadOf(await $.mcp.call(SERVER, tool, args))
31  } catch {
32    return undefined
33  }
34}
35
36async function fetchEvents($: any, days: number): Promise<Reading> {
37  const calendars = calendarsOf(await call($, 'list_calendars', {}))
38  if (!calendars) return { ok: false, reason: 'Google Calendar connector did not answer' }
39  const now = await $.clock.now()
40  const window = { startTime: new Date(now).toISOString(), endTime: new Date(now + days * DAY_MS).toISOString() }
41  const wanted = wantedOf(calendars, excluded)
42  const lists = await Promise.all(
43    wanted.map(async (c) =>
44      eventsOf(await call($, 'list_events', { calendarId: c.id, ...window, orderBy: 'startTime', pageSize: 100 })),
45    ),
46  )
47  const failed = wanted.filter((_, i) => lists[i] === null).map((c) => c.name)
48  if (failed.length) return { ok: false, reason: `could not read ${failed.join(', ')}` }
49  return { ok: true, events: lists.flatMap((l) => l ?? []) }
50}
51
52// A fetch that started before one already published would put older data back on screen.
53async function fetchAndPublish($: any) {
54  const seq = ++fetchesStarted
55  const reading = await fetchEvents($, view.days)
56  const now = await $.clock.now()
57  if (seq < lastPublished) return
58  lastPublished = seq
59  view = reading.ok
60    ? { ...view, events: reading.events, failure: null, lastGoodAt: now }
61    : { ...view, failure: reading.reason }
62  $.ui.invalidate('ui.render')
63  // At session start the claude.ai connectors are still connecting, so a band left on
64  // would otherwise read "Unavailable" until the 15-minute poll.
65  if (reading.ok) retries = 0
66  else if (!view.events && bandOn && retries < MAX_RETRIES) {
67    retries += 1
68    $.clock.after(RETRY_MS, () => {
69      void fetchAndPublish($).catch(() => {})
70    })
71  }
72}
73
74async function remember($: any, key: string, value: unknown) {
75  try {
76    await $.store.set(key, value)
77  } catch {
78    // The band still follows the command for this session; only the next one will not remember it.
79  }
80}
81
82async function readStore($: any) {
83  try {
84    bandOn = (await $.store.get(BAND_KEY)) === true
85    const days = await $.store.get(DAYS_KEY)
86    if (Number.isInteger(days) && days >= 1 && days <= MAX_DAYS) view = { ...view, days }
87  } catch {
88    bandOn = false
89  }
90}
91
92async function showBand($: any, on: boolean) {
93  bandOn = on
94  await remember($, BAND_KEY, on)
95  $.ui.invalidate('ui.render')
96  if (on) void fetchAndPublish($).catch(() => {})
97  return {}
98}
99
100const DAY_COLOR = '#5b9cf5'
101const NEXT_COLOR = '#46a758'
102
103// The engine has no call that opens a URL, and a Link the terminal cannot open prints its URL.
104function openUrl($: any, url: string) {
105  return $.process.run(['open', url]).catch(() => $.process.run(['xdg-open', url]))
106}
107
108async function renderBand($: any, e: any, beneath: unknown) {
109  const { Box, Text, Link, Button } = await $.ui.resolve(e)
110  // Only the terminal can run a command: `process` is CLI only.
111  const button = (key: string, label: string, href: string) =>
112    e.surface === 'terminal'
113      ? Button({ key, label, onPress: () => void openUrl($, href).catch(() => {}) })
114      : Text({ children: [Link({ href, children: [`[ ${label} ]`] })] })
115  const tz = (await $.env.get('TZ')) || Intl.DateTimeFormat().resolvedOptions().timeZone
116  const model = calModelOf(view, await $.clock.now(), tz)
117  const room = Math.max(1, (e.props.maxRows ?? 10) - (model.notice ? 1 : 0))
118  const hidden = model.rows.length > room ? model.rows.length - room + 1 : 0
119  const lines = []
120  if (model.notice) lines.push(Text({ dimColor: true, wrap: 'truncate-end', children: [model.notice] }))
121  for (const [i, row] of (hidden ? model.rows.slice(0, room - 1) : model.rows).entries()) {
122    const children = [
123      Text({ color: DAY_COLOR, children: [row.day] }),
124      `  ${row.span}  `,
125      row.isNext ? Text({ color: NEXT_COLOR, bold: true, children: [row.title] }) : row.title,
126    ]
127    if (row.location) children.push(Text({ dimColor: true, children: [`  @${row.location}`] }))
128    if (row.note) children.push(Text({ color: NEXT_COLOR, children: [`  ${row.note}`] }))
129    const buttons = [button(`calendar-${i}`, CALENDAR_BUTTON, row.calendarHref)]
130    if (row.urlHref) buttons.push(button(`url-${i}`, URL_BUTTON, row.urlHref))
131    lines.push(Box({ flexDirection: 'row', gap: 2, children: [Text({ wrap: 'truncate-end', children }), ...buttons] }))
132  }
133  if (hidden) lines.push(Text({ dimColor: true, children: [`… ${hidden} more`] }))
134  if (beneath) lines.push(beneath)
135  return Box({ flexDirection: 'column', children: lines })
136}
137
138export function register(on: On, options: { exclude_calendars?: string[] } = {}) {
139  excluded = Array.isArray(options.exclude_calendars) ? options.exclude_calendars : []
140
141  on('session.start', async ($, e, next) => {
142    try {
143      await $.command.register({
144        name: 'cal',
145        description: 'Show upcoming Google Calendar events above the prompt',
146        argumentHint: '[days]',
147        immediate: true,
148      })
149    } catch {
150      // A refused /cal leaves nothing else to run.
151    }
152    await readStore($)
153    if (bandOn) void fetchAndPublish($).catch(() => {})
154    // A reload re-fires session.start on this instance; a second timer would double the cadence.
155    poll?.cancel()
156    poll = $.clock.every(POLL_MS, () => {
157      if (bandOn) void fetchAndPublish($).catch(() => {})
158    })
159    tick?.cancel()
160    tick = $.clock.every(60_000, () => {
161      if (bandOn) $.ui.invalidate('ui.render')
162    })
163    return next(e)
164  })
165
166  on('command.run', { command: 'cal' }, async ($, e) => {
167    const args = (e.args ?? '').trim()
168    if (args === '') return showBand($, !bandOn)
169    const days = Number(args)
170    if (!/^\d+$/.test(args) || days < 1 || days > MAX_DAYS) return { text: `usage: /cal [days], days 1–${MAX_DAYS}` }
171    view = { ...view, days }
172    await remember($, DAYS_KEY, days)
173    return showBand($, true)
174  })
175
176  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
177    if (!bandOn || e.props.hasSurvey) return next(e)
178    // The band is one instance: a tree that drops next(e) hides every other plugin's band.
179    return renderBand($, e, await next(e))
180  })
181}
182
hooks/cal-model.ts 156 lines
1import type { CalEvent, CalTime } from './events.ts'
2
3export type CalView = { events: CalEvent[] | null; failure: string | null; lastGoodAt: number | null; days: number }
4
5// `day` and `span` arrive padded to their column; `note` is today's countdown.
6export type CalRow = {
7  day: string
8  span: string
9  title: string
10  location: string
11  note: string
12  isNext: boolean
13  calendarHref: string
14  urlHref: string | null
15}
16
17export type CalModel = { notice: string | null; rows: CalRow[] }
18
19const MINUTE = 60_000
20const HOUR = 60 * MINUTE
21const DAY_MS = 24 * HOUR
22const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
23const ALL_DAY = '全天'
24export const CALENDAR_BUTTON = '日曆'
25export const URL_BUTTON = '連結'
26
27type Local = { date: string; time: string }
28
29function localOf(ms: number, tz: string): Local {
30  const parts = new Intl.DateTimeFormat('en-CA', {
31    timeZone: tz,
32    year: 'numeric',
33    month: '2-digit',
34    day: '2-digit',
35    hour: '2-digit',
36    minute: '2-digit',
37    hourCycle: 'h23',
38  }).formatToParts(ms)
39  const part = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
40  return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
41}
42
43function monthDayOf(date: string): string {
44  return `${date.slice(5, 7)}/${date.slice(8, 10)}`
45}
46
47function dayLabelOf(date: string, today: string, tomorrow: string): string {
48  if (date === today) return '今天'
49  if (date === tomorrow) return '明天'
50  const [y, m, d] = date.split('-').map(Number)
51  return `${monthDayOf(date)} ${WEEKDAYS[new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay()]}`
52}
53
54function startOf(time: CalTime, tz: string): Local {
55  return time.kind === 'date' ? { date: time.date, time: '' } : localOf(time.ms, tz)
56}
57
58function spanOf(event: CalEvent, start: Local, tz: string): string {
59  if (event.start.kind === 'date') return ALL_DAY
60  if (!event.end || event.end.kind === 'date') return start.time
61  const end = localOf(event.end.ms, tz)
62  return end.date === start.date ? `${start.time}–${end.time}` : `${start.time}–${monthDayOf(end.date)} ${end.time}`
63}
64
65// Terminal cells: a CJK character takes two.
66export function cellsOf(text: string): number {
67  let cells = 0
68  for (const ch of text) cells += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1
69  return cells
70}
71
72function padCells(text: string, width: number): string {
73  return text + ' '.repeat(Math.max(0, width - cellsOf(text)))
74}
75
76// A Link refuses anything but an https URL spelled as the URL parser spells it.
77function httpsHref(text: string): string | null {
78  try {
79    const url = new URL(text)
80    return url.protocol === 'https:' ? url.href : null
81  } catch {
82    return null
83  }
84}
85
86function dayHrefOf(date: string): string {
87  const [y, m, d] = date.split('-').map(Number)
88  return `https://calendar.google.com/calendar/r/day/${y}/${m}/${d}`
89}
90
91function durationOf(ms: number): string {
92  const h = Math.floor(ms / HOUR)
93  const m = Math.floor((ms % HOUR) / MINUTE)
94  return h && m ? `${h}h ${m}m` : h ? `${h}h` : `${m}m`
95}
96
97function noteOf(event: CalEvent, start: Local, today: string, nowMs: number): string {
98  if (event.start.kind !== 'time' || start.date !== today) return ''
99  return event.start.ms <= nowMs ? '進行中' : `還有 ${durationOf(event.start.ms - nowMs)}`
100}
101
102export function lineOf(row: CalRow): string {
103  const buttons = [CALENDAR_BUTTON, row.urlHref && URL_BUTTON].filter(Boolean).map((label) => `[ ${label} ]`)
104  return [row.day, row.span, row.title, row.location && `@${row.location}`, row.note, ...buttons].filter(Boolean).join('  ')
105}
106
107function rowsOf(events: CalEvent[], nowMs: number, tz: string): CalRow[] {
108  const today = localOf(nowMs, tz).date
109  const tomorrow = localOf(nowMs + DAY_MS, tz).date
110  const seen = new Set<string>()
111  const lines = events
112    .map((event) => ({ event, start: startOf(event.start, tz) }))
113    .filter(({ event, start }) => {
114      const key = `${event.id}|${start.date}|${start.time}`
115      if (seen.has(key)) return false
116      seen.add(key)
117      return true
118    })
119    .sort((a, b) => `${a.start.date} ${a.start.time}`.localeCompare(`${b.start.date} ${b.start.time}`))
120    .map(({ event, start }) => ({
121      date: start.date,
122      day: dayLabelOf(start.date, today, tomorrow),
123      span: spanOf(event, start, tz),
124      event,
125      note: noteOf(event, start, today, nowMs),
126    }))
127  const dayWidth = Math.max(0, ...lines.map((l) => cellsOf(l.day)))
128  const spanWidth = Math.max(0, ...lines.map((l) => cellsOf(l.span)))
129  const next = lines.findIndex((l) => l.event.start.kind === 'time')
130  return lines.map(({ date, day, span, event, note }, i) => {
131    const urlHref = event.url ? httpsHref(event.url) : null
132    return {
133      day: padCells(day, dayWidth),
134      span: padCells(span, spanWidth),
135      title: event.title,
136      location: event.location === event.url && urlHref ? '' : event.location,
137      note,
138      isNext: i === next,
139      calendarHref: (event.link && httpsHref(event.link)) || dayHrefOf(date),
140      urlHref,
141    }
142  })
143}
144
145export function calModelOf(view: CalView, nowMs: number, tz: string): CalModel {
146  if (!view.events) {
147    return { notice: view.failure ? `Unavailable: ${view.failure}` : 'Fetching calendar events', rows: [] }
148  }
149  const stale = view.failure
150    ? `Stale: ${view.failure}; showing data from ${durationOf(nowMs - (view.lastGoodAt ?? nowMs))} ago`
151    : null
152  const rows = rowsOf(view.events, nowMs, tz)
153  const empty = rows.length ? null : `No events in the next ${view.days} days`
154  return { notice: stale ?? empty, rows }
155}
156
hooks/events.ts 82 lines
1export type CalTime = { kind: 'date'; date: string } | { kind: 'time'; ms: number }
2
3export type CalEvent = { id: string; title: string; location: string; link: string; url: string; start: CalTime; end: CalTime | null }
4
5export type Calendar = { id: string; name: string }
6
7function field(value: unknown, key: string): unknown {
8  return value !== null && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined
9}
10
11function stringOf(value: unknown): string {
12  return typeof value === 'string' ? value : ''
13}
14
15// An MCP tool answers its JSON as the text of its content blocks.
16export function payloadOf(result: unknown): unknown {
17  if (field(result, 'isError') === true) return undefined
18  const blocks = field(result, 'content')
19  if (!Array.isArray(blocks)) return undefined
20  const text = blocks.map((b) => (field(b, 'type') === 'text' ? stringOf(field(b, 'text')) : '')).join('')
21  try {
22    return JSON.parse(text)
23  } catch {
24    return undefined
25  }
26}
27
28function timeOf(raw: unknown): CalTime | null {
29  const dateTime = stringOf(field(raw, 'dateTime'))
30  if (dateTime) {
31    const ms = Date.parse(dateTime)
32    return Number.isFinite(ms) ? { kind: 'time', ms } : null
33  }
34  // The connector spells an all-day date as midnight UTC (`2026-10-09T00:00:00Z`); the
35  // calendar day is the date part, which parsing it as a time would shift west of UTC.
36  const date = stringOf(field(raw, 'date'))
37  return /^\d{4}-\d{2}-\d{2}(T|$)/.test(date) ? { kind: 'date', date: date.slice(0, 10) } : null
38}
39
40// A meeting's join link, or a location that is itself a link.
41function urlOf(raw: unknown): string {
42  const entryPoints = field(field(raw, 'conferenceData'), 'entryPoints')
43  const video = Array.isArray(entryPoints) ? entryPoints.find((p) => field(p, 'entryPointType') === 'video') : undefined
44  const location = stringOf(field(raw, 'location'))
45  return stringOf(field(raw, 'hangoutLink')) || stringOf(field(video, 'uri')) || (/^https:\/\//.test(location) ? location : '')
46}
47
48export function calendarsOf(payload: unknown): Calendar[] | null {
49  const list = field(payload, 'calendars')
50  if (!Array.isArray(list)) return null
51  return list.flatMap((c) => {
52    const id = stringOf(field(c, 'id'))
53    return id ? [{ id, name: stringOf(field(c, 'summary')) || id }] : []
54  })
55}
56
57// A calendar with no events in the window answers no `events` key at all.
58export function eventsOf(payload: unknown): CalEvent[] | null {
59  if (payload === null || typeof payload !== 'object') return null
60  const list = field(payload, 'events') ?? []
61  if (!Array.isArray(list)) return null
62  return list.flatMap((raw) => {
63    const start = timeOf(field(raw, 'start'))
64    if (!start || field(raw, 'status') === 'cancelled') return []
65    return [
66      {
67        id: stringOf(field(raw, 'id')),
68        title: stringOf(field(raw, 'summary')) || '(no title)',
69        location: stringOf(field(raw, 'location')),
70        link: stringOf(field(raw, 'htmlLink')),
71        url: urlOf(raw),
72        start,
73        end: timeOf(field(raw, 'end')),
74      },
75    ]
76  })
77}
78
79export function wantedOf(calendars: Calendar[], excluded: string[]): Calendar[] {
80  return calendars.filter((c) => !excluded.includes(c.name) && !excluded.includes(c.id))
81}
82