feat(tools): add Google Docs, Drive, and Tasks read-only tools

Add three new Google service integrations following the established
Gmail/GCal pattern:

- Google Docs (docs.list, docs.search, docs.read): list, search, and
  read document content as plain text via Docs + Drive APIs
- Google Drive (drive.list, drive.search, drive.read): list, search,
  and read files with export support for Workspace files (Docs→text,
  Sheets→CSV, Slides→text)
- Google Tasks (tasks.lists, tasks.list): list task lists and tasks
  with status, due dates, and notes

Each service has its own config section, OAuth auth command, tool
policy group, and test suite (53 new tests). The setup wizard now
offers to configure all Google services together and run OAuth auth
flows automatically after saving config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 12:59:15 -08:00
parent 411c6d84a2
commit f204ff1dd7
20 changed files with 2844 additions and 15 deletions
+248
View File
@@ -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!');
});
}
+245
View File
@@ -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<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 registerGdriveAuthCommand(program: Command): void {
program
.command('gdrive-auth')
.description('Authenticate with Google Drive 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 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<typeof readCredentials>;
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!');
});
}
+245
View File
@@ -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<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 registerGtasksAuthCommand(program: Command): void {
program
.command('gtasks-auth')
.description('Authenticate with Google Tasks 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 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<typeof readCredentials>;
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!');
});
}
+6
View File
@@ -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;
}
+4
View File
@@ -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<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -20,10 +21,12 @@ export async function runSetup(configPath: string): Promise<void> {
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<void> {
const menuBuilder = ConfigBuilder.fromObject(parsed);
await runMenu(p, menuBuilder);
saveConfig(configPath, menuBuilder, p);
await runGoogleAuth(p, menuBuilder.build());
}
}
} finally {
+130 -12
View File
@@ -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<void> {
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<string, any>): Promise<void> {
const automation = config.automation as Record<string, any> | 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`);
}
}
}
+24
View File
@@ -125,6 +125,30 @@ export class ConfigBuilder {
this.config.automation = automation;
}
setGcalEnabled(credentialsFile: string): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
automation.gcal = { enabled: true, credentials_file: credentialsFile };
this.config.automation = automation;
}
setGdocsEnabled(credentialsFile: string): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
automation.gdocs = { enabled: true, credentials_file: credentialsFile };
this.config.automation = automation;
}
setGdriveEnabled(credentialsFile: string): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
automation.gdrive = { enabled: true, credentials_file: credentialsFile };
this.config.automation = automation;
}
setGtasksEnabled(credentialsFile: string): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
automation.gtasks = { enabled: true, credentials_file: credentialsFile };
this.config.automation = automation;
}
setCronEnabled(): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
if (!automation.cron) automation.cron = [];
+4
View File
@@ -25,6 +25,10 @@ export function renderSummary(config: Record<string, any>): 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'}`);
+22 -1
View File
@@ -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');