From f204ff1dd79a8bbf65f63cf14eca45da91446b13 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 12:59:15 -0800 Subject: [PATCH] feat(tools): add Google Docs, Drive, and Tasks read-only tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cli/gdocs-auth.ts | 248 ++++++++++++++++++++ src/cli/gdrive-auth.ts | 245 +++++++++++++++++++ src/cli/gtasks-auth.ts | 245 +++++++++++++++++++ src/cli/index.ts | 6 + src/cli/setup.ts | 4 + src/cli/setup/automation.ts | 142 ++++++++++- src/cli/setup/config.ts | 24 ++ src/cli/setup/summary.ts | 4 + src/cli/tui.ts | 23 +- src/config/schema.ts | 24 ++ src/daemon/index.ts | 11 +- src/tools/builtin/gdocs.test.ts | 379 ++++++++++++++++++++++++++++++ src/tools/builtin/gdocs.ts | 256 ++++++++++++++++++++ src/tools/builtin/gdrive.test.ts | 389 +++++++++++++++++++++++++++++++ src/tools/builtin/gdrive.ts | 346 +++++++++++++++++++++++++++ src/tools/builtin/gtasks.test.ts | 274 ++++++++++++++++++++++ src/tools/builtin/gtasks.ts | 215 +++++++++++++++++ src/tools/builtin/index.ts | 3 + src/tools/index.ts | 2 +- src/tools/policy.ts | 19 ++ 20 files changed, 2844 insertions(+), 15 deletions(-) create mode 100644 src/cli/gdocs-auth.ts create mode 100644 src/cli/gdrive-auth.ts create mode 100644 src/cli/gtasks-auth.ts create mode 100644 src/tools/builtin/gdocs.test.ts create mode 100644 src/tools/builtin/gdocs.ts create mode 100644 src/tools/builtin/gdrive.test.ts create mode 100644 src/tools/builtin/gdrive.ts create mode 100644 src/tools/builtin/gtasks.test.ts create mode 100644 src/tools/builtin/gtasks.ts diff --git a/src/cli/gdocs-auth.ts b/src/cli/gdocs-auth.ts new file mode 100644 index 0000000..26428f2 --- /dev/null +++ b/src/cli/gdocs-auth.ts @@ -0,0 +1,248 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { loadConfigSafe } from './shared.js'; + +const SCOPES = [ + 'https://www.googleapis.com/auth/documents.readonly', + 'https://www.googleapis.com/auth/drive.readonly', +]; +const REDIRECT_PORT = 3000; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; + +/** Expand ~ to the user's home directory. */ +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +/** Read and parse the OAuth2 credentials file. */ +function readCredentials(credentialsPath: string): { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} { + if (!existsSync(credentialsPath)) { + throw new Error(`Credentials file not found: ${credentialsPath}`); + } + + const credentials = JSON.parse(readFileSync(credentialsPath, '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'); + } + + return { client_id, client_secret, redirect_uris }; +} + +/** Generate the OAuth2 authorization URL. */ +function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +/** Exchange authorization code for tokens using Google's token endpoint. */ +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Token exchange failed (${response.status}): ${body}`); + } + + return response.json() as Promise>; +} + +/** Save token to disk with restrictive permissions (0o600). */ +function saveToken(tokenPath: string, token: unknown): void { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8'); + try { + chmodSync(tokenPath, 0o600); + } catch { + // chmod may fail on some filesystems — not critical + } +} + +/** Start a temporary HTTP server to receive the OAuth callback. */ +function waitForCallback(port: number): Promise<{ code: string; server: Server }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authorization failed

${error}

`); + reject(new Error(`OAuth error: ${error}`)); + server.close(); + return; + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); + resolve({ code, server }); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + }); + + server.listen(port, () => {}); + server.on('error', reject); + }); +} + +/** Try to open a URL in the user's browser. */ +async function openBrowser(url: string): Promise { + const { exec } = await import('child_process'); + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + return new Promise((resolve) => { + exec(`${command} ${JSON.stringify(url)}`, (error) => { + resolve(!error); + }); + }); +} + +/** Manual code entry via stdin. */ +async function promptForCode(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export function registerGdocsAuthCommand(program: Command): void { + program + .command('gdocs-auth') + .description('Authenticate with Google Docs via OAuth2') + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { config?: string; manual?: boolean }) => { + // 1. Load config + const { config, error } = loadConfigSafe(opts.config); + if (error || !config) { + console.error(`Error: ${error ?? 'Could not load config'}`); + process.exit(1); + } + + const gdocsConfig = config.automation.gdocs; + if (!gdocsConfig) { + console.error('Error: automation.gdocs is not configured in config.yaml'); + process.exit(1); + } + + // 2. Read credentials + const credentialsPath = expandPath(gdocsConfig.credentials_file ?? '~/.config/flynn/gdocs-credentials.json'); + let creds: ReturnType; + try { + creds = readCredentials(credentialsPath); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const tokenPath = expandPath(gdocsConfig.token_file ?? '~/.config/flynn/gdocs-token.json'); + + // 3. Check if already authenticated + if (existsSync(tokenPath)) { + console.log(`Token already exists at ${tokenPath}`); + console.log('Delete it first if you want to re-authenticate.'); + process.exit(0); + } + + const redirectUri = opts.manual + ? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob') + : REDIRECT_URI; + + // 4. Generate auth URL + const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri); + + if (opts.manual) { + // Manual flow + console.log('\nOpen this URL in your browser:\n'); + console.log(authUrl); + console.log(''); + const code = await promptForCode(); + const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } else { + // Local server flow + console.log('Starting local server for OAuth callback...'); + + let callbackResult: { code: string; server: Server }; + try { + const callbackPromise = waitForCallback(REDIRECT_PORT); + + const opened = await openBrowser(authUrl); + if (!opened) { + console.log('\nCould not open browser. Open this URL manually:\n'); + console.log(authUrl); + } else { + console.log('\nBrowser opened. Complete the authorization flow...'); + } + + console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`); + callbackResult = await callbackPromise; + } catch (err) { + console.error(`\nError: ${err instanceof Error ? err.message : err}`); + console.log('\nTry again with --manual flag: flynn gdocs-auth --manual'); + process.exit(1); + } + + try { + const token = await exchangeCodeForTokens( + callbackResult.code, + creds.client_id, + creds.client_secret, + redirectUri, + ); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } finally { + callbackResult.server.close(); + } + } + + console.log('Google Docs authentication complete!'); + }); +} diff --git a/src/cli/gdrive-auth.ts b/src/cli/gdrive-auth.ts new file mode 100644 index 0000000..ccd02dc --- /dev/null +++ b/src/cli/gdrive-auth.ts @@ -0,0 +1,245 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { loadConfigSafe } from './shared.js'; + +const SCOPES = ['https://www.googleapis.com/auth/drive.readonly']; +const REDIRECT_PORT = 3000; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; + +/** Expand ~ to the user's home directory. */ +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +/** Read and parse the OAuth2 credentials file. */ +function readCredentials(credentialsPath: string): { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} { + if (!existsSync(credentialsPath)) { + throw new Error(`Credentials file not found: ${credentialsPath}`); + } + + const credentials = JSON.parse(readFileSync(credentialsPath, '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'); + } + + return { client_id, client_secret, redirect_uris }; +} + +/** Generate the OAuth2 authorization URL. */ +function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +/** Exchange authorization code for tokens using Google's token endpoint. */ +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Token exchange failed (${response.status}): ${body}`); + } + + return response.json() as Promise>; +} + +/** Save token to disk with restrictive permissions (0o600). */ +function saveToken(tokenPath: string, token: unknown): void { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8'); + try { + chmodSync(tokenPath, 0o600); + } catch { + // chmod may fail on some filesystems — not critical + } +} + +/** Start a temporary HTTP server to receive the OAuth callback. */ +function waitForCallback(port: number): Promise<{ code: string; server: Server }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authorization failed

${error}

`); + reject(new Error(`OAuth error: ${error}`)); + server.close(); + return; + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); + resolve({ code, server }); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + }); + + server.listen(port, () => {}); + server.on('error', reject); + }); +} + +/** Try to open a URL in the user's browser. */ +async function openBrowser(url: string): Promise { + const { exec } = await import('child_process'); + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + return new Promise((resolve) => { + exec(`${command} ${JSON.stringify(url)}`, (error) => { + resolve(!error); + }); + }); +} + +/** Manual code entry via stdin. */ +async function promptForCode(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export function registerGdriveAuthCommand(program: Command): void { + program + .command('gdrive-auth') + .description('Authenticate with Google Drive via OAuth2') + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { config?: string; manual?: boolean }) => { + // 1. Load config + const { config, error } = loadConfigSafe(opts.config); + if (error || !config) { + console.error(`Error: ${error ?? 'Could not load config'}`); + process.exit(1); + } + + const gdriveConfig = config.automation.gdrive; + if (!gdriveConfig) { + console.error('Error: automation.gdrive is not configured in config.yaml'); + process.exit(1); + } + + // 2. Read credentials + const credentialsPath = expandPath(gdriveConfig.credentials_file ?? '~/.config/flynn/gdrive-credentials.json'); + let creds: ReturnType; + try { + creds = readCredentials(credentialsPath); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const tokenPath = expandPath(gdriveConfig.token_file ?? '~/.config/flynn/gdrive-token.json'); + + // 3. Check if already authenticated + if (existsSync(tokenPath)) { + console.log(`Token already exists at ${tokenPath}`); + console.log('Delete it first if you want to re-authenticate.'); + process.exit(0); + } + + const redirectUri = opts.manual + ? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob') + : REDIRECT_URI; + + // 4. Generate auth URL + const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri); + + if (opts.manual) { + // Manual flow + console.log('\nOpen this URL in your browser:\n'); + console.log(authUrl); + console.log(''); + const code = await promptForCode(); + const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } else { + // Local server flow + console.log('Starting local server for OAuth callback...'); + + let callbackResult: { code: string; server: Server }; + try { + const callbackPromise = waitForCallback(REDIRECT_PORT); + + const opened = await openBrowser(authUrl); + if (!opened) { + console.log('\nCould not open browser. Open this URL manually:\n'); + console.log(authUrl); + } else { + console.log('\nBrowser opened. Complete the authorization flow...'); + } + + console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`); + callbackResult = await callbackPromise; + } catch (err) { + console.error(`\nError: ${err instanceof Error ? err.message : err}`); + console.log('\nTry again with --manual flag: flynn gdrive-auth --manual'); + process.exit(1); + } + + try { + const token = await exchangeCodeForTokens( + callbackResult.code, + creds.client_id, + creds.client_secret, + redirectUri, + ); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } finally { + callbackResult.server.close(); + } + } + + console.log('Google Drive authentication complete!'); + }); +} diff --git a/src/cli/gtasks-auth.ts b/src/cli/gtasks-auth.ts new file mode 100644 index 0000000..2156aaa --- /dev/null +++ b/src/cli/gtasks-auth.ts @@ -0,0 +1,245 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { loadConfigSafe } from './shared.js'; + +const SCOPES = ['https://www.googleapis.com/auth/tasks.readonly']; +const REDIRECT_PORT = 3000; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; + +/** Expand ~ to the user's home directory. */ +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +/** Read and parse the OAuth2 credentials file. */ +function readCredentials(credentialsPath: string): { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} { + if (!existsSync(credentialsPath)) { + throw new Error(`Credentials file not found: ${credentialsPath}`); + } + + const credentials = JSON.parse(readFileSync(credentialsPath, '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'); + } + + return { client_id, client_secret, redirect_uris }; +} + +/** Generate the OAuth2 authorization URL. */ +function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +/** Exchange authorization code for tokens using Google's token endpoint. */ +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Token exchange failed (${response.status}): ${body}`); + } + + return response.json() as Promise>; +} + +/** Save token to disk with restrictive permissions (0o600). */ +function saveToken(tokenPath: string, token: unknown): void { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8'); + try { + chmodSync(tokenPath, 0o600); + } catch { + // chmod may fail on some filesystems — not critical + } +} + +/** Start a temporary HTTP server to receive the OAuth callback. */ +function waitForCallback(port: number): Promise<{ code: string; server: Server }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authorization failed

${error}

`); + reject(new Error(`OAuth error: ${error}`)); + server.close(); + return; + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); + resolve({ code, server }); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + }); + + server.listen(port, () => {}); + server.on('error', reject); + }); +} + +/** Try to open a URL in the user's browser. */ +async function openBrowser(url: string): Promise { + const { exec } = await import('child_process'); + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + return new Promise((resolve) => { + exec(`${command} ${JSON.stringify(url)}`, (error) => { + resolve(!error); + }); + }); +} + +/** Manual code entry via stdin. */ +async function promptForCode(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export function registerGtasksAuthCommand(program: Command): void { + program + .command('gtasks-auth') + .description('Authenticate with Google Tasks via OAuth2') + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { config?: string; manual?: boolean }) => { + // 1. Load config + const { config, error } = loadConfigSafe(opts.config); + if (error || !config) { + console.error(`Error: ${error ?? 'Could not load config'}`); + process.exit(1); + } + + const gtasksConfig = config.automation.gtasks; + if (!gtasksConfig) { + console.error('Error: automation.gtasks is not configured in config.yaml'); + process.exit(1); + } + + // 2. Read credentials + const credentialsPath = expandPath(gtasksConfig.credentials_file ?? '~/.config/flynn/gtasks-credentials.json'); + let creds: ReturnType; + try { + creds = readCredentials(credentialsPath); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const tokenPath = expandPath(gtasksConfig.token_file ?? '~/.config/flynn/gtasks-token.json'); + + // 3. Check if already authenticated + if (existsSync(tokenPath)) { + console.log(`Token already exists at ${tokenPath}`); + console.log('Delete it first if you want to re-authenticate.'); + process.exit(0); + } + + const redirectUri = opts.manual + ? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob') + : REDIRECT_URI; + + // 4. Generate auth URL + const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri); + + if (opts.manual) { + // Manual flow + console.log('\nOpen this URL in your browser:\n'); + console.log(authUrl); + console.log(''); + const code = await promptForCode(); + const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } else { + // Local server flow + console.log('Starting local server for OAuth callback...'); + + let callbackResult: { code: string; server: Server }; + try { + const callbackPromise = waitForCallback(REDIRECT_PORT); + + const opened = await openBrowser(authUrl); + if (!opened) { + console.log('\nCould not open browser. Open this URL manually:\n'); + console.log(authUrl); + } else { + console.log('\nBrowser opened. Complete the authorization flow...'); + } + + console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`); + callbackResult = await callbackPromise; + } catch (err) { + console.error(`\nError: ${err instanceof Error ? err.message : err}`); + console.log('\nTry again with --manual flag: flynn gtasks-auth --manual'); + process.exit(1); + } + + try { + const token = await exchangeCodeForTokens( + callbackResult.code, + creds.client_id, + creds.client_secret, + redirectUri, + ); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } finally { + callbackResult.server.close(); + } + } + + console.log('Google Tasks authentication complete!'); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ed90261..1d2c3ec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,9 @@ import { registerCompletionCommand } from './completion.js'; import { registerSetupCommand } from './setup.js'; import { registerGmailAuthCommand } from './gmail-auth.js'; import { registerGcalAuthCommand } from './gcal-auth.js'; +import { registerGdocsAuthCommand } from './gdocs-auth.js'; +import { registerGdriveAuthCommand } from './gdrive-auth.js'; +import { registerGtasksAuthCommand } from './gtasks-auth.js'; export function createProgram(): Command { const program = new Command(); @@ -29,6 +32,9 @@ export function createProgram(): Command { registerSetupCommand(program); registerGmailAuthCommand(program); registerGcalAuthCommand(program); + registerGdocsAuthCommand(program); + registerGdriveAuthCommand(program); + registerGtasksAuthCommand(program); return program; } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 496974a..e02ffc2 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -7,6 +7,7 @@ import { getConfigPath } from './shared.js'; import { createPrompter } from './setup/prompts.js'; import { ConfigBuilder } from './setup/config.js'; import { runFirstRunWizard, runMenu } from './setup/orchestrator.js'; +import { runGoogleAuth } from './setup/automation.js'; export async function runSetup(configPath: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -20,10 +21,12 @@ export async function runSetup(configPath: string): Promise { const builder = ConfigBuilder.fromObject(parsed); await runMenu(p, builder); saveConfig(configPath, builder, p); + await runGoogleAuth(p, builder.build()); } else { // No config → first-run wizard const builder = await runFirstRunWizard(p); saveConfig(configPath, builder, p); + await runGoogleAuth(p, builder.build()); const shouldStart = await p.confirm('Start Flynn now?', true); if (shouldStart) { @@ -43,6 +46,7 @@ export async function runSetup(configPath: string): Promise { const menuBuilder = ConfigBuilder.fromObject(parsed); await runMenu(p, menuBuilder); saveConfig(configPath, menuBuilder, p); + await runGoogleAuth(p, menuBuilder.build()); } } } finally { diff --git a/src/cli/setup/automation.ts b/src/cli/setup/automation.ts index 80293c5..84709fd 100644 --- a/src/cli/setup/automation.ts +++ b/src/cli/setup/automation.ts @@ -1,6 +1,61 @@ import type { Prompter } from './prompts.js'; import type { ConfigBuilder } from './config.js'; +const GOOGLE_SETUP_INSTRUCTIONS = [ + ' To set up Google API access:', + ' 1. Go to https://console.cloud.google.com → create or select a project', + ' 2. Enable the required APIs (see below)', + ' 3. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID', + ' 4. Choose "Desktop app", download the JSON file', + ' 5. Save it as ~/.config/flynn/gmail-credentials.json (or a path of your choice)', +]; + +interface GoogleService { + name: string; + configKey: string; + apis: string[]; + authCmd: string; + setter: (builder: ConfigBuilder, creds: string) => void; +} + +const GOOGLE_SERVICES: GoogleService[] = [ + { + name: 'Gmail', + configKey: 'gmail', + apis: ['Gmail API'], + authCmd: 'gmail-auth', + setter: (b, c) => b.setGmailEnabled(c, 'webchat', 'gmail'), + }, + { + name: 'Google Calendar', + configKey: 'gcal', + apis: ['Google Calendar API'], + authCmd: 'gcal-auth', + setter: (b, c) => b.setGcalEnabled(c), + }, + { + name: 'Google Docs', + configKey: 'gdocs', + apis: ['Google Docs API', 'Google Drive API'], + authCmd: 'gdocs-auth', + setter: (b, c) => b.setGdocsEnabled(c), + }, + { + name: 'Google Drive', + configKey: 'gdrive', + apis: ['Google Drive API'], + authCmd: 'gdrive-auth', + setter: (b, c) => b.setGdriveEnabled(c), + }, + { + name: 'Google Tasks', + configKey: 'gtasks', + apis: ['Google Tasks API'], + authCmd: 'gtasks-auth', + setter: (b, c) => b.setGtasksEnabled(c), + }, +]; + export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Promise { const cron = await p.confirm('Enable cron scheduler?', false); if (cron) { @@ -17,17 +72,80 @@ export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Prom p.println('✓ Webhooks enabled — define triggers in config.yaml under automation.webhooks[]'); } - const gmail = await p.confirm('Enable Gmail watcher?', false); - if (gmail) { - p.println(' To set up Gmail access:'); - p.println(' 1. Go to https://console.cloud.google.com → create or select a project'); - p.println(' 2. Enable the Gmail API and Cloud Pub/Sub API'); - p.println(' 3. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID'); - p.println(' 4. Choose "Desktop app", download the JSON file'); - p.println(' 5. Save it as ~/.config/flynn/gmail-credentials.json'); - p.println(' On first run, Flynn will open a browser for OAuth consent and save the token.'); - const creds = await p.ask('OAuth credentials file', '~/.config/flynn/gmail-credentials.json'); - builder.setGmailEnabled(creds, 'webchat', 'gmail'); - p.println('✓ Gmail watcher enabled'); + // Google services + const wantGoogle = await p.confirm('Configure Google services (Gmail, Calendar, Docs, Drive, Tasks)?', false); + if (!wantGoogle) return; + + p.println(); + for (const line of GOOGLE_SETUP_INSTRUCTIONS) { + p.println(line); + } + p.println(); + + const creds = await p.ask('OAuth credentials file', '~/.config/flynn/gmail-credentials.json'); + p.println(); + + const enabledServices: GoogleService[] = []; + + for (const service of GOOGLE_SERVICES) { + const enable = await p.confirm(`Enable ${service.name}?`, false); + if (enable) { + service.setter(builder, creds); + const apis = service.apis.join(', '); + p.println(`✓ ${service.name} enabled (requires: ${apis})`); + enabledServices.push(service); + } + } + + if (enabledServices.length > 0) { + p.println(); + p.println('After saving config, authenticate each service:'); + for (const svc of enabledServices) { + p.println(` flynn ${svc.authCmd}`); + } + } +} + +/** + * Run OAuth auth flows for enabled Google services. + * Called after config is saved so the auth commands can read it. + */ +export async function runGoogleAuth(p: Prompter, config: Record): Promise { + const automation = config.automation as Record | undefined; + if (!automation) return; + + const pending: { name: string; authCmd: string }[] = []; + for (const svc of GOOGLE_SERVICES) { + const svcConfig = automation[svc.configKey] as { enabled?: boolean } | undefined; + if (svcConfig?.enabled) { + pending.push({ name: svc.name, authCmd: svc.authCmd }); + } + } + + if (pending.length === 0) return; + + p.println(); + const runAuth = await p.confirm(`Run OAuth authentication for ${pending.map(s => s.name).join(', ')}?`, true); + if (!runAuth) { + p.println('Skipped. Run these commands later:'); + for (const svc of pending) { + p.println(` flynn ${svc.authCmd}`); + } + return; + } + + const { execFileSync } = await import('child_process'); + + for (const svc of pending) { + p.println(); + p.println(`Authenticating ${svc.name}...`); + try { + execFileSync(process.execPath, [process.argv[1], svc.authCmd], { + stdio: 'inherit', + }); + p.println(`✓ ${svc.name} authenticated`); + } catch { + p.println(`✗ ${svc.name} auth failed — run "flynn ${svc.authCmd}" manually`); + } } } diff --git a/src/cli/setup/config.ts b/src/cli/setup/config.ts index 2a41a1c..bdf07c2 100644 --- a/src/cli/setup/config.ts +++ b/src/cli/setup/config.ts @@ -125,6 +125,30 @@ export class ConfigBuilder { this.config.automation = automation; } + setGcalEnabled(credentialsFile: string): void { + const automation = (this.config.automation ?? {}) as Record; + automation.gcal = { enabled: true, credentials_file: credentialsFile }; + this.config.automation = automation; + } + + setGdocsEnabled(credentialsFile: string): void { + const automation = (this.config.automation ?? {}) as Record; + automation.gdocs = { enabled: true, credentials_file: credentialsFile }; + this.config.automation = automation; + } + + setGdriveEnabled(credentialsFile: string): void { + const automation = (this.config.automation ?? {}) as Record; + automation.gdrive = { enabled: true, credentials_file: credentialsFile }; + this.config.automation = automation; + } + + setGtasksEnabled(credentialsFile: string): void { + const automation = (this.config.automation ?? {}) as Record; + automation.gtasks = { enabled: true, credentials_file: credentialsFile }; + this.config.automation = automation; + } + setCronEnabled(): void { const automation = (this.config.automation ?? {}) as Record; if (!automation.cron) automation.cron = []; diff --git a/src/cli/setup/summary.ts b/src/cli/setup/summary.ts index 3fb92c7..ef2d9a1 100644 --- a/src/cli/setup/summary.ts +++ b/src/cli/setup/summary.ts @@ -25,6 +25,10 @@ export function renderSummary(config: Record): string { if (auto.cron?.length > 0) autoFeatures.push(`${auto.cron.length} cron jobs`); if (auto.webhooks?.length > 0) autoFeatures.push('webhooks'); if (auto.gmail?.enabled) autoFeatures.push('gmail'); + if (auto.gcal?.enabled) autoFeatures.push('gcal'); + if (auto.gdocs?.enabled) autoFeatures.push('gdocs'); + if (auto.gdrive?.enabled) autoFeatures.push('gdrive'); + if (auto.gtasks?.enabled) autoFeatures.push('gtasks'); if (auto.heartbeat?.enabled) autoFeatures.push('heartbeat'); lines.push(` Automation: ${autoFeatures.join(', ') || 'none'}`); diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 50e1133..559daf5 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -82,7 +82,7 @@ export function registerTuiCommand(program: Command): void { setLogLevel(tuiLogLevel); const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { NativeAgent } = await import('../backends/index.js'); - const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, createGmailTools, createGcalTools } = await import('../tools/index.js'); + const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools } = await import('../tools/index.js'); const { HookEngine } = await import('../hooks/index.js'); const { createModelRouter } = await import('../daemon/index.js'); @@ -153,6 +153,27 @@ export function registerTuiCommand(program: Command): void { } } + // Register Google Docs tools if configured + if (config.automation.gdocs?.enabled) { + for (const tool of createGdocsTools(config.automation.gdocs)) { + toolRegistry.register(tool); + } + } + + // Register Google Drive tools if configured + if (config.automation.gdrive?.enabled) { + for (const tool of createGdriveTools(config.automation.gdrive)) { + toolRegistry.register(tool); + } + } + + // Register Google Tasks tools if configured + if (config.automation.gtasks?.enabled) { + for (const tool of createGtasksTools(config.automation.gtasks)) { + toolRegistry.register(tool); + } + } + const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); const session = sessionManager.getSession('tui', 'local'); diff --git a/src/config/schema.ts b/src/config/schema.ts index 1916c13..dd84bd6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -174,11 +174,32 @@ const gcalSchema = z.object({ calendar_ids: z.array(z.string()).default(['primary']), }).optional(); +const gdocsSchema = z.object({ + enabled: z.boolean().default(false), + credentials_file: z.string().optional(), + token_file: z.string().default('~/.config/flynn/gdocs-token.json'), +}).optional(); + +const gdriveSchema = z.object({ + enabled: z.boolean().default(false), + credentials_file: z.string().optional(), + token_file: z.string().default('~/.config/flynn/gdrive-token.json'), +}).optional(); + +const gtasksSchema = z.object({ + enabled: z.boolean().default(false), + credentials_file: z.string().optional(), + token_file: z.string().default('~/.config/flynn/gtasks-token.json'), +}).optional(); + const automationSchema = z.object({ cron: z.array(cronJobSchema).default([]), webhooks: z.array(webhookSchema).default([]), gmail: gmailSchema, gcal: gcalSchema, + gdocs: gdocsSchema, + gdrive: gdriveSchema, + gtasks: gtasksSchema, heartbeat: heartbeatSchema, }).default({}); @@ -417,5 +438,8 @@ export type HeartbeatCheck = z.infer; export type EmbeddingConfig = z.infer; export type EmbeddingProvider = z.infer; export type GcalConfig = z.infer; +export type GdocsConfig = z.infer; +export type GdriveConfig = z.infer; +export type GtasksConfig = z.infer; export type PairingCodeConfig = z.infer; export type LogLevel = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 9cdfeb4..aa5a04e 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -25,7 +25,7 @@ import { initSkills, initMcp, loadSystemPrompt, initPairingManager, createGatewa import type { ModelRouter } from '../models/index.js'; import { SessionStore, SessionManager, parseDuration } from '../session/index.js'; import { HookEngine } from '../hooks/index.js'; -import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools } from '../tools/index.js'; +import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools } from '../tools/index.js'; import { ChannelRegistry } from '../channels/index.js'; import type { McpManager } from '../mcp/index.js'; import type { SkillRegistry, SkillInstaller } from '../skills/index.js'; @@ -143,6 +143,15 @@ export async function startDaemon(config: Config): Promise { if (config.automation.gcal?.enabled) { for (const tool of createGcalTools(config.automation.gcal)) { toolRegistry.register(tool); } } + if (config.automation.gdocs?.enabled) { + for (const tool of createGdocsTools(config.automation.gdocs)) { toolRegistry.register(tool); } + } + if (config.automation.gdrive?.enabled) { + for (const tool of createGdriveTools(config.automation.gdrive)) { toolRegistry.register(tool); } + } + if (config.automation.gtasks?.enabled) { + for (const tool of createGtasksTools(config.automation.gtasks)) { toolRegistry.register(tool); } + } // ── Lifecycle ── await startServices({ config, lifecycle, channelRegistry, gateway, modelRouter, memoryDir, dataDir }); diff --git a/src/tools/builtin/gdocs.test.ts b/src/tools/builtin/gdocs.test.ts new file mode 100644 index 0000000..ae52e07 --- /dev/null +++ b/src/tools/builtin/gdocs.test.ts @@ -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('fs'); + return { + ...actual, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }; +}); + +import { createGdocsTools } from './gdocs.js'; + +// ── Test config ───────────────────────────────────────────────────────────── + +const testConfig: NonNullable = { + 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'); + }); +}); diff --git a/src/tools/builtin/gdocs.ts b/src/tools/builtin/gdocs.ts new file mode 100644 index 0000000..349381d --- /dev/null +++ b/src/tools/builtin/gdocs.ts @@ -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): 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): 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 => { + 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 => { + 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 => { + 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]; +} diff --git a/src/tools/builtin/gdrive.test.ts b/src/tools/builtin/gdrive.test.ts new file mode 100644 index 0000000..05b45f8 --- /dev/null +++ b/src/tools/builtin/gdrive.test.ts @@ -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('fs'); + return { + ...actual, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }; +}); + +import { createGdriveTools } from './gdrive.js'; + +// ── Test config ───────────────────────────────────────────────────────────── + +const testConfig: NonNullable = { + 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'); + }); +}); diff --git a/src/tools/builtin/gdrive.ts b/src/tools/builtin/gdrive.ts new file mode 100644 index 0000000..ca0f9bb --- /dev/null +++ b/src/tools/builtin/gdrive.ts @@ -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): 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 = { + '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 = { + '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): 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 => { + 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 => { + 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 => { + 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]; +} diff --git a/src/tools/builtin/gtasks.test.ts b/src/tools/builtin/gtasks.test.ts new file mode 100644 index 0000000..c417a6c --- /dev/null +++ b/src/tools/builtin/gtasks.test.ts @@ -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('fs'); + return { + ...actual, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }; +}); + +import { createGtasksTools } from './gtasks.js'; + +// ── Test config ───────────────────────────────────────────────────────────── + +const testConfig: NonNullable = { + 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 }), + ); + }); +}); diff --git a/src/tools/builtin/gtasks.ts b/src/tools/builtin/gtasks.ts new file mode 100644 index 0000000..209b211 --- /dev/null +++ b/src/tools/builtin/gtasks.ts @@ -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): 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): 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 => { + 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 => { + 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]; +} diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index 8658d28..daabe78 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -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'; diff --git a/src/tools/index.ts b/src/tools/index.ts index 274a74c..5a1ec7e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js'; export type { ToolExecutorConfig } from './executor.js'; export { ToolPolicy } from './policy.js'; export type { ToolPolicyContext } from './policy.js'; -export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools } from './builtin/index.js'; +export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools } from './builtin/index.js'; export type { WebSearchConfig } from './builtin/web-search.js'; export type { ProcessManagerConfig } from './builtin/process/index.js'; export type { BrowserManagerConfig } from './builtin/browser/index.js'; diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 8ed8e46..80ab2cc 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -26,6 +26,14 @@ const PROFILE_TOOLS: Record> = { 'calendar.today', 'calendar.list', 'calendar.search', + 'docs.list', + 'docs.search', + 'docs.read', + 'drive.list', + 'drive.search', + 'drive.read', + 'tasks.lists', + 'tasks.list', ]), coding: new Set([ 'file.read', @@ -42,6 +50,14 @@ const PROFILE_TOOLS: Record> = { 'calendar.today', 'calendar.list', 'calendar.search', + 'docs.list', + 'docs.search', + 'docs.read', + 'drive.list', + 'drive.search', + 'drive.read', + 'tasks.lists', + 'tasks.list', 'file.write', 'file.edit', 'file.patch', @@ -71,6 +87,9 @@ export const TOOL_GROUPS: Record = { 'group:memory': ['memory.read', 'memory.write', 'memory.search'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'], + 'group:gdocs': ['docs.list', 'docs.search', 'docs.read'], + 'group:gdrive': ['drive.list', 'drive.search', 'drive.read'], + 'group:gtasks': ['tasks.lists', 'tasks.list'], }; /** Expand group references in a list of tool names/patterns. */