zele 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +112 -0
  4. package/dist/api-utils.d.ts +6 -0
  5. package/dist/api-utils.js +52 -0
  6. package/dist/api-utils.js.map +1 -0
  7. package/dist/auth.d.ts +16 -0
  8. package/dist/auth.js +74 -5
  9. package/dist/auth.js.map +1 -1
  10. package/dist/calendar-client.d.ts +135 -0
  11. package/dist/calendar-client.js +498 -0
  12. package/dist/calendar-client.js.map +1 -0
  13. package/dist/calendar-time.d.ts +24 -0
  14. package/dist/calendar-time.js +245 -0
  15. package/dist/calendar-time.js.map +1 -0
  16. package/dist/cli.js +5 -3
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/auth-cmd.js +5 -5
  19. package/dist/commands/auth-cmd.js.map +1 -1
  20. package/dist/commands/calendar.d.ts +2 -0
  21. package/dist/commands/calendar.js +563 -0
  22. package/dist/commands/calendar.js.map +1 -0
  23. package/dist/generated/browser.d.ts +10 -0
  24. package/dist/generated/client.d.ts +10 -0
  25. package/dist/generated/internal/class.d.ts +22 -0
  26. package/dist/generated/internal/class.js +2 -2
  27. package/dist/generated/internal/class.js.map +1 -1
  28. package/dist/generated/internal/prismaNamespace.d.ts +174 -1
  29. package/dist/generated/internal/prismaNamespace.js +21 -0
  30. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  31. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
  33. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  34. package/dist/generated/models/accounts.d.ts +281 -0
  35. package/dist/generated/models/calendar_events.d.ts +1433 -0
  36. package/dist/generated/models/calendar_events.js +2 -0
  37. package/dist/generated/models/calendar_events.js.map +1 -0
  38. package/dist/generated/models/calendar_lists.d.ts +1131 -0
  39. package/dist/generated/models/calendar_lists.js +2 -0
  40. package/dist/generated/models/calendar_lists.js.map +1 -0
  41. package/dist/generated/models.d.ts +2 -0
  42. package/dist/gmail-cache.d.ts +22 -0
  43. package/dist/gmail-cache.js +76 -0
  44. package/dist/gmail-cache.js.map +1 -1
  45. package/dist/gmail-client.js +1 -48
  46. package/dist/gmail-client.js.map +1 -1
  47. package/dist/output.d.ts +11 -0
  48. package/dist/output.js +42 -0
  49. package/dist/output.js.map +1 -1
  50. package/package.json +4 -2
  51. package/schema.prisma +39 -6
  52. package/scripts/test-device-code-clients.ts +186 -0
  53. package/scripts/test-micropython-scopes.ts +72 -0
  54. package/scripts/test-oauth-clients.ts +257 -0
  55. package/src/api-utils.ts +60 -0
  56. package/src/auth.ts +92 -5
  57. package/src/calendar-client.ts +758 -0
  58. package/src/calendar-time.ts +299 -0
  59. package/src/cli.ts +5 -3
  60. package/src/commands/auth-cmd.ts +5 -5
  61. package/src/commands/calendar.ts +634 -0
  62. package/src/gmail-cache.ts +96 -0
  63. package/src/gmail-client.ts +1 -57
  64. package/src/output.ts +51 -0
  65. package/src/schema.sql +22 -0
@@ -0,0 +1,634 @@
1
+ // Calendar commands: list, events, get, create, update, delete, respond, freebusy.
2
+ // Manages Google Calendar with YAML output and cache integration.
3
+ // Multi-account: list/events fetch all accounts concurrently and merge by start time.
4
+ // Improved UX over gogcli: multi-account by default, +duration syntax, cleaner output.
5
+
6
+ import type { Goke } from 'goke'
7
+ import { z } from 'zod'
8
+ import readline from 'node:readline'
9
+ import { getCalendarClients, getCalendarClient } from '../auth.js'
10
+ import type { CalendarClient, CalendarEvent, CalendarListItem, EventListResult } from '../calendar-client.js'
11
+ import * as cache from '../gmail-cache.js'
12
+ import * as out from '../output.js'
13
+ import { resolveTimeRange, parseTimeExpression, parseDuration, isDateOnly } from '../calendar-time.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Register commands
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function registerCalendarCommands(cli: Goke) {
20
+ // =========================================================================
21
+ // cal list
22
+ // =========================================================================
23
+
24
+ cli
25
+ .command('cal list', 'List calendars')
26
+ .option('--no-cache', 'Skip cache')
27
+ .action(async (options) => {
28
+ const clients = await getCalendarClients(options.account)
29
+
30
+ const settled = await Promise.allSettled(
31
+ clients.map(async ({ email, client }) => {
32
+ if (!options.noCache) {
33
+ const cached = await cache.getCachedCalendarList<CalendarListItem[]>(email)
34
+ if (cached) return { email, calendars: cached }
35
+ }
36
+
37
+ const calendars = await client.listCalendars()
38
+
39
+ if (!options.noCache) {
40
+ await cache.cacheCalendarList(email, calendars)
41
+ }
42
+
43
+ return { email, calendars }
44
+ }),
45
+ )
46
+
47
+ const allResults = settled
48
+ .filter((r): r is PromiseFulfilledResult<{ email: string; calendars: CalendarListItem[] }> => {
49
+ if (r.status === 'rejected') {
50
+ const msg = String(r.reason)
51
+ if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
52
+ out.error('CalDAV authentication failed. Try: zele login')
53
+ } else {
54
+ out.error(`Failed to fetch calendars: ${msg}`)
55
+ }
56
+ return false
57
+ }
58
+ return true
59
+ })
60
+ .map((r) => r.value)
61
+
62
+ const showAccount = clients.length > 1
63
+ const merged = allResults.flatMap(({ email, calendars }) =>
64
+ calendars
65
+ .sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0))
66
+ .map((cal) => ({
67
+ ...(showAccount ? { account: email } : {}),
68
+ id: cal.id,
69
+ name: cal.summary,
70
+ role: cal.role,
71
+ ...(cal.primary ? { primary: true } : {}),
72
+ })),
73
+ )
74
+
75
+ if (merged.length === 0) {
76
+ out.hint('No calendars found')
77
+ return
78
+ }
79
+
80
+ out.printList(merged)
81
+ out.hint(`${merged.length} calendars`)
82
+ })
83
+
84
+ // =========================================================================
85
+ // cal events
86
+ // =========================================================================
87
+
88
+ cli
89
+ .command('cal events', 'List calendar events')
90
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
91
+ .option('--from <from>', 'Start time (ISO date, "today", "tomorrow", weekday name)')
92
+ .option('--to <to>', 'End time (same formats, or +1h/+30m/+2d relative to --from)')
93
+ .option('--today', 'Show today only')
94
+ .option('--tomorrow', 'Show tomorrow only')
95
+ .option('--week', 'Show this week')
96
+ .option('--days <days>', z.number().describe('Show next N days'))
97
+ .option('--all', 'Fetch from all calendars')
98
+ .option('--query <query>', 'Free text search')
99
+ .option('--max [max]', 'Max results (default: 20)')
100
+ .option('--page <page>', 'Pagination token')
101
+ .option('--no-cache', 'Skip cache')
102
+ .action(async (options) => {
103
+ const max = options.max ? Number(options.max) : 20
104
+ const calendarId = options.calendar ?? 'primary'
105
+
106
+ if (options.all && options.calendar) {
107
+ out.error('--all and --calendar cannot be used together')
108
+ process.exit(1)
109
+ }
110
+
111
+ const clients = await getCalendarClients(options.account)
112
+
113
+ if (options.page && clients.length > 1) {
114
+ out.error('--page cannot be used with multiple accounts (page tokens are per-account)')
115
+ process.exit(1)
116
+ }
117
+
118
+ const settled = await Promise.allSettled(
119
+ clients.map(async ({ email, client }) => {
120
+ const tz = await client.getTimezone(calendarId)
121
+ const { timeMin, timeMax } = resolveTimeRange({
122
+ from: options.from,
123
+ to: options.to,
124
+ today: options.today,
125
+ tomorrow: options.tomorrow,
126
+ week: options.week,
127
+ days: options.days,
128
+ }, tz)
129
+
130
+ const cacheParams = {
131
+ calendarId: options.all ? '__all__' : calendarId,
132
+ timeMin,
133
+ timeMax,
134
+ query: options.query,
135
+ maxResults: max,
136
+ pageToken: options.page,
137
+ }
138
+
139
+ if (!options.noCache) {
140
+ const cached = await cache.getCachedCalendarEvents<EventListResult>(email, cacheParams)
141
+ if (cached) return { email, result: cached, tz }
142
+ }
143
+
144
+ let result: EventListResult
145
+
146
+ if (options.all) {
147
+ // Fetch from all calendars
148
+ const calendars = await client.listCalendars()
149
+ const allEvents: CalendarEvent[] = []
150
+
151
+ const perCalResults = await Promise.allSettled(
152
+ calendars.map(async (cal) => {
153
+ const r = await client.listEvents({
154
+ calendarId: cal.id,
155
+ timeMin,
156
+ timeMax,
157
+ query: options.query,
158
+ maxResults: max,
159
+ })
160
+ return r.events.map((e) => ({ ...e, calendarId: cal.id }))
161
+ }),
162
+ )
163
+
164
+ for (const r of perCalResults) {
165
+ if (r.status === 'fulfilled') {
166
+ allEvents.push(...r.value)
167
+ } else {
168
+ out.error(`Calendar fetch failed: ${r.reason}`)
169
+ }
170
+ }
171
+
172
+ result = {
173
+ events: allEvents,
174
+ nextPageToken: null,
175
+ timezone: tz,
176
+ }
177
+ } else {
178
+ result = await client.listEvents({
179
+ calendarId,
180
+ timeMin,
181
+ timeMax,
182
+ query: options.query,
183
+ maxResults: max,
184
+ pageToken: options.page,
185
+ })
186
+ }
187
+
188
+ if (!options.noCache) {
189
+ await cache.cacheCalendarEvents(email, cacheParams, result)
190
+ }
191
+
192
+ return { email, result, tz }
193
+ }),
194
+ )
195
+
196
+ const allResults = settled
197
+ .filter((r): r is PromiseFulfilledResult<{ email: string; result: EventListResult; tz: string }> => {
198
+ if (r.status === 'rejected') {
199
+ const msg = String(r.reason)
200
+ if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
201
+ out.error('CalDAV authentication failed. Try: zele login')
202
+ } else {
203
+ out.error(`Failed to fetch events: ${msg}`)
204
+ }
205
+ return false
206
+ }
207
+ return true
208
+ })
209
+ .map((r) => r.value)
210
+
211
+ const showAccount = clients.length > 1
212
+
213
+ // Merge events from all accounts, sort by start time
214
+ const merged = allResults
215
+ .flatMap(({ email, result }) =>
216
+ result.events.map((e) => ({ ...e, account: email })),
217
+ )
218
+ .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
219
+ .slice(0, max)
220
+
221
+ if (merged.length === 0) {
222
+ out.hint('No events found')
223
+ return
224
+ }
225
+
226
+ out.printList(
227
+ merged.map((e) => {
228
+ const time = out.formatEventTime(e.start, e.end, e.allDay)
229
+ return {
230
+ ...(showAccount ? { account: e.account } : {}),
231
+ id: e.id,
232
+ summary: e.summary,
233
+ start: time.start,
234
+ end: time.end,
235
+ ...(e.location ? { location: e.location } : {}),
236
+ ...(e.calendarId && e.calendarId !== calendarId ? { calendar: e.calendarId } : {}),
237
+ }
238
+ }),
239
+ { nextPage: allResults[0]?.result.nextPageToken },
240
+ )
241
+
242
+ out.hint(`${merged.length} events`)
243
+ })
244
+
245
+ // =========================================================================
246
+ // cal get
247
+ // =========================================================================
248
+
249
+ cli
250
+ .command('cal get <eventId>', 'Get event details')
251
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
252
+ .action(async (eventId, options) => {
253
+ const calendarId = options.calendar ?? 'primary'
254
+ const { client } = await getCalendarClient(options.account)
255
+
256
+ const event = await client.getEvent({ calendarId, eventId })
257
+ const time = out.formatEventTime(event.start, event.end, event.allDay)
258
+
259
+ const doc: Record<string, unknown> = {
260
+ id: event.id,
261
+ summary: event.summary,
262
+ start: time.start,
263
+ end: time.end,
264
+ }
265
+
266
+ if (event.allDay) doc.all_day = true
267
+ if (event.location) doc.location = event.location
268
+ if (event.description) doc.description = event.description
269
+ if (event.meetLink) doc.meet = event.meetLink
270
+ if (event.attendees.length > 0) {
271
+ doc.attendees = event.attendees.map((a) => ({
272
+ email: a.email,
273
+ ...(a.name ? { name: a.name } : {}),
274
+ status: a.status,
275
+ ...(a.organizer ? { organizer: true } : {}),
276
+ }))
277
+ }
278
+ if (event.recurrence.length > 0) doc.recurrence = event.recurrence
279
+ if (event.visibility !== 'default') doc.visibility = event.visibility
280
+ if (event.transparency === 'transparent') doc.show_as = 'free'
281
+ if (event.colorId) doc.color = event.colorId
282
+ if (event.htmlLink) doc.link = event.htmlLink
283
+
284
+ out.printYaml(doc)
285
+ })
286
+
287
+ // =========================================================================
288
+ // cal create
289
+ // =========================================================================
290
+
291
+ cli
292
+ .command('cal create', 'Create a calendar event')
293
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
294
+ .option('--summary <summary>', z.string().describe('Event title'))
295
+ .option('--from <from>', z.string().describe('Start time'))
296
+ .option('--to <to>', z.string().describe('End time (or +1h, +30m, +2d relative to --from)'))
297
+ .option('--description <description>', 'Event description')
298
+ .option('--location <location>', 'Event location')
299
+ .option('--attendees <attendees>', 'Comma-separated attendee emails')
300
+ .option('--meet', 'Create Google Meet link')
301
+ .option('--all-day', 'All-day event')
302
+ .option('--recurrence <recurrence>', 'Recurrence rule (e.g. RRULE:FREQ=WEEKLY;BYDAY=MO)')
303
+ .option('--reminder <reminder>', 'Reminder as method:duration (e.g. popup:15m)')
304
+ .option('--color <color>', 'Event color ID (1-11)')
305
+ .option('--visibility <visibility>', 'Event visibility (default, public, private)')
306
+ .action(async (options) => {
307
+ if (!options.summary) {
308
+ out.error('--summary is required')
309
+ process.exit(1)
310
+ }
311
+ if (!options.from) {
312
+ out.error('--from is required')
313
+ process.exit(1)
314
+ }
315
+ if (!options.to) {
316
+ out.error('--to is required')
317
+ process.exit(1)
318
+ }
319
+
320
+ const calendarId = options.calendar ?? 'primary'
321
+ const { email, client } = await getCalendarClient(options.account)
322
+ const tz = await client.getTimezone(calendarId)
323
+
324
+ const allDay = options.allDay || (isDateOnly(options.from) && isDateOnly(options.to))
325
+ const start = allDay ? options.from : parseTimeExpression(options.from, tz)
326
+
327
+ let end: string
328
+ if (allDay) {
329
+ // Calendar API uses exclusive end date: add 1 day so user can pass same date for single-day
330
+ const endDate = new Date(options.to + 'T00:00:00')
331
+ endDate.setDate(endDate.getDate() + 1)
332
+ end = endDate.toISOString().split('T')[0]!
333
+ } else {
334
+ // Support +duration syntax (e.g. +1h, +30m)
335
+ const durationMs = parseDuration(options.to)
336
+ if (durationMs !== null) {
337
+ end = new Date(new Date(start).getTime() + durationMs).toISOString()
338
+ } else {
339
+ end = parseTimeExpression(options.to, tz)
340
+ }
341
+ }
342
+
343
+ const attendees = options.attendees
344
+ ? options.attendees.split(',').map((e: string) => e.trim()).filter(Boolean)
345
+ : undefined
346
+
347
+ const reminders = options.reminder ? [parseReminder(options.reminder)] : undefined
348
+
349
+ const event = await client.createEvent({
350
+ calendarId,
351
+ summary: options.summary,
352
+ start,
353
+ end,
354
+ allDay,
355
+ description: options.description,
356
+ location: options.location,
357
+ attendees,
358
+ withMeet: options.meet ?? false,
359
+ recurrence: options.recurrence ? [options.recurrence] : undefined,
360
+ reminders,
361
+ colorId: options.color,
362
+ visibility: options.visibility,
363
+ })
364
+
365
+ await cache.invalidateCalendarEvents(email)
366
+
367
+ printEventDetail(event)
368
+ out.success('Event created')
369
+ })
370
+
371
+ // =========================================================================
372
+ // cal update
373
+ // =========================================================================
374
+
375
+ cli
376
+ .command('cal update <eventId>', 'Update a calendar event')
377
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
378
+ .option('--summary <summary>', 'Event title')
379
+ .option('--from <from>', 'Start time')
380
+ .option('--to <to>', 'End time')
381
+ .option('--description <description>', 'Event description')
382
+ .option('--location <location>', 'Event location')
383
+ .option('--add-attendees <addAttendees>', 'Comma-separated emails to add')
384
+ .option('--remove-attendees <removeAttendees>', 'Comma-separated emails to remove')
385
+ .option('--meet', 'Add Google Meet link')
386
+ .option('--color <color>', 'Event color ID (1-11, empty to clear)')
387
+ .option('--visibility <visibility>', 'Event visibility')
388
+ .action(async (eventId, options) => {
389
+ const calendarId = options.calendar ?? 'primary'
390
+ const { email, client } = await getCalendarClient(options.account)
391
+
392
+ const addAttendees = options.addAttendees
393
+ ? options.addAttendees.split(',').map((e: string) => e.trim()).filter(Boolean)
394
+ : undefined
395
+
396
+ const removeAttendees = options.removeAttendees
397
+ ? options.removeAttendees.split(',').map((e: string) => e.trim()).filter(Boolean)
398
+ : undefined
399
+
400
+ let start: string | undefined
401
+ let end: string | undefined
402
+ let allDay: boolean | undefined
403
+
404
+ if (options.from || options.to) {
405
+ // Detect all-day: both from and to are date-only
406
+ allDay = options.from && options.to && isDateOnly(options.from) && isDateOnly(options.to)
407
+ ? true
408
+ : undefined
409
+
410
+ if (allDay) {
411
+ start = options.from
412
+ // Add 1 day for exclusive end date
413
+ if (options.to) {
414
+ const endDate = new Date(options.to + 'T00:00:00')
415
+ endDate.setDate(endDate.getDate() + 1)
416
+ end = endDate.toISOString().split('T')[0]!
417
+ }
418
+ } else {
419
+ const tz = await client.getTimezone(calendarId)
420
+ if (options.from) start = parseTimeExpression(options.from, tz)
421
+ if (options.to) {
422
+ const durationMs = parseDuration(options.to)
423
+ if (durationMs !== null && start) {
424
+ end = new Date(new Date(start).getTime() + durationMs).toISOString()
425
+ } else {
426
+ end = parseTimeExpression(options.to, tz)
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ const event = await client.updateEvent({
433
+ calendarId,
434
+ eventId,
435
+ summary: options.summary,
436
+ start,
437
+ end,
438
+ allDay,
439
+ description: options.description,
440
+ location: options.location,
441
+ addAttendees,
442
+ removeAttendees,
443
+ withMeet: options.meet,
444
+ colorId: options.color,
445
+ visibility: options.visibility,
446
+ })
447
+
448
+ await cache.invalidateCalendarEvents(email)
449
+
450
+ printEventDetail(event)
451
+ out.success('Event updated')
452
+ })
453
+
454
+ // =========================================================================
455
+ // cal delete
456
+ // =========================================================================
457
+
458
+ cli
459
+ .command('cal delete <eventId>', 'Delete a calendar event')
460
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
461
+ .option('--force', 'Skip confirmation')
462
+ .action(async (eventId, options) => {
463
+ const calendarId = options.calendar ?? 'primary'
464
+ const { email, client } = await getCalendarClient(options.account)
465
+
466
+ if (!options.force && process.stdin.isTTY) {
467
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
468
+ const answer = await new Promise<string>((resolve) => {
469
+ rl.question(`Delete event ${eventId}? [y/N] `, resolve)
470
+ })
471
+ rl.close()
472
+
473
+ if (answer.toLowerCase() !== 'y') {
474
+ out.hint('Cancelled')
475
+ return
476
+ }
477
+ }
478
+
479
+ await client.deleteEvent({ calendarId, eventId })
480
+ await cache.invalidateCalendarEvents(email)
481
+
482
+ out.printYaml({ deleted: true, id: eventId })
483
+ out.success('Event deleted')
484
+ })
485
+
486
+ // =========================================================================
487
+ // cal respond
488
+ // =========================================================================
489
+
490
+ cli
491
+ .command('cal respond <eventId>', 'Respond to event invitation')
492
+ .option('--calendar <calendar>', 'Calendar ID (default: primary)')
493
+ .option('--status <status>', z.string().describe('Response: accepted, declined, tentative'))
494
+ .option('--comment <comment>', 'Optional comment')
495
+ .action(async (eventId, options) => {
496
+ if (!options.status) {
497
+ out.error('--status is required (accepted, declined, tentative)')
498
+ process.exit(1)
499
+ }
500
+
501
+ const validStatuses = ['accepted', 'declined', 'tentative']
502
+ if (!validStatuses.includes(options.status)) {
503
+ out.error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`)
504
+ process.exit(1)
505
+ }
506
+
507
+ const calendarId = options.calendar ?? 'primary'
508
+ const { email, client } = await getCalendarClient(options.account)
509
+
510
+ const event = await client.respondToEvent({
511
+ calendarId,
512
+ eventId,
513
+ status: options.status as 'accepted' | 'declined' | 'tentative',
514
+ comment: options.comment,
515
+ })
516
+
517
+ await cache.invalidateCalendarEvents(email)
518
+
519
+ out.printYaml({
520
+ id: event.id,
521
+ summary: event.summary,
522
+ status: options.status,
523
+ ...(options.comment ? { comment: options.comment } : {}),
524
+ })
525
+ out.success(`Responded: ${options.status}`)
526
+ })
527
+
528
+ // =========================================================================
529
+ // cal freebusy
530
+ // =========================================================================
531
+
532
+ cli
533
+ .command('cal freebusy [...calendarIds]', 'Get free/busy information')
534
+ .option('--from <from>', z.string().describe('Start time'))
535
+ .option('--to <to>', z.string().describe('End time'))
536
+ .action(async (calendarIds, options) => {
537
+ if (!calendarIds || calendarIds.length === 0) {
538
+ out.error('At least one calendar ID (email) is required')
539
+ process.exit(1)
540
+ }
541
+ if (!options.from || !options.to) {
542
+ out.error('--from and --to are required')
543
+ process.exit(1)
544
+ }
545
+
546
+ const { client } = await getCalendarClient(options.account)
547
+ const tz = await client.getTimezone()
548
+
549
+ const timeMin = parseTimeExpression(options.from, tz)
550
+ const timeMax = parseTimeExpression(options.to, tz)
551
+
552
+ const results = await client.getFreeBusy({
553
+ calendarIds,
554
+ timeMin,
555
+ timeMax,
556
+ })
557
+
558
+ const items = results.map((r) => ({
559
+ calendar: r.calendar,
560
+ busy: r.busy.length > 0
561
+ ? r.busy.map((b) => {
562
+ const st = new Date(b.start).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
563
+ const en = new Date(b.end).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
564
+ return `${st} - ${en}`
565
+ })
566
+ : ['(free)'],
567
+ }))
568
+
569
+ out.printList(items)
570
+ })
571
+
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Helpers
576
+ // ---------------------------------------------------------------------------
577
+
578
+ function printEventDetail(event: CalendarEvent) {
579
+ const time = out.formatEventTime(event.start, event.end, event.allDay)
580
+
581
+ const doc: Record<string, unknown> = {
582
+ id: event.id,
583
+ summary: event.summary,
584
+ start: time.start,
585
+ end: time.end,
586
+ }
587
+
588
+ if (event.allDay) doc.all_day = true
589
+ if (event.location) doc.location = event.location
590
+ if (event.description) doc.description = event.description
591
+ if (event.meetLink) doc.meet = event.meetLink
592
+ if (event.attendees.length > 0) {
593
+ doc.attendees = event.attendees.map((a) => ({
594
+ email: a.email,
595
+ ...(a.name ? { name: a.name } : {}),
596
+ status: a.status,
597
+ }))
598
+ }
599
+ if (event.recurrence.length > 0) doc.recurrence = event.recurrence
600
+ if (event.htmlLink) doc.link = event.htmlLink
601
+
602
+ out.printYaml(doc)
603
+ }
604
+
605
+ function parseReminder(input: string): { method: string; minutes: number } {
606
+ const [method, duration] = input.split(':')
607
+ if (!method || !duration) {
608
+ throw new Error(`Invalid reminder format: "${input}". Use method:duration (e.g. popup:15m)`)
609
+ }
610
+
611
+ const validMethods = ['popup', 'email']
612
+ if (!validMethods.includes(method)) {
613
+ throw new Error(`Invalid reminder method: "${method}". Must be popup or email`)
614
+ }
615
+
616
+ const match = duration.match(/^(\d+)(m|h|d|w)?$/)
617
+ if (!match) {
618
+ throw new Error(`Invalid reminder duration: "${duration}". Use 30, 30m, 1h, 3d, or 1w`)
619
+ }
620
+
621
+ const value = Number(match[1])
622
+ const unit = match[2] ?? 'm'
623
+
624
+ let minutes: number
625
+ switch (unit) {
626
+ case 'm': minutes = value; break
627
+ case 'h': minutes = value * 60; break
628
+ case 'd': minutes = value * 1440; break
629
+ case 'w': minutes = value * 10080; break
630
+ default: minutes = value
631
+ }
632
+
633
+ return { method, minutes }
634
+ }