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
+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 }),
);
});
});