Files
flynn/src/tools/builtin/gtasks.ts
T
William Valentin f204ff1dd7 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>
2026-02-10 12:59:15 -08:00

216 lines
6.3 KiB
TypeScript

import { google, type Auth } from 'googleapis';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import type { GtasksConfig } 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 Google Tasks config (credentials + token files). */
function createOAuth2Client(config: NonNullable<GtasksConfig>): Auth.OAuth2Client {
const credentialsPath = config.credentials_file;
if (!credentialsPath) {
throw new Error('No credentials_file configured. Set automation.gtasks.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/gtasks-token.json');
if (!existsSync(tokenPath)) {
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gtasks-auth" to authenticate.`);
}
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
oauth2Client.setCredentials(token);
return oauth2Client;
}
interface TaskListSummary {
id: string;
title: string;
updated: string;
}
interface TaskSummary {
id: string;
title: string;
status: string;
due: string;
notes: string;
updated: string;
parent: string;
}
/** Format task lists for tool output. */
function formatTaskLists(lists: TaskListSummary[]): string {
if (lists.length === 0) {
return 'No task lists found.';
}
return lists
.map(l => `[${l.id}] ${l.title}\n Updated: ${l.updated}`)
.join('\n\n');
}
/** Format tasks for tool output. */
function formatTasks(tasks: TaskSummary[]): string {
if (tasks.length === 0) {
return 'No tasks found.';
}
return tasks
.map(t => {
const checkbox = t.status === 'completed' ? '[x]' : '[ ]';
const parts = [`${checkbox} ${t.title}`];
if (t.due) parts.push(` Due: ${t.due}`);
if (t.notes) parts.push(` Notes: ${t.notes}`);
parts.push(` ID: ${t.id}`);
return parts.join('\n');
})
.join('\n\n');
}
/**
* Creates Google Tasks read-only tools bound to the given GtasksConfig.
* Tools create their own OAuth2 client per invocation.
*/
export function createGtasksTools(config: NonNullable<GtasksConfig>): Tool[] {
const tasksLists: Tool = {
name: 'tasks.lists',
description:
'List all Google Tasks task lists. Returns id, title, and last updated time for each list.',
inputSchema: {
type: 'object',
properties: {
maxResults: {
type: 'number',
description: 'Maximum number of task lists to return (default: 20)',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { maxResults?: number };
const maxResults = args.maxResults ?? 20;
try {
const auth = createOAuth2Client(config);
const tasks = google.tasks({ version: 'v1', auth });
const response = await tasks.tasklists.list({
maxResults,
});
const items = response.data.items ?? [];
const lists: TaskListSummary[] = items.map(l => ({
id: l.id ?? '',
title: l.title ?? '(untitled)',
updated: l.updated ?? '',
}));
return {
success: true,
output: formatTaskLists(lists),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const tasksList: Tool = {
name: 'tasks.list',
description:
'List tasks from a Google Tasks task list. Returns title, status (completed/needsAction), due date, and notes. Use tasks.lists first to get task list IDs.',
inputSchema: {
type: 'object',
properties: {
taskListId: {
type: 'string',
description: 'Task list ID (from tasks.lists). Defaults to the primary "@default" list.',
},
maxResults: {
type: 'number',
description: 'Maximum number of tasks to return (default: 100)',
},
showCompleted: {
type: 'boolean',
description: 'Whether to include completed tasks (default: true)',
},
showHidden: {
type: 'boolean',
description: 'Whether to include hidden/deleted tasks (default: false)',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { taskListId?: string; maxResults?: number; showCompleted?: boolean; showHidden?: boolean };
const taskListId = args.taskListId ?? '@default';
const maxResults = args.maxResults ?? 100;
try {
const auth = createOAuth2Client(config);
const tasks = google.tasks({ version: 'v1', auth });
const response = await tasks.tasks.list({
tasklist: taskListId,
maxResults,
showCompleted: args.showCompleted ?? true,
showHidden: args.showHidden ?? false,
});
const items = response.data.items ?? [];
const taskList: TaskSummary[] = items.map(t => ({
id: t.id ?? '',
title: t.title ?? '(untitled)',
status: t.status ?? 'needsAction',
due: t.due ?? '',
notes: t.notes ?? '',
updated: t.updated ?? '',
parent: t.parent ?? '',
}));
return {
success: true,
output: formatTasks(taskList),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [tasksLists, tasksList];
}