Claude Code mod: shows your next meeting (Microsoft 365 or iCloud/CalDAV) in the status line, via the calendar skill's tools/calendar.sh

Transmuting raw AI capabilities into golden solutions
A plugin for Claude Code and GitHub Copilot CLI with custom agents and skills for AI-assisted development.
Claude Code:
claude plugin install aichemist
GitHub Copilot CLI:
copilot plugin install aichemist
See docs/installation.md for alternative installation methods and MCP server configuration.
Agents — Specialized agents for the Task tool:
Skills — Context-aware capabilities:
playwright-clim365 CLI) and iCloud/CalDAVMods — Optional Claude Code-only add-ons, installed separately; they add to the skills above and never replace them:
calendar.sh, for Microsoft 365 or iCloud (claude plugin install aichemist-calendar-status@aichemist)beads-db.sh (claude plugin install aichemist-beads-band@aichemist)/pr-pane shows the PR Review Loop's state, CI and unresolved threads in a live pane (claude plugin install aichemist-pr-review-pane@aichemist)See docs/ for detailed documentation.
# Review your current branch (skill auto-invoked by natural language)
# "Do a code review" or /code-review
# See your recent Jira tickets
# "Show my Jira tickets from last week" or /jira
# The agents work automatically via Task tool
# e.g., "Review this PR for DDD patterns" invokes the DDD agent
Like the alchemists of old who sought to transform base metals into gold, AIchemist aims to refine and combine AI building blocks into powerful, practical solutions. Each component is crafted to be:
See LICENSE for details.
hooks/register.ts 89 lines1import type { Register, Timer } from 'claude-code'
2
3import {
4 dueReminders,
5 formatStatus,
6 parseMeetings,
7 reminderKey,
8 toGraphIso,
9 type Meeting,
10} from './calendar'
11
12const MINUTE = 60_000
13const HOUR = 60 * MINUTE
14
15// Shows the next meeting in the status line, read through the same
16// tools/calendar.sh the calendar skill uses (Microsoft 365 or iCloud/CalDAV).
17// Display only: the skill stays the source of truth and works the same
18// without this mod.
19export const register: Register = (on, options) => {
20 const refreshMs = Math.max(1, Number(options.refreshMinutes ?? 5)) * MINUTE
21 const lookaheadMs = Math.max(1, Number(options.lookaheadHours ?? 8)) * HOUR
22 const reminderMs = Math.max(0, Number(options.reminderMinutes ?? 2)) * MINUTE
23
24 let meetings: Meeting[] = []
25 const announced = new Set<string>()
26 let timers: Timer[] = []
27
28 on('session.start', async ($, e, next) => {
29 // Without either backend's variables the person has not set up the
30 // calendar skill, so stay out of the way instead of showing an error.
31 const hasMsgraph = Boolean((await $.env.get('MSGRAPH_APP_ID')) && (await $.env.get('MSGRAPH_TENANT_ID')))
32 const hasCaldav = Boolean(await $.env.get('CALDAV_USERNAME'))
33 if (!e.isInteractive || !(hasMsgraph || hasCaldav)) {
34 return next(e)
35 }
36
37 const script = `${$.plugin.root}/tools/calendar.sh`
38
39 const render = async () => {
40 const now = await $.clock.now()
41 $.ui.status(formatStatus(meetings, now))
42
43 for (const meeting of dueReminders(meetings, now, reminderMs, announced)) {
44 announced.add(reminderKey(meeting))
45 $.ui.toast(`📅 ${meeting.title} starts in ${Math.ceil((meeting.startMs - now) / MINUTE)}m`)
46 }
47 }
48
49 const refresh = async () => {
50 const now = await $.clock.now()
51 try {
52 const { exitCode, stdout } = await $.process.run(
53 [
54 'bash',
55 script,
56 'get-events',
57 '--start',
58 toGraphIso(now),
59 '--end',
60 toGraphIso(now + lookaheadMs),
61 ],
62 { timeoutMs: MINUTE },
63 )
64 // On a failed fetch (expired login, offline) keep the last good list:
65 // its countdowns stay right and past meetings drop off by themselves.
66 if (exitCode === 0) {
67 meetings = parseMeetings(stdout)
68 }
69 } catch {
70 // Timed out or could not start; same as a failed fetch.
71 }
72 await render()
73 }
74
75 // session.start can fire again without a reload (an enable, a worker
76 // respawn); drop the previous timers so they don't stack.
77 for (const timer of timers) {
78 timer.cancel()
79 }
80 timers = [
81 $.clock.after(1, () => void refresh()),
82 $.clock.every(refreshMs, () => void refresh()),
83 $.clock.every(MINUTE, () => void render()),
84 ]
85
86 return next(e)
87 })
88}
89hooks/calendar.ts 126 lines1// Pure helpers for the calendar status line: no `$`, so tests call them directly.
2// Event shape is what `tools/msgraph.sh get-events` prints (Graph calendarView,
3// trimmed by its $select).
4
5export type GraphDateTime = {
6 dateTime: string
7 timeZone: string
8}
9
10export type GraphEvent = {
11 id: string
12 subject?: string | null
13 start: GraphDateTime
14 end: GraphDateTime
15 sensitivity?: string
16 isCancelled?: boolean
17 isAllDay?: boolean
18}
19
20export type Meeting = {
21 id: string
22 title: string
23 startMs: number
24 endMs: number
25}
26
27const MINUTE = 60_000
28
29// calendarView returns UTC wall-clock times without a zone suffix
30// (`2026-05-11T07:00:00.0000000`, timeZone "UTC") unless a Prefer header
31// asks otherwise, which msgraph.sh does not send.
32export function toEpochMs(value: GraphDateTime): number {
33 const hasZone = /(Z|[+-]\d{2}:\d{2})$/.test(value.dateTime)
34 const suffix = !hasZone && value.timeZone === 'UTC' ? 'Z' : ''
35
36 return Date.parse(value.dateTime + suffix)
37}
38
39// Graph rejects offsets without a colon, so spell UTC as +00:00 rather than
40// relying on a local offset the hooks environment may not know.
41export function toGraphIso(ms: number): string {
42 return new Date(ms).toISOString().replace(/\.\d{3}Z$/, '+00:00')
43}
44
45export function parseMeetings(stdout: string): Meeting[] {
46 const raw: unknown = JSON.parse(stdout)
47 if (!Array.isArray(raw)) {
48 return []
49 }
50
51 return (raw as GraphEvent[])
52 .filter(event => !event.isCancelled && !event.isAllDay)
53 .map(event => ({
54 id: event.id,
55 title:
56 event.sensitivity === 'private'
57 ? 'Private event'
58 : event.subject?.trim() || '(no subject)',
59 startMs: toEpochMs(event.start),
60 endMs: toEpochMs(event.end),
61 }))
62 .filter(m => Number.isFinite(m.startMs) && Number.isFinite(m.endMs))
63 .sort((a, b) => a.startMs - b.startMs)
64}
65
66export function formatDuration(ms: number): string {
67 const minutes = Math.max(1, Math.ceil(ms / MINUTE))
68 if (minutes < 60) {
69 return `${minutes}m`
70 }
71
72 const hours = Math.floor(minutes / 60)
73 const rest = minutes % 60
74
75 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
76}
77
78function truncate(text: string, max: number): string {
79 return text.length <= max ? text : `${text.slice(0, max - 1)}…`
80}
81
82// One line: what is on now, what is next, or nothing when the window is empty.
83export function formatStatus(
84 meetings: readonly Meeting[],
85 nowMs: number,
86 maxTitle = 40,
87): string | undefined {
88 const current = meetings.find(m => m.startMs <= nowMs && nowMs < m.endMs)
89 const next = meetings.find(m => m.startMs > nowMs)
90 const parts: string[] = []
91
92 if (current) {
93 parts.push(
94 `Now: ${truncate(current.title, maxTitle)} (ends in ${formatDuration(current.endMs - nowMs)})`,
95 )
96 }
97 if (next) {
98 parts.push(
99 `Next: ${truncate(next.title, maxTitle)} in ${formatDuration(next.startMs - nowMs)}`,
100 )
101 }
102
103 return parts.length === 0 ? undefined : `📅 ${parts.join(' · ')}`
104}
105
106// Keyed on start time too, so a rescheduled meeting is announced again.
107export function reminderKey(meeting: Meeting): string {
108 return `${meeting.id}@${meeting.startMs}`
109}
110
111// Meetings starting within `leadMs` that have not been announced yet.
112export function dueReminders(
113 meetings: readonly Meeting[],
114 nowMs: number,
115 leadMs: number,
116 announced: ReadonlySet<string>,
117): Meeting[] {
118 if (leadMs <= 0) {
119 return []
120 }
121
122 return meetings.filter(
123 m => m.startMs > nowMs && m.startMs - nowMs <= leadMs && !announced.has(reminderKey(m)),
124 )
125}
126