feat(tools): add Google Docs, Drive, and Tasks read-only tools

Add three new Google service integrations following the established
Gmail/GCal pattern:

- Google Docs (docs.list, docs.search, docs.read): list, search, and
  read document content as plain text via Docs + Drive APIs
- Google Drive (drive.list, drive.search, drive.read): list, search,
  and read files with export support for Workspace files (Docs→text,
  Sheets→CSV, Slides→text)
- Google Tasks (tasks.lists, tasks.list): list task lists and tasks
  with status, due dates, and notes

Each service has its own config section, OAuth auth command, tool
policy group, and test suite (53 new tests). The setup wizard now
offers to configure all Google services together and run OAuth auth
flows automatically after saving config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 12:59:15 -08:00
parent 411c6d84a2
commit f204ff1dd7
20 changed files with 2844 additions and 15 deletions
+346
View File
@@ -0,0 +1,346 @@
import { google, type Auth } from 'googleapis';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import type { GdriveConfig } from '../../config/schema.js';
import type { Tool, ToolResult } from '../types.js';
/** Expand ~ to home directory. */
function expandPath(p: string): string {
if (p.startsWith('~/') || p === '~') {
return resolve(homedir(), p.slice(2));
}
return resolve(p);
}
/** Create an OAuth2 client from config (credentials + token files). */
function createOAuth2Client(config: NonNullable<GdriveConfig>): Auth.OAuth2Client {
const credentialsPath = config.credentials_file;
if (!credentialsPath) {
throw new Error('No credentials_file configured. Set automation.gdrive.credentials_file in config.');
}
const expandedCredsPath = expandPath(credentialsPath);
if (!existsSync(expandedCredsPath)) {
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
}
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {};
if (!client_id || !client_secret) {
throw new Error('Invalid credentials file — missing client_id or client_secret');
}
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris?.[0] ?? 'http://localhost',
);
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gdrive-token.json');
if (!existsSync(tokenPath)) {
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdrive-auth" to authenticate.`);
}
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
oauth2Client.setCredentials(token);
return oauth2Client;
}
interface FileSummary {
id: string;
name: string;
mimeType: string;
modifiedTime: string;
size: string;
owners: string[];
webViewLink: string;
}
/** Map Google Workspace MIME types to human-readable labels. */
function friendlyMimeType(mimeType: string): string {
const map: Record<string, string> = {
'application/vnd.google-apps.document': 'Google Doc',
'application/vnd.google-apps.spreadsheet': 'Google Sheet',
'application/vnd.google-apps.presentation': 'Google Slides',
'application/vnd.google-apps.form': 'Google Form',
'application/vnd.google-apps.drawing': 'Google Drawing',
'application/vnd.google-apps.folder': 'Folder',
'application/pdf': 'PDF',
};
return map[mimeType] ?? mimeType;
}
/** Format file size in human-readable form. */
function formatSize(bytes: string | undefined): string {
if (!bytes) return '';
const n = parseInt(bytes, 10);
if (isNaN(n)) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** Format a list of file summaries for tool output. */
function formatFiles(files: FileSummary[]): string {
if (files.length === 0) {
return 'No files found.';
}
return files
.map(f => {
const parts = [`[${f.id}] ${f.name}`];
parts.push(` Type: ${friendlyMimeType(f.mimeType)}`);
parts.push(` Modified: ${f.modifiedTime}`);
const size = formatSize(f.size);
if (size) parts.push(` Size: ${size}`);
if (f.owners.length > 0) parts.push(` Owners: ${f.owners.join(', ')}`);
if (f.webViewLink) parts.push(` Link: ${f.webViewLink}`);
return parts.join('\n');
})
.join('\n\n');
}
/** Google Workspace MIME types that can be exported as plain text. */
const EXPORTABLE_TYPES: Record<string, { exportMime: string; label: string }> = {
'application/vnd.google-apps.document': { exportMime: 'text/plain', label: 'Google Doc' },
'application/vnd.google-apps.spreadsheet': { exportMime: 'text/csv', label: 'Google Sheet' },
'application/vnd.google-apps.presentation': { exportMime: 'text/plain', label: 'Google Slides' },
};
/** Text MIME types that can be read directly. */
function isTextMime(mimeType: string): boolean {
return mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
mimeType === 'application/javascript' ||
mimeType === 'application/x-yaml' ||
mimeType.endsWith('+json') ||
mimeType.endsWith('+xml');
}
/**
* Creates Google Drive read-only tools. Shares config/token with gdocs.
* Provides list, search, and read (with export for Workspace files).
*/
export function createGdriveTools(config: NonNullable<GdriveConfig>): Tool[] {
const driveList: Tool = {
name: 'drive.list',
description:
'List recent files from Google Drive. Returns id, name, type, modified time, size, owners, and link.',
inputSchema: {
type: 'object',
properties: {
maxResults: {
type: 'number',
description: 'Maximum number of files to return (default: 10)',
},
mimeType: {
type: 'string',
description: 'Filter by MIME type (e.g. "application/pdf", "application/vnd.google-apps.spreadsheet")',
},
folderId: {
type: 'string',
description: 'List files within a specific folder ID',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { maxResults?: number; mimeType?: string; folderId?: string };
const maxResults = args.maxResults ?? 10;
try {
const auth = createOAuth2Client(config);
const drive = google.drive({ version: 'v3', auth });
const queryParts = ['trashed=false'];
if (args.mimeType) {
queryParts.push(`mimeType='${args.mimeType}'`);
}
if (args.folderId) {
queryParts.push(`'${args.folderId}' in parents`);
}
const response = await drive.files.list({
q: queryParts.join(' and '),
pageSize: maxResults,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,mimeType,modifiedTime,size,owners,webViewLink)',
});
const files = response.data.files ?? [];
const summaries: FileSummary[] = files.map(f => ({
id: f.id ?? '',
name: f.name ?? '(untitled)',
mimeType: f.mimeType ?? '',
modifiedTime: f.modifiedTime ?? '',
size: f.size ?? '',
owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean),
webViewLink: f.webViewLink ?? '',
}));
return {
success: true,
output: formatFiles(summaries),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const driveSearch: Tool = {
name: 'drive.search',
description:
'Search Google Drive files by name or content. Returns id, name, type, modified time, size, owners, and link.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query to match against file names and content',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (default: 10)',
},
mimeType: {
type: 'string',
description: 'Filter by MIME type (e.g. "application/pdf")',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { query: string; maxResults?: number; mimeType?: string };
const maxResults = args.maxResults ?? 10;
try {
const auth = createOAuth2Client(config);
const drive = google.drive({ version: 'v3', auth });
const escapedQuery = args.query.replace(/'/g, "\\'");
const queryParts = [
'trashed=false',
`(name contains '${escapedQuery}' or fullText contains '${escapedQuery}')`,
];
if (args.mimeType) {
queryParts.push(`mimeType='${args.mimeType}'`);
}
const response = await drive.files.list({
q: queryParts.join(' and '),
pageSize: maxResults,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,mimeType,modifiedTime,size,owners,webViewLink)',
});
const files = response.data.files ?? [];
const summaries: FileSummary[] = files.map(f => ({
id: f.id ?? '',
name: f.name ?? '(untitled)',
mimeType: f.mimeType ?? '',
modifiedTime: f.modifiedTime ?? '',
size: f.size ?? '',
owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean),
webViewLink: f.webViewLink ?? '',
}));
return {
success: true,
output: formatFiles(summaries),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const driveRead: Tool = {
name: 'drive.read',
description:
'Read the content of a Google Drive file. Exports Google Workspace files (Docs, Sheets, Slides) as plain text/CSV. Downloads regular text files directly. Use drive.list or drive.search to get file IDs.',
inputSchema: {
type: 'object',
properties: {
fileId: {
type: 'string',
description: 'The Google Drive file ID (from drive.list or drive.search results)',
},
},
required: ['fileId'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { fileId: string };
try {
const auth = createOAuth2Client(config);
const drive = google.drive({ version: 'v3', auth });
// First get file metadata to determine type
const meta = await drive.files.get({
fileId: args.fileId,
fields: 'id,name,mimeType,size',
});
const name = meta.data.name ?? '(untitled)';
const mimeType = meta.data.mimeType ?? '';
// Google Workspace files: export
const exportable = EXPORTABLE_TYPES[mimeType];
if (exportable) {
const exported = await drive.files.export({
fileId: args.fileId,
mimeType: exportable.exportMime,
}, { responseType: 'text' });
const content = typeof exported.data === 'string' ? exported.data : String(exported.data);
return {
success: true,
output: `Name: ${name}\nType: ${exportable.label}\n\n${content || '(empty)'}`,
};
}
// Regular text files: download
if (isTextMime(mimeType)) {
const downloaded = await drive.files.get({
fileId: args.fileId,
alt: 'media',
}, { responseType: 'text' });
const content = typeof downloaded.data === 'string' ? downloaded.data : String(downloaded.data);
return {
success: true,
output: `Name: ${name}\nType: ${mimeType}\n\n${content || '(empty)'}`,
};
}
// Binary / unsupported types
return {
success: true,
output: `Name: ${name}\nType: ${friendlyMimeType(mimeType)}\n\nBinary file — cannot display content. Use the web link to view this file.`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [driveList, driveSearch, driveRead];
}