SLOPSHOPPER

aichemist-calendar-status

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

newtoaststatusprocesstimer
★ 1v3.10.0MITupdated 2026-10-05Anras573/AIchemist/mods/calendar-status
A shopper browsing a rack in a slop shop
README

AIchemist

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.

Installation

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.

What's Included

Agents — Specialized agents for the Task tool:

  • Code Review — Parallel review with Jira integration and confidence scoring
  • TypeScript/React — Full-stack TypeScript, React, Next.js, Node.js
  • .NET — C#/.NET with async patterns, SOLID, DDD, testing
  • DDD — Domain-Driven Design guidance (language-agnostic)

Skills — Context-aware capabilities:

  • Ticket Flow — End-to-end workflow for taking a Jira ticket from definition to reviewed implementation
  • Brainstorming — Structured design dialogue with a hard gate before any implementation
  • Beads — AI-native task tracking with automatic sidecar storage outside your repo
  • Code Review — Parallel review agents with confidence scoring and Jira integration
  • Jira — Issue management with confirmation gates for write operations
  • Mermaid — Generate diagrams (flowcharts, sequence, ER, C4, and more) as markdown
  • PostgreSQL — Safe database querying with blocked writes by default
  • Daily Note — Interact with your Obsidian daily note for journaling and task tracking
  • Capture — Quick capture of thoughts and code snippets to Obsidian
  • Research — Search your Obsidian vault for context during coding sessions
  • Playwright — Browser automation and web testing via playwright-cli
  • Calendar — Calendar queries and meeting prep briefings for Microsoft 365 (via m365 CLI) and iCloud/CalDAV
  • Markitdown — Convert remote URLs and local files to clean markdown
  • MemPalace — Persistent local memory (vector + knowledge graph) across sessions
  • Tool Preferences — Consistent tool selection patterns

Mods — Optional Claude Code-only add-ons, installed separately; they add to the skills above and never replace them:

  • Calendar Status — Current and next meeting in the status line, via the Calendar skill's calendar.sh, for Microsoft 365 or iCloud (claude plugin install aichemist-calendar-status@aichemist)
  • Beads Band — In-progress and ready beads tasks above the prompt, via the Beads skill's beads-db.sh (claude plugin install aichemist-beads-band@aichemist)
  • PR Review Pane — /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.

Quick Start

# 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

Philosophy

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:

  • Composable — Works well independently and in combination
  • Documented — Clear purpose and usage instructions
  • Practical — Solves real problems in daily workflows

License

See LICENSE for details.

Source 2 files
hooks/register.ts 89 lines
1import 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}
89
hooks/calendar.ts 126 lines
1// 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