@plosson/agentio 0.4.2 → 0.4.3

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.
@@ -0,0 +1,383 @@
1
+ import { Command } from 'commander';
2
+ import { google } from 'googleapis';
3
+ import { getValidTokens, createGoogleAuth } from '../auth/token-manager';
4
+ import { setCredentials } from '../auth/token-store';
5
+ import { setProfile } from '../config/config-manager';
6
+ import { createProfileCommands } from '../utils/profile-commands';
7
+ import { performOAuthFlow } from '../auth/oauth';
8
+ import { GCalClient } from '../services/gcal/client';
9
+ import { printGCalCalendarList, printGCalEventList, printGCalEvent, printGCalEventCreated, printGCalEventDeleted, printGCalFreeBusy } from '../utils/output';
10
+ import { CliError, handleError } from '../utils/errors';
11
+ import { readStdin } from '../utils/stdin';
12
+
13
+ async function getGCalClient(profileName?: string): Promise<{ client: GCalClient; profile: string }> {
14
+ const { tokens, profile } = await getValidTokens('gcal', profileName);
15
+ const auth = createGoogleAuth(tokens);
16
+ return { client: new GCalClient(auth), profile };
17
+ }
18
+
19
+ function parseTimeRange(options: { from?: string; to?: string; today?: boolean; tomorrow?: boolean; days?: string }): { timeMin?: string; timeMax?: string } {
20
+ const now = new Date();
21
+ let timeMin: string | undefined;
22
+ let timeMax: string | undefined;
23
+
24
+ if (options.today) {
25
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
26
+ const end = new Date(start);
27
+ end.setDate(end.getDate() + 1);
28
+ timeMin = start.toISOString();
29
+ timeMax = end.toISOString();
30
+ } else if (options.tomorrow) {
31
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
32
+ const end = new Date(start);
33
+ end.setDate(end.getDate() + 1);
34
+ timeMin = start.toISOString();
35
+ timeMax = end.toISOString();
36
+ } else if (options.days) {
37
+ const days = parseInt(options.days, 10);
38
+ if (!isNaN(days) && days > 0) {
39
+ timeMin = now.toISOString();
40
+ const end = new Date(now);
41
+ end.setDate(end.getDate() + days);
42
+ timeMax = end.toISOString();
43
+ }
44
+ } else {
45
+ if (options.from) timeMin = options.from;
46
+ if (options.to) timeMax = options.to;
47
+ }
48
+
49
+ return { timeMin, timeMax };
50
+ }
51
+
52
+ export function registerGCalCommands(program: Command): void {
53
+ const gcal = program
54
+ .command('gcal')
55
+ .description('Google Calendar operations');
56
+
57
+ // List calendars
58
+ gcal
59
+ .command('calendars')
60
+ .description('List available calendars')
61
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
62
+ .option('--limit <n>', 'Max results', '100')
63
+ .action(async (options) => {
64
+ try {
65
+ const { client } = await getGCalClient(options.profile);
66
+ const calendars = await client.listCalendars(parseInt(options.limit, 10));
67
+ printGCalCalendarList(calendars);
68
+ } catch (error) {
69
+ handleError(error);
70
+ }
71
+ });
72
+
73
+ // List events
74
+ gcal
75
+ .command('events')
76
+ .alias('list')
77
+ .description('List events from a calendar')
78
+ .argument('[calendar-id]', 'Calendar ID (default: primary)')
79
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
80
+ .option('--limit <n>', 'Max results', '10')
81
+ .option('--from <datetime>', 'Start time (RFC3339 or YYYY-MM-DD)')
82
+ .option('--to <datetime>', 'End time (RFC3339 or YYYY-MM-DD)')
83
+ .option('--today', 'Show today\'s events only')
84
+ .option('--tomorrow', 'Show tomorrow\'s events only')
85
+ .option('--days <n>', 'Show events for next N days')
86
+ .option('--query <q>', 'Free text search query')
87
+ .action(async (calendarId: string | undefined, options) => {
88
+ try {
89
+ const { client } = await getGCalClient(options.profile);
90
+ const { timeMin, timeMax } = parseTimeRange(options);
91
+ const result = await client.listEvents({
92
+ calendarId: calendarId || 'primary',
93
+ maxResults: parseInt(options.limit, 10),
94
+ timeMin,
95
+ timeMax,
96
+ query: options.query,
97
+ });
98
+ printGCalEventList(result.events, result.nextPageToken);
99
+ } catch (error) {
100
+ handleError(error);
101
+ }
102
+ });
103
+
104
+ // Get event
105
+ gcal
106
+ .command('get')
107
+ .alias('event')
108
+ .description('Get a single event')
109
+ .argument('<calendar-id>', 'Calendar ID')
110
+ .argument('<event-id>', 'Event ID')
111
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
112
+ .action(async (calendarId: string, eventId: string, options) => {
113
+ try {
114
+ const { client } = await getGCalClient(options.profile);
115
+ const event = await client.getEvent(calendarId, eventId);
116
+ printGCalEvent(event);
117
+ } catch (error) {
118
+ handleError(error);
119
+ }
120
+ });
121
+
122
+ // Create event
123
+ gcal
124
+ .command('create')
125
+ .description('Create a new event')
126
+ .argument('[calendar-id]', 'Calendar ID (default: primary)')
127
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
128
+ .requiredOption('--summary <title>', 'Event title/summary')
129
+ .requiredOption('--from <datetime>', 'Start time (RFC3339 or YYYY-MM-DD for all-day)')
130
+ .requiredOption('--to <datetime>', 'End time (RFC3339 or YYYY-MM-DD for all-day)')
131
+ .option('--description <text>', 'Event description (or pipe via stdin)')
132
+ .option('--location <place>', 'Event location')
133
+ .option('--all-day', 'Create as all-day event')
134
+ .option('--attendee <email>', 'Attendee email (repeatable)', (val, acc: string[]) => [...acc, val], [])
135
+ .option('--rrule <rule>', 'Recurrence rule (repeatable, e.g., RRULE:FREQ=WEEKLY;BYDAY=MO)', (val, acc: string[]) => [...acc, val], [])
136
+ .option('--reminder <spec>', 'Reminder as method:minutes (repeatable, e.g., popup:30, email:1440)', (val, acc: string[]) => [...acc, val], [])
137
+ .option('--color <id>', 'Color ID (1-11)')
138
+ .option('--visibility <v>', 'Visibility: default, public, private, confidential')
139
+ .option('--show-as <v>', 'Show as: busy, free')
140
+ .option('--send-updates <mode>', 'Send notifications: all, externalOnly, none', 'all')
141
+ .option('--with-meet', 'Create Google Meet link')
142
+ .action(async (calendarId: string | undefined, options) => {
143
+ try {
144
+ let description = options.description;
145
+ if (!description) {
146
+ const stdin = await readStdin();
147
+ if (stdin) description = stdin;
148
+ }
149
+
150
+ const reminders = options.reminder.map((r: string) => {
151
+ const [method, minutes] = r.split(':');
152
+ if (!method || !minutes || !['email', 'popup'].includes(method)) {
153
+ throw new CliError('INVALID_PARAMS', `Invalid reminder format: ${r}`, 'Use format: method:minutes (e.g., popup:30)');
154
+ }
155
+ return { method: method as 'email' | 'popup', minutes: parseInt(minutes, 10) };
156
+ });
157
+
158
+ const { client } = await getGCalClient(options.profile);
159
+ const event = await client.createEvent({
160
+ calendarId: calendarId || 'primary',
161
+ summary: options.summary,
162
+ description,
163
+ location: options.location,
164
+ start: options.from,
165
+ end: options.to,
166
+ allDay: options.allDay,
167
+ attendees: options.attendee.length ? options.attendee : undefined,
168
+ recurrence: options.rrule.length ? options.rrule : undefined,
169
+ reminders: reminders.length ? reminders : undefined,
170
+ colorId: options.color,
171
+ visibility: options.visibility,
172
+ transparency: options.showAs === 'free' ? 'transparent' : options.showAs === 'busy' ? 'opaque' : undefined,
173
+ sendUpdates: options.sendUpdates,
174
+ withMeet: options.withMeet,
175
+ });
176
+ printGCalEventCreated(event);
177
+ } catch (error) {
178
+ handleError(error);
179
+ }
180
+ });
181
+
182
+ // Update event
183
+ gcal
184
+ .command('update')
185
+ .description('Update an existing event')
186
+ .argument('<calendar-id>', 'Calendar ID')
187
+ .argument('<event-id>', 'Event ID')
188
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
189
+ .option('--summary <title>', 'New event title/summary')
190
+ .option('--from <datetime>', 'New start time')
191
+ .option('--to <datetime>', 'New end time')
192
+ .option('--description <text>', 'New description (or pipe via stdin)')
193
+ .option('--location <place>', 'New location')
194
+ .option('--all-day', 'Convert to all-day event')
195
+ .option('--attendee <email>', 'Replace attendees (repeatable)', (val, acc: string[]) => [...acc, val], [])
196
+ .option('--add-attendee <email>', 'Add attendee (repeatable)', (val, acc: string[]) => [...acc, val], [])
197
+ .option('--color <id>', 'New color ID (1-11)')
198
+ .option('--visibility <v>', 'Visibility: default, public, private, confidential')
199
+ .option('--show-as <v>', 'Show as: busy, free')
200
+ .option('--send-updates <mode>', 'Send notifications: all, externalOnly, none', 'all')
201
+ .action(async (calendarId: string, eventId: string, options) => {
202
+ try {
203
+ let description = options.description;
204
+ if (description === undefined && !process.stdin.isTTY) {
205
+ const stdin = await readStdin();
206
+ if (stdin) description = stdin;
207
+ }
208
+
209
+ if (options.attendee.length && options.addAttendee.length) {
210
+ throw new CliError('INVALID_PARAMS', 'Cannot use both --attendee and --add-attendee');
211
+ }
212
+
213
+ const { client } = await getGCalClient(options.profile);
214
+ const event = await client.updateEvent({
215
+ calendarId,
216
+ eventId,
217
+ summary: options.summary,
218
+ description,
219
+ location: options.location,
220
+ start: options.from,
221
+ end: options.to,
222
+ allDay: options.allDay,
223
+ attendees: options.attendee.length ? options.attendee : undefined,
224
+ addAttendees: options.addAttendee.length ? options.addAttendee : undefined,
225
+ colorId: options.color,
226
+ visibility: options.visibility,
227
+ transparency: options.showAs === 'free' ? 'transparent' : options.showAs === 'busy' ? 'opaque' : undefined,
228
+ sendUpdates: options.sendUpdates,
229
+ });
230
+ printGCalEvent(event);
231
+ } catch (error) {
232
+ handleError(error);
233
+ }
234
+ });
235
+
236
+ // Delete event
237
+ gcal
238
+ .command('delete')
239
+ .description('Delete an event')
240
+ .argument('<calendar-id>', 'Calendar ID')
241
+ .argument('<event-id>', 'Event ID')
242
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
243
+ .option('--send-updates <mode>', 'Send notifications: all, externalOnly, none', 'all')
244
+ .action(async (calendarId: string, eventId: string, options) => {
245
+ try {
246
+ const { client } = await getGCalClient(options.profile);
247
+ await client.deleteEvent(calendarId, eventId, options.sendUpdates);
248
+ printGCalEventDeleted(calendarId, eventId);
249
+ } catch (error) {
250
+ handleError(error);
251
+ }
252
+ });
253
+
254
+ // Search events
255
+ gcal
256
+ .command('search')
257
+ .description('Search events')
258
+ .argument('<query>', 'Search query')
259
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
260
+ .option('--calendar <id>', 'Calendar ID', 'primary')
261
+ .option('--limit <n>', 'Max results', '25')
262
+ .option('--from <datetime>', 'Start time (RFC3339)')
263
+ .option('--to <datetime>', 'End time (RFC3339)')
264
+ .action(async (query: string, options) => {
265
+ try {
266
+ const { client } = await getGCalClient(options.profile);
267
+
268
+ // Default search range: 30 days past to 90 days future
269
+ const now = new Date();
270
+ const defaultFrom = new Date(now);
271
+ defaultFrom.setDate(defaultFrom.getDate() - 30);
272
+ const defaultTo = new Date(now);
273
+ defaultTo.setDate(defaultTo.getDate() + 90);
274
+
275
+ const result = await client.search(query, {
276
+ calendarId: options.calendar,
277
+ maxResults: parseInt(options.limit, 10),
278
+ timeMin: options.from || defaultFrom.toISOString(),
279
+ timeMax: options.to || defaultTo.toISOString(),
280
+ });
281
+ printGCalEventList(result.events, result.nextPageToken);
282
+ } catch (error) {
283
+ handleError(error);
284
+ }
285
+ });
286
+
287
+ // Respond to event
288
+ gcal
289
+ .command('respond')
290
+ .description('Respond to an event invitation')
291
+ .argument('<calendar-id>', 'Calendar ID')
292
+ .argument('<event-id>', 'Event ID')
293
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
294
+ .requiredOption('--status <status>', 'Response: accepted, declined, tentative')
295
+ .option('--comment <text>', 'Optional comment')
296
+ .action(async (calendarId: string, eventId: string, options) => {
297
+ try {
298
+ const status = options.status.toLowerCase();
299
+ if (!['accepted', 'declined', 'tentative'].includes(status)) {
300
+ throw new CliError('INVALID_PARAMS', `Invalid status: ${options.status}`, 'Use: accepted, declined, or tentative');
301
+ }
302
+
303
+ const { client } = await getGCalClient(options.profile);
304
+ const event = await client.respond({
305
+ calendarId,
306
+ eventId,
307
+ status: status as 'accepted' | 'declined' | 'tentative',
308
+ comment: options.comment,
309
+ });
310
+ console.log(`Response updated: ${status}`);
311
+ console.log(`Event: ${event.summary || '(no title)'}`);
312
+ if (event.htmlLink) console.log(`Link: ${event.htmlLink}`);
313
+ } catch (error) {
314
+ handleError(error);
315
+ }
316
+ });
317
+
318
+ // Free/busy query
319
+ gcal
320
+ .command('freebusy')
321
+ .description('Get free/busy information')
322
+ .argument('<calendar-ids>', 'Comma-separated calendar IDs')
323
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
324
+ .requiredOption('--from <datetime>', 'Start time (RFC3339)')
325
+ .requiredOption('--to <datetime>', 'End time (RFC3339)')
326
+ .action(async (calendarIds: string, options) => {
327
+ try {
328
+ const ids = calendarIds.split(',').map((id) => id.trim()).filter(Boolean);
329
+ if (ids.length === 0) {
330
+ throw new CliError('INVALID_PARAMS', 'At least one calendar ID is required');
331
+ }
332
+
333
+ const { client } = await getGCalClient(options.profile);
334
+ const result = await client.freeBusy({
335
+ calendarIds: ids,
336
+ timeMin: options.from,
337
+ timeMax: options.to,
338
+ });
339
+ printGCalFreeBusy(result);
340
+ } catch (error) {
341
+ handleError(error);
342
+ }
343
+ });
344
+
345
+ // Profile management
346
+ const profile = createProfileCommands<{ email?: string }>(gcal, {
347
+ service: 'gcal',
348
+ displayName: 'Google Calendar',
349
+ getExtraInfo: (credentials) => credentials?.email ? ` - ${credentials.email}` : '',
350
+ });
351
+
352
+ profile
353
+ .command('add')
354
+ .description('Add a new Google Calendar profile')
355
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
356
+ .action(async (options) => {
357
+ try {
358
+ console.error('Starting OAuth flow for Google Calendar...\n');
359
+
360
+ const tokens = await performOAuthFlow('gcal');
361
+
362
+ // Fetch the user's email from calendar settings
363
+ const auth = createGoogleAuth(tokens);
364
+ const calendar = google.calendar({ version: 'v3', auth });
365
+ const settings = await calendar.calendarList.get({ calendarId: 'primary' });
366
+ const email = settings.data.id;
367
+
368
+ if (!email) {
369
+ throw new CliError('AUTH_FAILED', 'Could not fetch email from Calendar', 'Try again or specify --profile manually');
370
+ }
371
+
372
+ const profileName = options.profile || email;
373
+
374
+ await setProfile('gcal', profileName);
375
+ await setCredentials('gcal', profileName, { ...tokens, email });
376
+
377
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
378
+ console.log(` Email: ${email}`);
379
+ } catch (error) {
380
+ handleError(error);
381
+ }
382
+ });
383
+ }