feat(zee): integrate Google Calendar with canvas sidecar

- Add Google Calendar client using OAuth2 tokens from ~/.zee/credentials/google/
- Update zee:calendar tool with real Google Calendar API integration
- Support today, week, month, and custom date range queries
- Auto-display calendar events in canvas sidecar
- Handle token refresh automatically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-08 23:40:20 +01:00
parent ea937786d1
commit 550b02445b
3 changed files with 429 additions and 39 deletions
+275
View File
@@ -0,0 +1,275 @@
/**
* Google Calendar Client for Zee
*
* Uses OAuth2 tokens from ~/.zee/credentials/google/ to fetch calendar events.
*/
import { readFile, writeFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
const CREDENTIALS_DIR = join(homedir(), ".zee", "credentials", "google");
const OAUTH_CLIENT_PATH = join(CREDENTIALS_DIR, "oauth-client.json");
const TOKENS_PATH = join(CREDENTIALS_DIR, "tokens.json");
const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3";
interface OAuthClient {
installed: {
client_id: string;
client_secret: string;
token_uri: string;
};
}
interface Tokens {
access_token: string;
refresh_token: string;
expiry_date: number;
token_type: string;
scope: string;
}
interface CalendarEvent {
id: string;
summary: string;
description?: string;
start: { dateTime?: string; date?: string; timeZone?: string };
end: { dateTime?: string; date?: string; timeZone?: string };
location?: string;
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
status: string;
htmlLink?: string;
}
interface CalendarList {
items: Array<{
id: string;
summary: string;
primary?: boolean;
backgroundColor?: string;
}>;
}
interface EventsResponse {
items: CalendarEvent[];
nextPageToken?: string;
summary?: string;
}
export interface FormattedEvent {
id: string;
title: string;
date: string;
startTime?: string;
endTime?: string;
location?: string;
description?: string;
isAllDay: boolean;
}
async function loadCredentials(): Promise<{ client: OAuthClient; tokens: Tokens }> {
const [clientJson, tokensJson] = await Promise.all([
readFile(OAUTH_CLIENT_PATH, "utf-8"),
readFile(TOKENS_PATH, "utf-8"),
]);
return {
client: JSON.parse(clientJson) as OAuthClient,
tokens: JSON.parse(tokensJson) as Tokens,
};
}
async function saveTokens(tokens: Tokens): Promise<void> {
await writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2));
}
async function refreshAccessToken(client: OAuthClient, tokens: Tokens): Promise<Tokens> {
const response = await fetch(client.installed.token_uri, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: client.installed.client_id,
client_secret: client.installed.client_secret,
refresh_token: tokens.refresh_token,
grant_type: "refresh_token",
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to refresh token: ${error}`);
}
const newTokenData = await response.json() as Record<string, unknown>;
const updatedTokens: Tokens = {
...tokens,
access_token: newTokenData.access_token as string,
expiry_date: Date.now() + ((newTokenData.expires_in as number) || 3600) * 1000,
};
await saveTokens(updatedTokens);
return updatedTokens;
}
async function getValidToken(): Promise<string> {
const { client, tokens } = await loadCredentials();
// Refresh if expired or expiring within 5 minutes
if (Date.now() > tokens.expiry_date - 5 * 60 * 1000) {
const refreshed = await refreshAccessToken(client, tokens);
return refreshed.access_token;
}
return tokens.access_token;
}
async function calendarRequest<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
const token = await getValidToken();
const url = new URL(`${CALENDAR_API_BASE}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
}
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Calendar API error (${response.status}): ${error}`);
}
return response.json() as T;
}
export async function listCalendars(): Promise<CalendarList["items"]> {
const result = await calendarRequest<CalendarList>("/users/me/calendarList");
return result.items;
}
export async function listEvents(
calendarId: string = "primary",
options: {
timeMin?: string;
timeMax?: string;
maxResults?: number;
singleEvents?: boolean;
orderBy?: "startTime" | "updated";
} = {}
): Promise<CalendarEvent[]> {
const params: Record<string, string> = {
singleEvents: String(options.singleEvents ?? true),
orderBy: options.orderBy ?? "startTime",
maxResults: String(options.maxResults ?? 50),
};
if (options.timeMin) params.timeMin = options.timeMin;
if (options.timeMax) params.timeMax = options.timeMax;
const result = await calendarRequest<EventsResponse>(
`/calendars/${encodeURIComponent(calendarId)}/events`,
params
);
return result.items || [];
}
export async function getTodayEvents(calendarId: string = "primary"): Promise<CalendarEvent[]> {
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
return listEvents(calendarId, {
timeMin: startOfDay.toISOString(),
timeMax: endOfDay.toISOString(),
});
}
export async function getWeekEvents(calendarId: string = "primary"): Promise<CalendarEvent[]> {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay()); // Start from Sunday
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 7);
return listEvents(calendarId, {
timeMin: startOfWeek.toISOString(),
timeMax: endOfWeek.toISOString(),
});
}
export async function getMonthEvents(
calendarId: string = "primary",
year?: number,
month?: number
): Promise<CalendarEvent[]> {
const now = new Date();
const targetYear = year ?? now.getFullYear();
const targetMonth = month ?? now.getMonth();
const startOfMonth = new Date(targetYear, targetMonth, 1);
const endOfMonth = new Date(targetYear, targetMonth + 1, 0, 23, 59, 59);
return listEvents(calendarId, {
timeMin: startOfMonth.toISOString(),
timeMax: endOfMonth.toISOString(),
});
}
export function formatEventsForCanvas(events: CalendarEvent[]): FormattedEvent[] {
return events.map((event) => {
const startDateTime = event.start.dateTime || event.start.date;
const endDateTime = event.end.dateTime || event.end.date;
const isAllDay = !event.start.dateTime;
let date = "";
let startTime: string | undefined;
let endTime: string | undefined;
if (startDateTime) {
const startDate = new Date(startDateTime);
date = startDate.toISOString().split("T")[0];
if (!isAllDay) {
startTime = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
if (endDateTime) {
const endDate = new Date(endDateTime);
endTime = endDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
}
}
}
return {
id: event.id,
title: event.summary || "(No title)",
date,
startTime,
endTime,
location: event.location,
description: event.description,
isAllDay,
};
});
}
export async function checkCredentialsExist(): Promise<boolean> {
try {
await Promise.all([
readFile(OAUTH_CLIENT_PATH),
readFile(TOKENS_PATH),
]);
return true;
} catch {
return false;
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Google API integrations for Zee
*/
export * from "./calendar.js";
+149 -39
View File
@@ -10,6 +10,16 @@
import { z } from "zod";
import type { ToolDefinition, ToolRuntime, ToolExecutionContext, ToolExecutionResult } from "../../mcp/types";
import {
getTodayEvents,
getWeekEvents,
getMonthEvents,
listEvents,
formatEventsForCanvas,
checkCredentialsExist,
type FormattedEvent,
} from "./google/calendar.js";
import { requestDaemon } from "../../daemon/ipc-client.js";
// =============================================================================
// Memory Store Tool
@@ -255,64 +265,164 @@ The notification system will:
// =============================================================================
const CalendarParams = z.object({
action: z.enum(["list", "create", "update", "delete", "find_time"])
.describe("Calendar action"),
event: z.object({
title: z.string().optional(),
start: z.string().optional(),
end: z.string().optional(),
description: z.string().optional(),
attendees: z.array(z.string()).optional(),
}).optional().describe("Event details"),
action: z.enum(["list", "today", "week", "month", "show"])
.describe("Calendar action: list (date range), today, week, month, or show (display in canvas)"),
dateRange: z.object({
start: z.string(),
end: z.string(),
}).optional().describe("Date range for queries"),
duration: z.number().optional()
.describe("Duration in minutes (for find_time)"),
}).optional().describe("Date range for 'list' action (ISO dates)"),
showInCanvas: z.boolean().default(true)
.describe("Display results in canvas sidecar"),
year: z.number().optional().describe("Year for 'month' action"),
month: z.number().min(0).max(11).optional().describe("Month (0-11) for 'month' action"),
});
export const calendarTool: ToolDefinition = {
id: "zee:calendar",
category: "domain",
init: async () => ({
description: `Manage calendar events and find available time.
description: `View Google Calendar events and display them in canvas sidecar.
Actions:
- list: Show events in a date range
- create: Create new event
- update: Update existing event
- delete: Remove event
- find_time: Find available slots
- today: Show today's events
- week: Show this week's events
- month: Show this month's events (or specify year/month)
- list: Show events in a custom date range
- show: Just display calendar canvas without fetching new events
Examples:
- List today's events: { action: "list", dateRange: { start: "2024-01-15", end: "2024-01-15" } }
- Find 30min slot: { action: "find_time", duration: 30 }`,
- Today's events: { action: "today" }
- This week: { action: "week" }
- January 2026: { action: "month", year: 2026, month: 0 }
- Custom range: { action: "list", dateRange: { start: "2026-01-01", end: "2026-01-15" } }
- Display only: { action: "show" }`,
parameters: CalendarParams,
execute: async (args, ctx): Promise<ToolExecutionResult> => {
const { action, event, dateRange, duration } = args;
const { action, dateRange, showInCanvas, year, month } = args;
ctx.metadata({ title: `Calendar: ${action}` });
return {
title: `Calendar: ${action}`,
metadata: {
action,
hasEvent: !!event,
hasDateRange: !!dateRange,
},
output: `[Zee would ${action} calendar]
// Check credentials first
const hasCredentials = await checkCredentialsExist();
if (!hasCredentials) {
return {
title: `Calendar Error`,
metadata: { action },
output: `Google Calendar credentials not found.
Action: ${action}
${event ? `Event: ${JSON.stringify(event, null, 2)}` : ""}
${dateRange ? `Date range: ${dateRange.start} to ${dateRange.end}` : ""}
${duration ? `Duration: ${duration} minutes` : ""}
Please set up OAuth credentials at:
~/.zee/credentials/google/oauth-client.json
~/.zee/credentials/google/tokens.json
Calendar integration:
- Google Calendar API
- Apple Calendar
- Outlook Calendar
- CalDAV support`,
};
You can create credentials at:
https://console.cloud.google.com/apis/credentials
Required scopes:
- https://www.googleapis.com/auth/calendar
- https://www.googleapis.com/auth/calendar.events`,
};
}
try {
let events: FormattedEvent[] = [];
let periodLabel = "";
if (action === "show") {
// Just show the canvas with current date
periodLabel = "Calendar";
} else if (action === "today") {
const raw = await getTodayEvents();
events = formatEventsForCanvas(raw);
periodLabel = "Today";
} else if (action === "week") {
const raw = await getWeekEvents();
events = formatEventsForCanvas(raw);
periodLabel = "This Week";
} else if (action === "month") {
const raw = await getMonthEvents("primary", year, month);
events = formatEventsForCanvas(raw);
const targetDate = new Date(year ?? new Date().getFullYear(), month ?? new Date().getMonth(), 1);
periodLabel = targetDate.toLocaleDateString("en-US", { month: "long", year: "numeric" });
} else if (action === "list" && dateRange) {
const raw = await listEvents("primary", {
timeMin: new Date(dateRange.start).toISOString(),
timeMax: new Date(dateRange.end).toISOString(),
});
events = formatEventsForCanvas(raw);
periodLabel = `${dateRange.start} to ${dateRange.end}`;
}
// Format events for display
const eventsList = events.length > 0
? events.map((e) => {
const time = e.isAllDay ? "All day" : `${e.startTime}${e.endTime ? ` - ${e.endTime}` : ""}`;
const loc = e.location ? ` @ ${e.location}` : "";
return `${e.date} ${time}: ${e.title}${loc}`;
}).join("\n")
: "No events found.";
// Show in canvas sidecar if requested
if (showInCanvas) {
try {
// Convert events to canvas calendar format
const canvasEvents = events.map((e) => ({
date: e.date,
title: e.title,
}));
const targetDate = action === "month" && year && month !== undefined
? `${year}-${String(month + 1).padStart(2, "0")}-15`
: new Date().toISOString().split("T")[0];
await requestDaemon("canvas:spawn", {
kind: "calendar",
id: "zee-calendar",
config: {
title: `Calendar: ${periodLabel}`,
date: targetDate,
events: canvasEvents,
},
});
} catch (canvasError) {
// Canvas might not be available, continue without it
}
}
return {
title: `Calendar: ${periodLabel}`,
metadata: {
action,
eventCount: events.length,
period: periodLabel,
},
output: `${periodLabel} - ${events.length} event(s)
${eventsList}${showInCanvas ? "\n\n📅 Calendar displayed in canvas sidecar." : ""}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if it's an auth error
if (errorMessage.includes("401") || errorMessage.includes("invalid_grant")) {
return {
title: `Calendar Auth Error`,
metadata: { action },
output: `Google Calendar authentication failed.
Your access token may have expired. Please re-authenticate:
1. Delete ~/.zee/credentials/google/tokens.json
2. Run the OAuth flow again to get new tokens
Error: ${errorMessage}`,
};
}
return {
title: `Calendar Error`,
metadata: { action },
output: `Failed to fetch calendar: ${errorMessage}`,
};
}
},
}),
};