diff --git a/packages/cli/src/commands/unified-cal/command.ts b/packages/cli/src/commands/unified-cal/command.ts new file mode 100644 index 0000000..d889d51 --- /dev/null +++ b/packages/cli/src/commands/unified-cal/command.ts @@ -0,0 +1,310 @@ +import type { Command } from "commander"; +import { apiRequest } from "../../shared/api"; +import { getApiUrl, getAuthToken } from "../../shared/config"; +import { withErrorHandling } from "../../shared/errors"; +import { + renderBusyTimes, + renderConnections, + renderEvent, + renderEventCreated, + renderEventDeleted, + renderEventList, + renderEventUpdated, +} from "./output"; +import type { + BusyTimesResponse, + CalendarEvent, + ConnectionsResponse, + EventListResponse, + EventResponse, +} from "./types"; + +function registerConnectionsCommand(unifiedCalCmd: Command): void { + unifiedCalCmd + .command("connections") + .description("List calendar connections for the authenticated user") + .option("--json", "Output as JSON") + .action(async (options: { json?: boolean }) => { + await withErrorHandling(async () => { + const response = await apiRequest( + "/v2/calendars/connections" + ); + renderConnections(response.data?.connections, options); + }); + }); +} + +function registerEventsCommands(unifiedCalCmd: Command): void { + const eventsCmd = unifiedCalCmd.command("events").description("Manage calendar events via unified API"); + + eventsCmd + .command("list") + .description("List events for a calendar connection") + .requiredOption("--connection-id ", "Calendar connection ID (from 'unified-cal connections')") + .requiredOption("--from ", "Start date (YYYY-MM-DD or ISO 8601)") + .requiredOption("--to ", "End date (YYYY-MM-DD or ISO 8601)") + .option("--calendar-id ", "Calendar ID within the connection (default: primary)") + .option("--timezone ", "Timezone (e.g. America/New_York)") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + from: string; + to: string; + calendarId?: string; + timezone?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const query: Record = { + from: options.from, + to: options.to, + calendarId: options.calendarId, + timeZone: options.timezone, + }; + + const response = await apiRequest( + `/v2/calendars/connections/${options.connectionId}/events`, + { query } + ); + renderEventList(response.data as CalendarEvent[] | undefined, options); + }); + } + ); + + eventsCmd + .command("get") + .description("Get a single event by ID") + .requiredOption("--connection-id ", "Calendar connection ID") + .requiredOption("--event-id ", "Event ID") + .option("--calendar-id ", "Calendar ID within the connection (default: primary)") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + eventId: string; + calendarId?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const query: Record = { + calendarId: options.calendarId, + }; + + const response = await apiRequest( + `/v2/calendars/connections/${options.connectionId}/events/${options.eventId}`, + { query } + ); + renderEvent(response.data as CalendarEvent | undefined, options); + }); + } + ); + + eventsCmd + .command("create") + .description("Create a new event on a calendar connection") + .requiredOption("--connection-id ", "Calendar connection ID") + .requiredOption("--title ", "Event title/summary") + .requiredOption("--start <datetime>", "Start time (ISO 8601, e.g. 2026-03-20T14:00:00)") + .requiredOption("--end <datetime>", "End time (ISO 8601, e.g. 2026-03-20T15:00:00)") + .option("--description <text>", "Event description") + .option("--location <location>", "Event location") + .option("--timezone <tz>", "Timezone (e.g. America/New_York)") + .option("--attendees <emails>", "Comma-separated attendee emails") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + title: string; + start: string; + end: string; + description?: string; + location?: string; + timezone?: string; + attendees?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const body: Record<string, unknown> = { + summary: options.title, + start: options.start, + end: options.end, + }; + + if (options.description) { + body.description = options.description; + } + if (options.location) { + body.location = options.location; + } + if (options.timezone) { + body.timeZone = options.timezone; + } + if (options.attendees) { + body.attendees = options.attendees.split(",").map((email) => ({ + email: email.trim(), + })); + } + + const response = await apiRequest<EventResponse["data"]>( + `/v2/calendars/connections/${options.connectionId}/events`, + { method: "POST", body } + ); + renderEventCreated(response.data as CalendarEvent | undefined, options); + }); + } + ); + + eventsCmd + .command("update") + .description("Update an existing event") + .requiredOption("--connection-id <id>", "Calendar connection ID") + .requiredOption("--event-id <id>", "Event ID to update") + .option("--title <title>", "New event title/summary") + .option("--start <datetime>", "New start time (ISO 8601)") + .option("--end <datetime>", "New end time (ISO 8601)") + .option("--description <text>", "New event description") + .option("--location <location>", "New event location") + .option("--timezone <tz>", "Timezone") + .option("--calendar-id <id>", "Calendar ID within the connection (default: primary)") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + eventId: string; + title?: string; + start?: string; + end?: string; + description?: string; + location?: string; + timezone?: string; + calendarId?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const body: Record<string, unknown> = {}; + if (options.title) { + body.summary = options.title; + } + if (options.start) { + body.start = options.start; + } + if (options.end) { + body.end = options.end; + } + if (options.description) { + body.description = options.description; + } + if (options.location) { + body.location = options.location; + } + if (options.timezone) { + body.timeZone = options.timezone; + } + + if (Object.keys(body).length === 0) { + throw new Error( + "No fields to update. Provide at least one of: --title, --start, --end, --description, --location, --timezone" + ); + } + + const query: Record<string, string | undefined> = { + calendarId: options.calendarId, + }; + + const response = await apiRequest<EventResponse["data"]>( + `/v2/calendars/connections/${options.connectionId}/events/${options.eventId}`, + { method: "PATCH", body, query } + ); + renderEventUpdated(response.data as CalendarEvent | undefined, options); + }); + } + ); + + eventsCmd + .command("delete") + .description("Delete/cancel an event") + .requiredOption("--connection-id <id>", "Calendar connection ID") + .requiredOption("--event-id <id>", "Event ID to delete") + .option("--calendar-id <id>", "Calendar ID within the connection (default: primary)") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + eventId: string; + calendarId?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const apiUrl = getApiUrl(); + const token = await getAuthToken(); + + let url = `${apiUrl}/v2/calendars/connections/${options.connectionId}/events/${options.eventId}`; + if (options.calendarId) { + url += `?calendarId=${encodeURIComponent(options.calendarId)}`; + } + + const response = await fetch(url, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `API Error (${response.status}): ${errorBody || response.statusText}` + ); + } + + renderEventDeleted(options); + }); + } + ); +} + +function registerFreeBusyCommand(unifiedCalCmd: Command): void { + unifiedCalCmd + .command("freebusy") + .description("Get free/busy times for a calendar connection") + .requiredOption("--connection-id <id>", "Calendar connection ID") + .requiredOption("--from <date>", "Start date (YYYY-MM-DD or ISO 8601)") + .requiredOption("--to <date>", "End date (YYYY-MM-DD or ISO 8601)") + .option("--timezone <tz>", "Timezone (e.g. America/New_York)") + .option("--json", "Output as JSON") + .action( + async (options: { + connectionId: string; + from: string; + to: string; + timezone?: string; + json?: boolean; + }) => { + await withErrorHandling(async () => { + const query: Record<string, string | undefined> = { + from: options.from, + to: options.to, + timeZone: options.timezone, + }; + + const response = await apiRequest<BusyTimesResponse["data"]>( + `/v2/calendars/connections/${options.connectionId}/freebusy`, + { query } + ); + renderBusyTimes(response.data as BusyTimesResponse["data"] | undefined, options); + }); + } + ); +} + +export function registerUnifiedCalCommand(program: Command): void { + const unifiedCalCmd = program + .command("unified-cal") + .description("Unified calendar API — CRUD operations on connected calendar events"); + + registerConnectionsCommand(unifiedCalCmd); + registerEventsCommands(unifiedCalCmd); + registerFreeBusyCommand(unifiedCalCmd); +} diff --git a/packages/cli/src/commands/unified-cal/index.ts b/packages/cli/src/commands/unified-cal/index.ts new file mode 100644 index 0000000..6ea6183 --- /dev/null +++ b/packages/cli/src/commands/unified-cal/index.ts @@ -0,0 +1 @@ +export { registerUnifiedCalCommand } from "./command"; diff --git a/packages/cli/src/commands/unified-cal/output.ts b/packages/cli/src/commands/unified-cal/output.ts new file mode 100644 index 0000000..d192303 --- /dev/null +++ b/packages/cli/src/commands/unified-cal/output.ts @@ -0,0 +1,174 @@ +import chalk from "chalk"; +import { + formatDateTime, + type OutputOptions, + renderHeader, + renderSuccess, + renderTable, +} from "../../shared/output"; +import type { + BusyTime, + CalendarConnection, + CalendarEvent, +} from "./types"; + +export function renderConnections( + connections: CalendarConnection[] | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(connections, null, 2)); + return; + } + + if (!connections || connections.length === 0) { + console.log("No calendar connections found."); + return; + } + + renderHeader("Calendar Connections"); + renderTable( + ["Connection ID", "Type", "Email"], + connections.map((c) => [c.connectionId, c.type, c.email || chalk.dim("unknown")]) + ); +} + +export function renderEventList( + events: CalendarEvent[] | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(events, null, 2)); + return; + } + + if (!events || events.length === 0) { + console.log("No events found."); + return; + } + + renderHeader(`Events (${events.length})`); + renderTable( + ["Start", "End", "Title", "ID"], + events.map((e) => [ + e.isAllDay ? chalk.cyan("All day") : formatDateTime(e.start), + e.isAllDay ? "" : formatDateTime(e.end), + e.title || chalk.dim("(no title)"), + chalk.dim(e.id), + ]) + ); +} + +export function renderEvent( + event: CalendarEvent | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(event, null, 2)); + return; + } + + if (!event) { + console.log("Event not found."); + return; + } + + renderHeader(event.title || "(no title)"); + console.log(` ID: ${event.id}`); + if (event.isAllDay) { + console.log(` When: All day`); + } else { + console.log(` Start: ${formatDateTime(event.start)}`); + console.log(` End: ${formatDateTime(event.end)}`); + } + if (event.timeZone) { + console.log(` Timezone: ${event.timeZone}`); + } + if (event.status) { + console.log(` Status: ${event.status}`); + } + if (event.location) { + console.log(` Location: ${event.location}`); + } + if (event.description) { + console.log(` Notes: ${event.description}`); + } + if (event.organizer?.email) { + const name = event.organizer.displayName + ? `${event.organizer.displayName} <${event.organizer.email}>` + : event.organizer.email; + console.log(` Organizer: ${name}`); + } + if (event.attendees && event.attendees.length > 0) { + console.log(" Attendees:"); + for (const a of event.attendees) { + const label = a.displayName ? `${a.displayName} <${a.email}>` : (a.email || "unknown"); + const status = a.responseStatus ? chalk.dim(` (${a.responseStatus})`) : ""; + console.log(` - ${label}${status}`); + } + } + if (event.htmlLink) { + console.log(` Link: ${event.htmlLink}`); + } + console.log(); +} + +export function renderEventCreated( + event: CalendarEvent | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(event, null, 2)); + return; + } + + renderSuccess("Event created successfully."); + if (event) { + renderEvent(event); + } +} + +export function renderEventUpdated( + event: CalendarEvent | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(event, null, 2)); + return; + } + + renderSuccess("Event updated successfully."); + if (event) { + renderEvent(event); + } +} + +export function renderEventDeleted({ json }: OutputOptions = {}): void { + if (json) { + console.log(JSON.stringify({ status: "success", message: "Event deleted" }, null, 2)); + return; + } + + renderSuccess("Event deleted successfully."); +} + +export function renderBusyTimes( + busyTimes: BusyTime[] | undefined, + { json }: OutputOptions = {} +): void { + if (json) { + console.log(JSON.stringify(busyTimes, null, 2)); + return; + } + + if (!busyTimes || busyTimes.length === 0) { + console.log("No busy times found."); + return; + } + + renderHeader("Busy Times"); + renderTable( + ["Start", "End", "Source"], + busyTimes.map((bt) => [formatDateTime(bt.start), formatDateTime(bt.end), bt.source || ""]) + ); +} diff --git a/packages/cli/src/commands/unified-cal/types.ts b/packages/cli/src/commands/unified-cal/types.ts new file mode 100644 index 0000000..43a3a48 --- /dev/null +++ b/packages/cli/src/commands/unified-cal/types.ts @@ -0,0 +1,55 @@ +export interface CalendarConnection { + connectionId: string; + type: "google" | "office365" | "apple"; + email: string | null; +} + +export interface ConnectionsResponse { + status: string; + data: { + connections: CalendarConnection[]; + }; +} + +export interface CalendarEvent { + id: string; + title: string; + description?: string; + start: string; + end: string; + timeZone?: string; + isAllDay?: boolean; + status?: string; + location?: string; + organizer?: { + email?: string; + displayName?: string; + }; + attendees?: Array<{ + email?: string; + displayName?: string; + responseStatus?: string; + }>; + htmlLink?: string; +} + +export interface EventResponse { + status: string; + data: CalendarEvent; +} + +export interface EventListResponse { + status: string; + data: CalendarEvent[]; +} + +export interface BusyTime { + start: string; + end: string; + source?: string; +} + +export interface BusyTimesResponse { + status: string; + data: BusyTime[]; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 49f93c3..56d5cda 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -45,6 +45,7 @@ import { registerTeamWorkflowsCommand } from "./commands/team-workflows"; import { registerTeamsCommand } from "./commands/teams"; import { registerTimezonesCommand } from "./commands/timezones"; import { registerVerifiedResourcesCommand } from "./commands/verified-resources"; +import { registerUnifiedCalCommand } from "./commands/unified-cal"; import { registerWebhooksCommand } from "./commands/webhooks"; const program: Command = new Command(); @@ -101,6 +102,7 @@ registerOAuthCommand(program); registerVerifiedResourcesCommand(program); registerTeamVerifiedResourcesCommand(program); registerOrgTeamVerifiedResourcesCommand(program); +registerUnifiedCalCommand(program); program.parseAsync(process.argv).catch((err: Error) => { console.error(`Error: ${err.message}`);