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
+379
View File
@@ -0,0 +1,379 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GdocsConfig } from '../../config/schema.js';
// Hoisted mocks so vi.mock factories can reference them
const { mockFilesList, mockDocumentsGet, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockFilesList: vi.fn(),
mockDocumentsGet: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
drive: vi.fn().mockReturnValue({
files: {
list: mockFilesList,
},
}),
docs: vi.fn().mockReturnValue({
documents: {
get: mockDocumentsGet,
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGdocsTools } from './gdocs.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GdocsConfig> = {
enabled: true,
credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json',
};
const fakeCredentials = {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost'],
},
};
const fakeToken = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function setupValidAuth() {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
if (p.includes('token')) return JSON.stringify(fakeToken);
return '';
});
}
function mockDriveFile(
id: string,
name: string,
opts?: { modifiedTime?: string; owners?: string[]; webViewLink?: string },
) {
return {
id,
name,
modifiedTime: opts?.modifiedTime ?? '2026-02-10T12:00:00Z',
owners: (opts?.owners ?? ['owner@test.com']).map(email => ({ emailAddress: email })),
webViewLink: opts?.webViewLink ?? `https://docs.google.com/document/d/${id}/edit`,
};
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGdocsTools', () => {
it('returns 3 tools with correct names', () => {
const tools = createGdocsTools(testConfig);
expect(tools).toHaveLength(3);
expect(tools.map(t => t.name)).toEqual(['docs.list', 'docs.search', 'docs.read']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGdocsTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('docs.list', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('returns error when token file missing', async () => {
mockExistsSync.mockImplementation((path: unknown) => {
return String(path).includes('creds');
});
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists recent documents', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('doc1', 'Project Plan'),
mockDriveFile('doc2', 'Meeting Notes'),
],
},
});
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('Project Plan');
expect(result.output).toContain('Meeting Notes');
expect(result.output).toContain('doc1');
expect(result.output).toContain('doc2');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: "mimeType='application/vnd.google-apps.document' and trashed=false",
pageSize: 10,
orderBy: 'modifiedTime desc',
}),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No documents found.');
});
it('respects maxResults parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdocsTools(testConfig);
await listTool.execute({ maxResults: 5 });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
pageSize: 5,
}),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('docs.search', () => {
it('searches with query parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('doc1', 'Sprint Planning Doc'),
],
},
});
const [, searchTool] = createGdocsTools(testConfig);
const result = await searchTool.execute({ query: 'planning' });
expect(result.success).toBe(true);
expect(result.output).toContain('Sprint Planning Doc');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("name contains 'planning'"),
}),
);
});
it('escapes single quotes in query', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdocsTools(testConfig);
await searchTool.execute({ query: "it's a test" });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("it\\'s a test"),
}),
);
});
it('respects maxResults param', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdocsTools(testConfig);
await searchTool.execute({ query: 'meeting', maxResults: 3 });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
pageSize: 3,
}),
);
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, searchTool] = createGdocsTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [, searchTool] = createGdocsTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('docs.read', () => {
it('reads document content', async () => {
setupValidAuth();
mockDocumentsGet.mockResolvedValue({
data: {
title: 'My Document',
body: {
content: [
{
paragraph: {
elements: [
{ textRun: { content: 'Hello ' } },
{ textRun: { content: 'World\n' } },
],
},
},
{
paragraph: {
elements: [
{ textRun: { content: 'Second paragraph\n' } },
],
},
},
],
},
},
});
const [, , readTool] = createGdocsTools(testConfig);
const result = await readTool.execute({ documentId: 'doc123' });
expect(result.success).toBe(true);
expect(result.output).toContain('Title: My Document');
expect(result.output).toContain('Hello World');
expect(result.output).toContain('Second paragraph');
expect(mockDocumentsGet).toHaveBeenCalledWith({
documentId: 'doc123',
});
});
it('handles empty document', async () => {
setupValidAuth();
mockDocumentsGet.mockResolvedValue({
data: {
title: 'Empty Doc',
body: { content: [] },
},
});
const [, , readTool] = createGdocsTools(testConfig);
const result = await readTool.execute({ documentId: 'doc-empty' });
expect(result.success).toBe(true);
expect(result.output).toContain('Title: Empty Doc');
expect(result.output).toContain('(empty document)');
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, , readTool] = createGdocsTools(testConfig);
const result = await readTool.execute({ documentId: 'doc123' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockDocumentsGet.mockRejectedValue(new Error('Document not found'));
const [, , readTool] = createGdocsTools(testConfig);
const result = await readTool.execute({ documentId: 'nonexistent' });
expect(result.success).toBe(false);
expect(result.error).toContain('Document not found');
});
it('formats output with owners and links', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('doc1', 'Team Wiki', {
modifiedTime: '2026-02-09T08:30:00Z',
owners: ['alice@test.com'],
webViewLink: 'https://docs.google.com/document/d/doc1/edit',
}),
],
},
});
const [listTool] = createGdocsTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('Team Wiki');
expect(result.output).toContain('2026-02-09T08:30:00Z');
expect(result.output).toContain('alice@test.com');
expect(result.output).toContain('https://docs.google.com/document/d/doc1/edit');
});
});
+256
View File
@@ -0,0 +1,256 @@
import { google, type Auth } from 'googleapis';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import type { GdocsConfig } 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 Docs config (credentials + token files). */
function createOAuth2Client(config: NonNullable<GdocsConfig>): Auth.OAuth2Client {
const credentialsPath = config.credentials_file;
if (!credentialsPath) {
throw new Error('No credentials_file configured. Set automation.gdocs.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/gdocs-token.json');
if (!existsSync(tokenPath)) {
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdocs-auth" to authenticate.`);
}
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
oauth2Client.setCredentials(token);
return oauth2Client;
}
interface DocSummary {
id: string;
name: string;
modifiedTime: string;
owners: string[];
webViewLink: string;
}
/** Format a list of document summaries for tool output. */
function formatDocs(docs: DocSummary[]): string {
if (docs.length === 0) {
return 'No documents found.';
}
return docs
.map(d => {
const parts = [`[${d.id}] ${d.name}`];
parts.push(` Modified: ${d.modifiedTime}`);
if (d.owners.length > 0) parts.push(` Owners: ${d.owners.join(', ')}`);
if (d.webViewLink) parts.push(` Link: ${d.webViewLink}`);
return parts.join('\n');
})
.join('\n\n');
}
/** Extract plain text from a Google Docs document body. */
function extractPlainText(body: import('googleapis').docs_v1.Schema$Body): string {
if (!body.content) return '';
const parts: string[] = [];
for (const structural of body.content) {
if (structural.paragraph?.elements) {
for (const element of structural.paragraph.elements) {
if (element.textRun?.content) {
parts.push(element.textRun.content);
}
}
}
}
return parts.join('');
}
/**
* Creates Google Docs read-only tools bound to the given GdocsConfig.
* Uses Drive API for list/search and Docs API for reading content.
*/
export function createGdocsTools(config: NonNullable<GdocsConfig>): Tool[] {
const docsList: Tool = {
name: 'docs.list',
description:
'List recent Google Docs documents. Returns id, name, modified time, owners, and link for each document.',
inputSchema: {
type: 'object',
properties: {
maxResults: {
type: 'number',
description: 'Maximum number of documents to return (default: 10)',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { maxResults?: number };
const maxResults = args.maxResults ?? 10;
try {
const auth = createOAuth2Client(config);
const drive = google.drive({ version: 'v3', auth });
const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.document' and trashed=false",
pageSize: maxResults,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,modifiedTime,owners,webViewLink)',
});
const files = response.data.files ?? [];
const docs: DocSummary[] = files.map(f => ({
id: f.id ?? '',
name: f.name ?? '(untitled)',
modifiedTime: f.modifiedTime ?? '',
owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean),
webViewLink: f.webViewLink ?? '',
}));
return {
success: true,
output: formatDocs(docs),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const docsSearch: Tool = {
name: 'docs.search',
description:
'Search Google Docs by name. Returns id, name, modified time, owners, and link for each matching document.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query to match against document names',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (default: 10)',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { query: string; maxResults?: number };
const maxResults = args.maxResults ?? 10;
try {
const auth = createOAuth2Client(config);
const drive = google.drive({ version: 'v3', auth });
const escapedQuery = args.query.replace(/'/g, "\\'");
const response = await drive.files.list({
q: `mimeType='application/vnd.google-apps.document' and trashed=false and (name contains '${escapedQuery}' or fullText contains '${escapedQuery}')`,
pageSize: maxResults,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,modifiedTime,owners,webViewLink)',
});
const files = response.data.files ?? [];
const docs: DocSummary[] = files.map(f => ({
id: f.id ?? '',
name: f.name ?? '(untitled)',
modifiedTime: f.modifiedTime ?? '',
owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean),
webViewLink: f.webViewLink ?? '',
}));
return {
success: true,
output: formatDocs(docs),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const docsRead: Tool = {
name: 'docs.read',
description:
'Read the content of a Google Doc as plain text. Use docs.list or docs.search first to get document IDs.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'The Google Docs document ID (from docs.list or docs.search results)',
},
},
required: ['documentId'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { documentId: string };
try {
const auth = createOAuth2Client(config);
const docs = google.docs({ version: 'v1', auth });
const doc = await docs.documents.get({
documentId: args.documentId,
});
const title = doc.data.title ?? '(untitled)';
const body = doc.data.body ? extractPlainText(doc.data.body) : '';
const parts = [
`Title: ${title}`,
'',
body || '(empty document)',
];
return {
success: true,
output: parts.join('\n'),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [docsList, docsSearch, docsRead];
}
+389
View File
@@ -0,0 +1,389 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GdriveConfig } from '../../config/schema.js';
// Hoisted mocks so vi.mock factories can reference them
const { mockFilesList, mockFilesGet, mockFilesExport, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockFilesList: vi.fn(),
mockFilesGet: vi.fn(),
mockFilesExport: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
drive: vi.fn().mockReturnValue({
files: {
list: mockFilesList,
get: mockFilesGet,
export: mockFilesExport,
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGdriveTools } from './gdrive.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GdriveConfig> = {
enabled: true,
credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json',
};
const fakeCredentials = {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost'],
},
};
const fakeToken = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function setupValidAuth() {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
if (p.includes('token')) return JSON.stringify(fakeToken);
return '';
});
}
function mockDriveFile(
id: string,
name: string,
mimeType: string,
opts?: { modifiedTime?: string; size?: string; owners?: string[]; webViewLink?: string },
) {
return {
id,
name,
mimeType,
modifiedTime: opts?.modifiedTime ?? '2026-02-10T12:00:00Z',
size: opts?.size,
owners: (opts?.owners ?? ['owner@test.com']).map(email => ({ emailAddress: email })),
webViewLink: opts?.webViewLink ?? `https://drive.google.com/file/d/${id}/view`,
};
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGdriveTools', () => {
it('returns 3 tools with correct names', () => {
const tools = createGdriveTools(testConfig);
expect(tools).toHaveLength(3);
expect(tools.map(t => t.name)).toEqual(['drive.list', 'drive.search', 'drive.read']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGdriveTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('drive.list', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('returns error when token file missing', async () => {
mockExistsSync.mockImplementation((path: unknown) => {
return String(path).includes('creds');
});
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists recent files', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('f1', 'Report.pdf', 'application/pdf', { size: '1048576' }),
mockDriveFile('f2', 'Notes', 'application/vnd.google-apps.document'),
],
},
});
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('Report.pdf');
expect(result.output).toContain('PDF');
expect(result.output).toContain('1.0 MB');
expect(result.output).toContain('Notes');
expect(result.output).toContain('Google Doc');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: 'trashed=false',
pageSize: 10,
orderBy: 'modifiedTime desc',
}),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No files found.');
});
it('respects maxResults parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ maxResults: 5 });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 5 }),
);
});
it('filters by mimeType', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ mimeType: 'application/pdf' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: "trashed=false and mimeType='application/pdf'",
}),
);
});
it('filters by folderId', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ folderId: 'folder123' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: "trashed=false and 'folder123' in parents",
}),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('drive.search', () => {
it('searches with query parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('f1', 'Q1 Budget', 'application/vnd.google-apps.spreadsheet'),
],
},
});
const [, searchTool] = createGdriveTools(testConfig);
const result = await searchTool.execute({ query: 'budget' });
expect(result.success).toBe(true);
expect(result.output).toContain('Q1 Budget');
expect(result.output).toContain('Google Sheet');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("name contains 'budget'"),
}),
);
});
it('escapes single quotes in query', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdriveTools(testConfig);
await searchTool.execute({ query: "it's a test" });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("it\\'s a test"),
}),
);
});
it('filters by mimeType', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdriveTools(testConfig);
await searchTool.execute({ query: 'report', mimeType: 'application/pdf' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("mimeType='application/pdf'"),
}),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [, searchTool] = createGdriveTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('drive.read', () => {
it('exports Google Docs as plain text', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'doc1', name: 'My Doc', mimeType: 'application/vnd.google-apps.document', size: null },
});
mockFilesExport.mockResolvedValue({ data: 'Hello from the document' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'doc1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: My Doc');
expect(result.output).toContain('Type: Google Doc');
expect(result.output).toContain('Hello from the document');
expect(mockFilesExport).toHaveBeenCalledWith(
{ fileId: 'doc1', mimeType: 'text/plain' },
{ responseType: 'text' },
);
});
it('exports Google Sheets as CSV', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'sheet1', name: 'Budget', mimeType: 'application/vnd.google-apps.spreadsheet' },
});
mockFilesExport.mockResolvedValue({ data: 'Name,Amount\nAlice,100' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'sheet1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: Budget');
expect(result.output).toContain('Type: Google Sheet');
expect(result.output).toContain('Name,Amount');
expect(mockFilesExport).toHaveBeenCalledWith(
{ fileId: 'sheet1', mimeType: 'text/csv' },
{ responseType: 'text' },
);
});
it('downloads plain text files directly', async () => {
setupValidAuth();
mockFilesGet
.mockResolvedValueOnce({
data: { id: 'txt1', name: 'notes.txt', mimeType: 'text/plain', size: '42' },
})
.mockResolvedValueOnce({ data: 'These are my notes' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'txt1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: notes.txt');
expect(result.output).toContain('These are my notes');
});
it('reports binary files as unreadable', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'img1', name: 'photo.jpg', mimeType: 'image/jpeg', size: '500000' },
});
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'img1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: photo.jpg');
expect(result.output).toContain('Binary file');
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'doc1' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesGet.mockRejectedValue(new Error('File not found'));
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'nonexistent' });
expect(result.success).toBe(false);
expect(result.error).toContain('File not found');
});
});
+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];
}
+274
View File
@@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GtasksConfig } from '../../config/schema.js';
// Hoisted mocks so vi.mock factories can reference them
const { mockTasklistsList, mockTasksList, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockTasklistsList: vi.fn(),
mockTasksList: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
tasks: vi.fn().mockReturnValue({
tasklists: {
list: mockTasklistsList,
},
tasks: {
list: mockTasksList,
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGtasksTools } from './gtasks.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GtasksConfig> = {
enabled: true,
credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json',
};
const fakeCredentials = {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost'],
},
};
const fakeToken = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function setupValidAuth() {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
if (p.includes('token')) return JSON.stringify(fakeToken);
return '';
});
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGtasksTools', () => {
it('returns 2 tools with correct names', () => {
const tools = createGtasksTools(testConfig);
expect(tools).toHaveLength(2);
expect(tools.map(t => t.name)).toEqual(['tasks.lists', 'tasks.list']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGtasksTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('tasks.lists', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [listsTool] = createGtasksTools(testConfig);
const result = await listsTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('returns error when token file missing', async () => {
mockExistsSync.mockImplementation((path: unknown) => {
return String(path).includes('creds');
});
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
const [listsTool] = createGtasksTools(testConfig);
const result = await listsTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists task lists', async () => {
setupValidAuth();
mockTasklistsList.mockResolvedValue({
data: {
items: [
{ id: 'list1', title: 'My Tasks', updated: '2026-02-10T10:00:00Z' },
{ id: 'list2', title: 'Work', updated: '2026-02-09T15:00:00Z' },
],
},
});
const [listsTool] = createGtasksTools(testConfig);
const result = await listsTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('My Tasks');
expect(result.output).toContain('Work');
expect(result.output).toContain('list1');
expect(result.output).toContain('list2');
});
it('handles empty results', async () => {
setupValidAuth();
mockTasklistsList.mockResolvedValue({ data: { items: [] } });
const [listsTool] = createGtasksTools(testConfig);
const result = await listsTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No task lists found.');
});
it('respects maxResults parameter', async () => {
setupValidAuth();
mockTasklistsList.mockResolvedValue({ data: { items: [] } });
const [listsTool] = createGtasksTools(testConfig);
await listsTool.execute({ maxResults: 5 });
expect(mockTasklistsList).toHaveBeenCalledWith(
expect.objectContaining({ maxResults: 5 }),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockTasklistsList.mockRejectedValue(new Error('API quota exceeded'));
const [listsTool] = createGtasksTools(testConfig);
const result = await listsTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('tasks.list', () => {
it('lists tasks from default list', async () => {
setupValidAuth();
mockTasksList.mockResolvedValue({
data: {
items: [
{ id: 'task1', title: 'Buy groceries', status: 'needsAction', due: '2026-02-11T00:00:00Z', notes: 'Milk, bread', parent: '' },
{ id: 'task2', title: 'Call dentist', status: 'completed', due: '', notes: '', parent: '' },
],
},
});
const [, listTool] = createGtasksTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('[ ] Buy groceries');
expect(result.output).toContain('Due: 2026-02-11');
expect(result.output).toContain('Notes: Milk, bread');
expect(result.output).toContain('[x] Call dentist');
expect(mockTasksList).toHaveBeenCalledWith(
expect.objectContaining({
tasklist: '@default',
showCompleted: true,
showHidden: false,
}),
);
});
it('uses specified taskListId', async () => {
setupValidAuth();
mockTasksList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGtasksTools(testConfig);
await listTool.execute({ taskListId: 'list123' });
expect(mockTasksList).toHaveBeenCalledWith(
expect.objectContaining({ tasklist: 'list123' }),
);
});
it('respects showCompleted parameter', async () => {
setupValidAuth();
mockTasksList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGtasksTools(testConfig);
await listTool.execute({ showCompleted: false });
expect(mockTasksList).toHaveBeenCalledWith(
expect.objectContaining({ showCompleted: false }),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockTasksList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGtasksTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No tasks found.');
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, listTool] = createGtasksTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockTasksList.mockRejectedValue(new Error('Not Found'));
const [, listTool] = createGtasksTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Not Found');
});
it('respects maxResults parameter', async () => {
setupValidAuth();
mockTasksList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGtasksTools(testConfig);
await listTool.execute({ maxResults: 50 });
expect(mockTasksList).toHaveBeenCalledWith(
expect.objectContaining({ maxResults: 50 }),
);
});
});
+215
View File
@@ -0,0 +1,215 @@
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];
}
+3
View File
@@ -23,6 +23,9 @@ export { createMessageSendTool } from './message-send.js';
export { createCronTools } from './cron.js';
export { createGmailTools } from './gmail.js';
export { createGcalTools } from './gcal.js';
export { createGdocsTools } from './gdocs.js';
export { createGdriveTools } from './gdrive.js';
export { createGtasksTools } from './gtasks.js';
import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js';