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. */