feat(tools): add Google Docs, Drive, and Tasks read-only tools
Add three new Google service integrations following the established Gmail/GCal pattern: - Google Docs (docs.list, docs.search, docs.read): list, search, and read document content as plain text via Docs + Drive APIs - Google Drive (drive.list, drive.search, drive.read): list, search, and read files with export support for Workspace files (Docs→text, Sheets→CSV, Slides→text) - Google Tasks (tasks.lists, tasks.list): list task lists and tasks with status, due dates, and notes Each service has its own config section, OAuth auth command, tool policy group, and test suite (53 new tests). The setup wizard now offers to configure all Google services together and run OAuth auth flows automatically after saving config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** 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(`<h1>Authorization failed</h1><p>${error}</p>`);
|
||||
reject(new Error(`OAuth error: ${error}`));
|
||||
server.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authorization successful!</h1><p>You can close this tab and return to the terminal.</p>');
|
||||
resolve({ code, server });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Missing authorization code</h1>');
|
||||
});
|
||||
|
||||
server.listen(port, () => {});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Try to open a URL in the user's browser. */
|
||||
async function openBrowser(url: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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 <path>', '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<typeof readCredentials>;
|
||||
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!');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user