Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:35:59 +08:00
commit 90883a4d25
287 changed files with 75058 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
---
name: google-workspace
description: Unified Google Workspace integration for managing email, calendar, files, and communication across multiple accounts
triggers:
# Gmail
- "check email"
- "read email"
- "send email"
- "search email"
- "list emails"
- "unread emails"
- "inbox"
# Calendar
- "check calendar"
- "schedule meeting"
- "create event"
- "what's on my calendar"
- "free time"
- "upcoming meetings"
# Drive
- "find file"
- "search drive"
- "list documents"
- "open document"
- "create document"
# Docs/Sheets/Slides
- "create doc"
- "create spreadsheet"
- "create presentation"
- "edit document"
# Tasks
- "google tasks"
- "task list"
# Chat
- "send chat"
- "check chat"
allowed-tools: Read, Bash
version: 0.1.0
---
# Google Workspace Skill
## Overview
Unified Google Workspace integration for managing email, calendar, files, and communication across three accounts:
| Alias | Purpose | Email |
|-------|---------|-------|
| `psd` | Work | PSD district email |
| `kh` | Personal | Personal Gmail |
| `hrg` | Business | Consulting & real estate |
## Account Selection
### Explicit
- "check my **psd** email"
- "send email from **hrg**"
- "**kh** calendar for tomorrow"
### Inferred
Geoffrey will infer the appropriate account from context:
- Work-related → `psd`
- Personal matters → `kh`
- Business/real estate → `hrg`
## Available Operations
### Gmail
| Script | Description | Example |
|--------|-------------|---------|
| `list_messages.js` | List inbox, unread, by label | "show unread psd emails" |
| `read_message.js` | Get full message content | "read that email" |
| `send_message.js` | Compose and send | "send email to John about..." |
| `search_messages.js` | Search with Gmail operators | "find emails from Sarah last week" |
### Calendar
| Script | Description | Example |
|--------|-------------|---------|
| `list_events.js` | Get upcoming events | "what's on my calendar today" |
| `create_event.js` | Schedule new events | "schedule meeting tomorrow at 2pm" |
| `update_event.js` | Modify existing events | "move that meeting to 3pm" |
| `search_events.js` | Find by criteria | "find meetings with Mike" |
### Drive
| Script | Description | Example |
|--------|-------------|---------|
| `list_files.js` | Browse/search files | "find budget spreadsheet" |
| `read_file.js` | Get file content | "show me that document" |
| `create_file.js` | Create new docs/sheets | "create a new spreadsheet" |
| `upload_file.js` | Upload local file | "upload this to drive" |
### Tasks
| Script | Description | Example |
|--------|-------------|---------|
| `list_tasks.js` | Get task lists | "show my google tasks" |
| `create_task.js` | Add new task | "add task to google tasks" |
| `complete_task.js` | Mark done | "complete that task" |
### Chat
| Script | Description | Example |
|--------|-------------|---------|
| `list_spaces.js` | Get available spaces | "list chat spaces" |
| `send_message.js` | Post to space | "send message to team chat" |
| `read_messages.js` | Get chat history | "show recent chat messages" |
## Usage Patterns
### Running Scripts
All scripts use the token_manager for authentication:
```javascript
const { getAuthClient } = require('./auth/token_manager');
async function main() {
const account = process.argv[2] || 'psd';
const auth = await getAuthClient(account);
// Use auth with Google API
const gmail = google.gmail({ version: 'v1', auth });
// ...
}
```
### Output Format
All scripts return JSON:
```json
{
"success": true,
"account": "psd",
"data": { ... },
"metadata": {
"timestamp": "2024-01-15T10:30:00Z",
"count": 5
}
}
```
### Error Handling
```json
{
"error": "Token expired",
"account": "psd",
"action": "Run: node token_manager.js refresh psd"
}
```
## Setup Required
Before using this skill:
1. Complete Google Cloud Console setup (see `auth/GOOGLE_CLOUD_SETUP.md`)
2. Add credentials to `~/Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env`
3. Authenticate all three accounts
4. For PSD account: allowlist OAuth app in Google Admin
## Cross-Account Operations
Some operations work across accounts:
- "Forward this to my personal email"
- "Copy this file to my work drive"
- "Add to both calendars"
## Gmail Search Operators
Support standard Gmail search:
- `from:` - sender
- `to:` - recipient
- `subject:` - subject line
- `has:attachment` - with attachments
- `after:` / `before:` - date range
- `is:unread` - unread only
- `label:` - by label
Example: "search psd email for `from:boss@psd.org after:2024-01-01 has:attachment`"
## Notes
- Access tokens expire after 1 hour (auto-refreshed)
- Refresh tokens don't expire unless revoked
- All API calls are rate-limited by Google
- Keep API has limited availability (may not be enabled)

View File

@@ -0,0 +1,180 @@
# Google Cloud Console Setup Guide
## Step 1: Create Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Sign in with your **consulting account** (this will own the OAuth app)
3. Click **Select a project****New Project**
4. Name: `Geoffrey Google Workspace`
5. Click **Create**
## Step 2: Enable APIs
Navigate to **APIs & Services → Library** and enable each:
### Required APIs
- [ ] Gmail API
- [ ] Google Calendar API
- [ ] Google Drive API
- [ ] Google Docs API
- [ ] Google Sheets API
- [ ] Google Slides API
- [ ] Google Forms API
- [ ] Google Chat API
- [ ] Tasks API
- [ ] People API (for user info)
### Optional APIs
- [ ] Google Keep API (limited availability)
- [ ] Gemini API (if using AI features)
**Tip:** Search for each API name and click **Enable**
## Step 3: Configure OAuth Consent Screen
1. Go to **APIs & Services → OAuth consent screen**
2. Select **External** (unless all accounts are in same org)
3. Click **Create**
### App Information
- App name: `Geoffrey`
- User support email: Your consulting email
- Developer contact: Your consulting email
### Scopes
Click **Add or Remove Scopes** and add:
```
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/drive
https://www.googleapis.com/auth/documents
https://www.googleapis.com/auth/spreadsheets
https://www.googleapis.com/auth/presentations
https://www.googleapis.com/auth/forms.body
https://www.googleapis.com/auth/chat.messages
https://www.googleapis.com/auth/tasks
https://www.googleapis.com/auth/userinfo.email
```
### Test Users
Add all three email addresses:
- Your PSD email
- Your personal email
- Your consulting email
**Note:** While in "Testing" mode, only these users can authorize.
## Step 4: Create OAuth Credentials
1. Go to **APIs & Services → Credentials**
2. Click **Create Credentials → OAuth client ID**
3. Application type: **Desktop app**
4. Name: `Geoffrey CLI`
5. Click **Create**
6. Copy the **Client ID** and **Client Secret**
7. Add to your iCloud secrets `.env` file:
```
~/Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env
```
Add these lines:
```
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
```
## Step 5: PSD Domain Allowlisting
Your PSD Google Workspace likely restricts third-party apps. To allow Geoffrey:
### If You're a Google Admin:
1. Go to [Google Admin Console](https://admin.google.com)
2. Navigate to **Security → Access and data control → API controls**
3. Click **Manage Third-Party App Access**
4. Click **Add app → OAuth App Name Or Client ID**
5. Enter your OAuth Client ID (from Step 4)
6. Select **Trusted** access
### If You Need IT Approval:
Send this to your IT team:
```
Subject: Request to Allow OAuth App for Personal Productivity Tool
I need to allowlist a personal productivity app that integrates with Google Workspace.
OAuth Client ID: [YOUR_CLIENT_ID_HERE]
Requested scopes:
- Gmail (read/send)
- Calendar (read/write)
- Drive (read/write)
- Docs/Sheets/Slides (read/write)
- Tasks (read/write)
This is a local CLI tool that runs only on my machine.
No data is sent to external servers.
Please add this client ID to the trusted apps list.
```
## Step 6: Authenticate Each Account
Once credentials are in your .env:
```bash
cd skills/google-workspace
# Install dependencies
bun install
# Authenticate each account
bun auth/oauth_setup.js psd # Will open browser, sign in with PSD account
bun auth/oauth_setup.js kh # Will open browser, sign in with personal account
bun auth/oauth_setup.js hrg # Will open browser, sign in with consulting account
# After each auth, store the tokens (copy the JSON output from oauth_setup)
bun auth/token_manager.js store psd '<tokens-json-output>'
bun auth/token_manager.js store kh '<tokens-json-output>'
bun auth/token_manager.js store hrg '<tokens-json-output>'
```
## Step 7: Verify Setup
```bash
# List stored accounts
bun auth/token_manager.js list
# Test token retrieval
bun auth/token_manager.js get psd
```
## Troubleshooting
### "Access blocked: This app's request is invalid"
- Check that redirect URI matches: `http://localhost:3000/oauth2callback`
- Verify OAuth consent screen is configured
### "Access denied" for PSD account
- App needs to be allowlisted in PSD Google Admin
- Contact IT with the client ID
### "Refresh token is null"
- Delete the app from your Google account's connected apps
- Re-run oauth_setup.js with the account
- The `prompt: 'consent'` should force a new refresh token
### Token expires quickly
- Access tokens last 1 hour
- token_manager.js auto-refreshes using the refresh token
- Refresh tokens don't expire unless revoked
## Security Notes
- Credentials stored in iCloud secrets `.env` (synced, but local to your devices)
- Tokens stored in macOS Keychain (encrypted)
- Each account has its own isolated tokens
- Revoke access anytime from Google Account → Security → Third-party apps

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* OAuth2 Setup Script for Google Workspace
*
* Usage: node oauth_setup.js <account-alias>
* Example: node oauth_setup.js psd
*
* This script:
* 1. Starts a local server to receive OAuth callback
* 2. Opens browser to Google consent page
* 3. Exchanges auth code for tokens
* 4. Outputs tokens as JSON (to be stored by token_manager.js)
*
* Prerequisites:
* - .env file with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
* - npm install googleapis open dotenv
*/
const { google } = require('googleapis');
const http = require('http');
const url = require('url');
const openModule = require('open');
const open = openModule.default || openModule;
const path = require('path');
// Load environment variables from iCloud secrets
const os = require('os');
const ENV_PATH = path.join(
os.homedir(),
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
);
require('dotenv').config({ path: ENV_PATH });
// Configuration
const REDIRECT_PORT = process.env.OAUTH_REDIRECT_PORT || 3000;
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/oauth2callback`;
// Full scope list for complete Google Workspace access
const SCOPES = [
// Gmail
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
// Calendar
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/calendar.events',
// Drive
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.file',
// Docs, Sheets, Slides
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/presentations',
// Forms
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
// Chat
'https://www.googleapis.com/auth/chat.spaces',
'https://www.googleapis.com/auth/chat.messages',
'https://www.googleapis.com/auth/chat.memberships.readonly',
// Tasks
'https://www.googleapis.com/auth/tasks',
// Keep (Note: Keep API has limited availability)
// 'https://www.googleapis.com/auth/keep',
// User info (to identify which account)
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
// Admin Directory (to look up user names)
'https://www.googleapis.com/auth/admin.directory.user.readonly',
];
async function main() {
const accountAlias = process.argv[2];
if (!accountAlias) {
console.error(JSON.stringify({
error: 'Missing account alias',
usage: 'node oauth_setup.js <account-alias>',
example: 'node oauth_setup.js psd'
}));
process.exit(1);
}
// Load client credentials from environment
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
console.error(JSON.stringify({
error: 'Missing credentials in .env',
instructions: [
'1. Go to Google Cloud Console',
'2. Create OAuth 2.0 Client ID (Desktop app)',
'3. Copy client ID and secret to skills/google-workspace/.env',
'4. See .env.example for format'
]
}));
process.exit(1);
}
// Create OAuth2 client
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
REDIRECT_URI
);
// Generate auth URL
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // Force to get refresh token
});
// Start local server to receive callback
const server = http.createServer(async (req, res) => {
try {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/oauth2callback') {
const code = parsedUrl.query.code;
if (!code) {
res.writeHead(400);
res.end('Missing authorization code');
return;
}
// Exchange code for tokens
const { tokens } = await oauth2Client.getToken(code);
// Get user info to confirm which account
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const userInfo = await oauth2.userinfo.get();
// Send success response to browser
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body style="font-family: sans-serif; padding: 40px; text-align: center;">
<h1>✅ Authorization Successful</h1>
<p>Account: <strong>${userInfo.data.email}</strong></p>
<p>Alias: <strong>${accountAlias}</strong></p>
<p>You can close this window.</p>
</body>
</html>
`);
// Output tokens as JSON
console.log(JSON.stringify({
success: true,
account: accountAlias,
email: userInfo.data.email,
tokens: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expiry_date: tokens.expiry_date,
}
}, null, 2));
// Close server
server.close();
}
} catch (error) {
res.writeHead(500);
res.end('Authorization failed');
console.error(JSON.stringify({
error: 'Token exchange failed',
message: error.message
}));
server.close();
process.exit(1);
}
});
server.listen(REDIRECT_PORT, async () => {
console.error(`Opening browser for ${accountAlias} account authorization...`);
console.error(`If browser doesn't open, visit: ${authUrl}`);
await open(authUrl);
});
// Timeout after 5 minutes
setTimeout(() => {
console.error(JSON.stringify({
error: 'Authorization timeout',
message: 'No response received within 5 minutes'
}));
server.close();
process.exit(1);
}, 5 * 60 * 1000);
}
main().catch(error => {
console.error(JSON.stringify({
error: 'Setup failed',
message: error.message
}));
process.exit(1);
});

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
/**
* Token Manager for Google Workspace
*
* Stores and retrieves OAuth tokens from macOS Keychain.
*
* Usage:
* Store: node token_manager.js store <account> '<tokens-json>'
* Retrieve: node token_manager.js get <account>
* Refresh: node token_manager.js refresh <account>
* List: node token_manager.js list
* Delete: node token_manager.js delete <account>
*
* Examples:
* node token_manager.js store psd '{"access_token":"...","refresh_token":"..."}'
* node token_manager.js get psd
* node token_manager.js refresh psd
*/
const { execSync } = require('child_process');
const { google } = require('googleapis');
const path = require('path');
// Load environment variables from iCloud secrets
const os = require('os');
const ENV_PATH = path.join(
os.homedir(),
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
);
require('dotenv').config({ path: ENV_PATH });
const SERVICE_NAME = 'geoffrey-google-workspace';
/**
* Store tokens in Keychain
*/
function storeTokens(account, tokens) {
const tokenString = typeof tokens === 'string' ? tokens : JSON.stringify(tokens);
// Delete existing entry if present
try {
execSync(
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}" 2>/dev/null`,
{ encoding: 'utf8' }
);
} catch (e) {
// Entry might not exist, that's fine
}
// Add new entry
try {
execSync(
`security add-generic-password -s "${SERVICE_NAME}" -a "${account}" -w '${tokenString.replace(/'/g, "'\\''")}' -U`,
{ encoding: 'utf8' }
);
return { success: true, account, message: 'Tokens stored in Keychain' };
} catch (error) {
throw new Error(`Failed to store tokens: ${error.message}`);
}
}
/**
* Retrieve tokens from Keychain
*/
function getTokens(account) {
try {
const result = execSync(
`security find-generic-password -s "${SERVICE_NAME}" -a "${account}" -w`,
{ encoding: 'utf8' }
).trim();
return JSON.parse(result);
} catch (error) {
throw new Error(`No tokens found for account: ${account}`);
}
}
/**
* Refresh access token using refresh token
*/
async function refreshTokens(account) {
// Get current tokens
const tokens = getTokens(account);
if (!tokens.refresh_token) {
throw new Error('No refresh token available');
}
// Load credentials from environment
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
}
// Create OAuth2 client and refresh
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
oauth2Client.setCredentials(tokens);
const { credentials: newTokens } = await oauth2Client.refreshAccessToken();
// Store updated tokens (keep refresh_token if not returned)
const updatedTokens = {
...tokens,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date,
};
storeTokens(account, updatedTokens);
return {
success: true,
account,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date
};
}
/**
* List all stored accounts
*/
function listAccounts() {
try {
const result = execSync(
`security dump-keychain | grep -A 4 '"svce"<blob>="${SERVICE_NAME}"' | grep '"acct"' | sed 's/.*="\\(.*\\)"/\\1/'`,
{ encoding: 'utf8' }
);
const accounts = result.trim().split('\n').filter(Boolean);
return { accounts };
} catch (error) {
return { accounts: [] };
}
}
/**
* Delete tokens for an account
*/
function deleteTokens(account) {
try {
execSync(
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}"`,
{ encoding: 'utf8' }
);
return { success: true, account, message: 'Tokens deleted' };
} catch (error) {
throw new Error(`Failed to delete tokens for ${account}: ${error.message}`);
}
}
/**
* Get an authenticated OAuth2 client for an account
*/
async function getAuthClient(account) {
const tokens = getTokens(account);
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
}
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
oauth2Client.setCredentials(tokens);
// Check if token needs refresh (expires in next 5 minutes)
if (tokens.expiry_date && tokens.expiry_date < Date.now() + 5 * 60 * 1000) {
const refreshed = await refreshTokens(account);
oauth2Client.setCredentials({
...tokens,
access_token: refreshed.access_token
});
}
return oauth2Client;
}
// CLI interface
async function main() {
const [command, account, data] = process.argv.slice(2);
try {
let result;
switch (command) {
case 'store':
if (!account || !data) {
throw new Error('Usage: token_manager.js store <account> <tokens-json>');
}
result = storeTokens(account, JSON.parse(data));
break;
case 'get':
if (!account) {
throw new Error('Usage: token_manager.js get <account>');
}
result = getTokens(account);
break;
case 'refresh':
if (!account) {
throw new Error('Usage: token_manager.js refresh <account>');
}
result = await refreshTokens(account);
break;
case 'list':
result = listAccounts();
break;
case 'delete':
if (!account) {
throw new Error('Usage: token_manager.js delete <account>');
}
result = deleteTokens(account);
break;
default:
throw new Error(`Unknown command: ${command}\nCommands: store, get, refresh, list, delete`);
}
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({ error: error.message }));
process.exit(1);
}
}
// Only run CLI if called directly
if (require.main === module) {
main();
}
// Export for use by other scripts
module.exports = { getAuthClient, getTokens, refreshTokens, storeTokens };

View File

@@ -0,0 +1,182 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "geoffrey-google-workspace",
"dependencies": {
"dotenv": "^16.3.1",
"googleapis": "^128.0.0",
"open": "^9.1.0",
},
},
},
"packages": {
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bplist-parser": ["bplist-parser@0.2.0", "", { "dependencies": { "big-integer": "^1.6.44" } }, "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bundle-name": ["bundle-name@3.0.0", "", { "dependencies": { "run-applescript": "^5.0.0" } }, "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"default-browser": ["default-browser@4.0.0", "", { "dependencies": { "bundle-name": "^3.0.0", "default-browser-id": "^3.0.0", "execa": "^7.1.1", "titleize": "^3.0.0" } }, "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA=="],
"default-browser-id": ["default-browser-id@3.0.0", "", { "dependencies": { "bplist-parser": "^0.2.0", "untildify": "^4.0.0" } }, "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA=="],
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
"gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
"google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
"googleapis": ["googleapis@128.0.0", "", { "dependencies": { "google-auth-library": "^9.0.0", "googleapis-common": "^7.0.0" } }, "sha512-+sLtVYNazcxaSD84N6rihVX4QiGoqRdnlz2SwmQQkadF31XonDfy4ufk3maMg27+FiySrH0rd7V8p+YJG6cknA=="],
"googleapis-common": ["googleapis-common@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^6.0.3", "google-auth-library": "^9.7.0", "qs": "^6.7.0", "url-template": "^2.0.8", "uuid": "^9.0.0" } }, "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@4.3.1", "", {}, "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"open": ["open@9.1.0", "", { "dependencies": { "default-browser": "^4.0.0", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^2.2.0" } }, "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"run-applescript": ["run-applescript@5.0.0", "", { "dependencies": { "execa": "^5.0.0" } }, "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"titleize": ["titleize@3.0.0", "", {}, "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="],
"url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"run-applescript/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"run-applescript/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"run-applescript/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"run-applescript/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"run-applescript/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"run-applescript/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
}
}

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
/**
* Create Calendar Event
*
* Usage: node create_event.js <account> --summary <title> --start <datetime> [options]
*
* Options:
* --summary Event title (required)
* --start Start datetime (required, ISO 8601 or natural)
* --end End datetime (default: 1 hour after start)
* --description Event description
* --location Event location
* --attendees Comma-separated email addresses
* --all-day Create all-day event (use date format for start/end)
*
* Examples:
* node create_event.js psd --summary "Team Meeting" --start "2024-01-15T14:00:00"
* node create_event.js personal --summary "Dinner" --start "2024-01-15T18:00:00" --end "2024-01-15T20:00:00" --location "Restaurant"
* node create_event.js consulting --summary "Client Call" --start "2024-01-15T10:00:00" --attendees "client@example.com"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function createEvent(account, options) {
const auth = await getAuthClient(account);
const calendar = google.calendar({ version: 'v3', auth });
// Parse start time
const startDate = new Date(options.start);
if (isNaN(startDate.getTime())) {
throw new Error(`Invalid start date: ${options.start}`);
}
// Calculate end time (default: 1 hour after start)
let endDate;
if (options.end) {
endDate = new Date(options.end);
if (isNaN(endDate.getTime())) {
throw new Error(`Invalid end date: ${options.end}`);
}
} else {
endDate = new Date(startDate);
endDate.setHours(endDate.getHours() + 1);
}
// Build event
const event = {
summary: options.summary,
description: options.description,
location: options.location,
};
// Handle all-day vs timed events
if (options.allDay) {
event.start = { date: startDate.toISOString().split('T')[0] };
event.end = { date: endDate.toISOString().split('T')[0] };
} else {
event.start = {
dateTime: startDate.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
event.end = {
dateTime: endDate.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}
// Add attendees
if (options.attendees) {
event.attendees = options.attendees.split(',').map(email => ({
email: email.trim(),
}));
}
const response = await calendar.events.insert({
calendarId: 'primary',
requestBody: event,
sendUpdates: options.attendees ? 'all' : 'none',
});
return {
success: true,
account,
event: {
id: response.data.id,
summary: response.data.summary,
start: response.data.start.dateTime || response.data.start.date,
end: response.data.end.dateTime || response.data.end.date,
htmlLink: response.data.htmlLink,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node create_event.js <account> --summary <title> --start <datetime> [options]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--summary':
options.summary = args[++i];
break;
case '--start':
options.start = args[++i];
break;
case '--end':
options.end = args[++i];
break;
case '--description':
options.description = args[++i];
break;
case '--location':
options.location = args[++i];
break;
case '--attendees':
options.attendees = args[++i];
break;
case '--all-day':
options.allDay = true;
break;
}
}
if (!options.summary || !options.start) {
console.error(JSON.stringify({
error: 'Missing required options: --summary, --start',
usage: 'node create_event.js <account> --summary <title> --start <datetime>'
}));
process.exit(1);
}
try {
const result = await createEvent(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createEvent };

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* List Calendar Events
*
* Usage: node list_events.js <account> [options]
*
* Options:
* --days Number of days to look ahead (default: 7)
* --today Show only today's events
* --max Maximum events to return (default: 50)
*
* Examples:
* node list_events.js psd
* node list_events.js psd --today
* node list_events.js personal --days 30
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function listEvents(account, options = {}) {
const auth = await getAuthClient(account);
const calendar = google.calendar({ version: 'v3', auth });
// Calculate time range
const now = new Date();
const timeMin = new Date(now);
timeMin.setHours(0, 0, 0, 0);
let timeMax;
if (options.today) {
timeMax = new Date(timeMin);
timeMax.setDate(timeMax.getDate() + 1);
} else {
timeMax = new Date(timeMin);
timeMax.setDate(timeMax.getDate() + (options.days || 7));
}
const response = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
maxResults: options.max || 50,
singleEvents: true,
orderBy: 'startTime',
});
const events = (response.data.items || []).map(event => ({
id: event.id,
summary: event.summary,
description: event.description,
location: event.location,
start: event.start.dateTime || event.start.date,
end: event.end.dateTime || event.end.date,
isAllDay: !event.start.dateTime,
status: event.status,
attendees: (event.attendees || []).map(a => ({
email: a.email,
name: a.displayName,
responseStatus: a.responseStatus,
})),
hangoutLink: event.hangoutLink,
htmlLink: event.htmlLink,
}));
return {
success: true,
account,
events,
metadata: {
timestamp: new Date().toISOString(),
count: events.length,
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node list_events.js <account> [--today] [--days N] [--max N]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--today':
options.today = true;
break;
case '--days':
options.days = parseInt(args[++i], 10);
break;
case '--max':
options.max = parseInt(args[++i], 10);
break;
}
}
try {
const result = await listEvents(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { listEvents };

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Search Calendar Events
*
* Usage: node search_events.js <account> <query> [options]
*
* Options:
* --days Number of days to search (default: 30)
* --max Maximum events to return (default: 50)
*
* Examples:
* node search_events.js psd "team meeting"
* node search_events.js personal "dinner" --days 60
* node search_events.js consulting "client"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function searchEvents(account, query, options = {}) {
const auth = await getAuthClient(account);
const calendar = google.calendar({ version: 'v3', auth });
// Calculate time range
const now = new Date();
const timeMin = new Date(now);
timeMin.setDate(timeMin.getDate() - (options.days || 30)); // Look back too
const timeMax = new Date(now);
timeMax.setDate(timeMax.getDate() + (options.days || 30));
const response = await calendar.events.list({
calendarId: 'primary',
q: query,
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
maxResults: options.max || 50,
singleEvents: true,
orderBy: 'startTime',
});
const events = (response.data.items || []).map(event => ({
id: event.id,
summary: event.summary,
description: event.description,
location: event.location,
start: event.start.dateTime || event.start.date,
end: event.end.dateTime || event.end.date,
isAllDay: !event.start.dateTime,
attendees: (event.attendees || []).map(a => ({
email: a.email,
name: a.displayName,
responseStatus: a.responseStatus,
})),
htmlLink: event.htmlLink,
}));
return {
success: true,
account,
query,
events,
metadata: {
timestamp: new Date().toISOString(),
count: events.length,
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node search_events.js <account> <query> [--days N] [--max N]'
}));
process.exit(1);
}
// Find query (first non-flag argument after account)
let query = '';
const options = {};
for (let i = 1; i < args.length; i++) {
if (args[i] === '--days') {
options.days = parseInt(args[++i], 10);
} else if (args[i] === '--max') {
options.max = parseInt(args[++i], 10);
} else if (!args[i].startsWith('--')) {
query = args[i];
}
}
if (!query) {
console.error(JSON.stringify({
error: 'Missing query',
usage: 'node search_events.js <account> <query>'
}));
process.exit(1);
}
try {
const result = await searchEvents(account, query, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
query,
}));
process.exit(1);
}
}
main();
module.exports = { searchEvents };

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* Update Calendar Event
*
* Usage: node update_event.js <account> <event-id> [options]
*
* Options:
* --summary New event title
* --start New start datetime
* --end New end datetime
* --description New description
* --location New location
*
* Examples:
* node update_event.js psd abc123 --start "2024-01-15T15:00:00"
* node update_event.js personal def456 --summary "Updated Meeting" --location "New Room"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function updateEvent(account, eventId, options) {
const auth = await getAuthClient(account);
const calendar = google.calendar({ version: 'v3', auth });
// Get existing event
const existing = await calendar.events.get({
calendarId: 'primary',
eventId,
});
const event = existing.data;
// Update fields
if (options.summary) event.summary = options.summary;
if (options.description) event.description = options.description;
if (options.location) event.location = options.location;
if (options.start) {
const startDate = new Date(options.start);
if (isNaN(startDate.getTime())) {
throw new Error(`Invalid start date: ${options.start}`);
}
event.start = {
dateTime: startDate.toISOString(),
timeZone: event.start.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}
if (options.end) {
const endDate = new Date(options.end);
if (isNaN(endDate.getTime())) {
throw new Error(`Invalid end date: ${options.end}`);
}
event.end = {
dateTime: endDate.toISOString(),
timeZone: event.end.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}
const response = await calendar.events.update({
calendarId: 'primary',
eventId,
requestBody: event,
sendUpdates: event.attendees ? 'all' : 'none',
});
return {
success: true,
account,
event: {
id: response.data.id,
summary: response.data.summary,
start: response.data.start.dateTime || response.data.start.date,
end: response.data.end.dateTime || response.data.end.date,
htmlLink: response.data.htmlLink,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const eventId = args[1];
if (!account || !eventId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node update_event.js <account> <event-id> [options]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--summary':
options.summary = args[++i];
break;
case '--start':
options.start = args[++i];
break;
case '--end':
options.end = args[++i];
break;
case '--description':
options.description = args[++i];
break;
case '--location':
options.location = args[++i];
break;
}
}
try {
const result = await updateEvent(account, eventId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
eventId,
}));
process.exit(1);
}
}
main();
module.exports = { updateEvent };

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* List Google Chat Spaces
*
* Usage: node list_spaces.js <account>
*
* Examples:
* node list_spaces.js psd
* node list_spaces.js kh
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function listSpaces(account) {
const auth = await getAuthClient(account);
const chat = google.chat({ version: 'v1', auth });
const response = await chat.spaces.list({
pageSize: 100,
});
// Get read state for each space to find unreads
const spacesWithState = await Promise.all(
(response.data.spaces || []).map(async space => {
try {
// Get space read state
const stateResponse = await chat.spaces.spaceReadState.get({
name: `${space.name}/spaceReadState`,
});
return {
name: space.name,
displayName: space.displayName,
type: space.type,
spaceType: space.spaceType,
lastReadTime: stateResponse.data.lastReadTime,
};
} catch (e) {
return {
name: space.name,
displayName: space.displayName,
type: space.type,
spaceType: space.spaceType,
lastReadTime: null,
};
}
})
);
const spaces = spacesWithState;
return {
success: true,
account,
spaces,
metadata: {
timestamp: new Date().toISOString(),
count: spaces.length,
}
};
}
// CLI interface
async function main() {
const account = process.argv[2];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node list_spaces.js <account>'
}));
process.exit(1);
}
try {
const result = await listSpaces(account);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { listSpaces };

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Read Google Chat Messages from a Space
*
* Usage: node read_messages.js <account> <space-name> [options]
*
* Options:
* --max Maximum messages to return (default: 50)
*
* Examples:
* node read_messages.js psd spaces/AAAA1234567
* node read_messages.js psd spaces/AAAA1234567 --max 100
*/
const { google } = require('googleapis');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
// Load user mapping from iCloud preferences
let allUserMappings = {};
const mappingPath = path.join(
os.homedir(),
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/knowledge/chat_user_mapping.json'
);
if (fs.existsSync(mappingPath)) {
try {
allUserMappings = JSON.parse(fs.readFileSync(mappingPath, 'utf8'));
} catch (e) {
// Ignore errors
}
}
async function readMessages(account, spaceName, options = {}) {
const auth = await getAuthClient(account);
const chat = google.chat({ version: 'v1', auth });
// Get space members to build userId -> displayName mapping
const membersResponse = await chat.spaces.members.list({
parent: spaceName,
pageSize: 100,
});
const userNameMap = {};
for (const membership of (membersResponse.data.memberships || [])) {
if (membership.member && membership.member.name) {
const userId = membership.member.name;
userNameMap[userId] = membership.member.displayName || userId;
}
}
const response = await chat.spaces.messages.list({
parent: spaceName,
pageSize: options.max || 50,
orderBy: 'createTime desc',
});
// Get account-specific user mapping
const userMapping = allUserMappings[account] || {};
const messages = (response.data.messages || []).map(msg => {
const senderId = msg.sender?.name;
const bareId = senderId?.replace('users/', '');
const senderName = userMapping[bareId] || userNameMap[senderId] || msg.sender?.displayName || senderId;
return {
name: msg.name,
sender: senderName,
senderId: bareId,
senderType: msg.sender?.type,
text: msg.text,
createTime: msg.createTime,
threadName: msg.thread?.name,
};
});
return {
success: true,
account,
spaceName,
messages,
metadata: {
timestamp: new Date().toISOString(),
count: messages.length,
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const spaceName = args[1];
if (!account || !spaceName) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_messages.js <account> <space-name> [--max N]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === '--max') {
options.max = parseInt(args[++i], 10);
}
}
try {
const result = await readMessages(account, spaceName, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
spaceName,
}));
process.exit(1);
}
}
main();
module.exports = { readMessages };

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Send Google Chat Message
*
* Usage: node send_message.js <account> <space-name> --text <message>
*
* Options:
* --text Message text (required)
* --thread Thread name to reply to (optional)
*
* Examples:
* node send_message.js psd spaces/AAAA1234567 --text "Hello team!"
* node send_message.js psd spaces/AAAA1234567 --text "Reply" --thread spaces/AAAA/threads/BBBB
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function sendMessage(account, spaceName, options) {
const auth = await getAuthClient(account);
const chat = google.chat({ version: 'v1', auth });
const requestBody = {
text: options.text,
};
if (options.thread) {
requestBody.thread = {
name: options.thread,
};
}
const response = await chat.spaces.messages.create({
parent: spaceName,
requestBody,
});
return {
success: true,
account,
message: {
name: response.data.name,
text: response.data.text,
createTime: response.data.createTime,
space: spaceName,
thread: response.data.thread?.name,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const spaceName = args[1];
if (!account || !spaceName) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node send_message.js <account> <space-name> --text <message>'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--text':
options.text = args[++i];
break;
case '--thread':
options.thread = args[++i];
break;
}
}
if (!options.text) {
console.error(JSON.stringify({
error: 'Missing --text option',
usage: 'node send_message.js <account> <space-name> --text <message>'
}));
process.exit(1);
}
try {
const result = await sendMessage(account, spaceName, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
spaceName,
}));
process.exit(1);
}
}
main();
module.exports = { sendMessage };

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* Create Google Doc with Markdown Formatting
*
* Usage: bun create_doc.js <account> --title <title> [options]
*
* Options:
* --title Document title (required)
* --content Markdown content
* --folder Subfolder in Geoffrey (Research, Notes, Reports, Travel)
* --raw Don't apply markdown formatting (plain text)
*
* Examples:
* bun create_doc.js hrg --title "Meeting Notes" --folder Notes
* bun create_doc.js psd --title "Report" --content "# Header\n\nContent" --folder Research
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const { ensureGeoffreyFolders } = require(path.join(__dirname, '..', 'utils', 'folder_manager'));
const { insertFormattedContent } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
async function createDoc(account, options) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
const drive = google.drive({ version: 'v3', auth });
// Get or create Geoffrey folder structure
let folderId = null;
if (options.folder) {
const folderResult = await ensureGeoffreyFolders(account, options.folder);
folderId = folderResult.targetFolder;
}
// Create the document
const createResponse = await docs.documents.create({
requestBody: {
title: options.title,
},
});
const documentId = createResponse.data.documentId;
// Move to Geoffrey folder if specified
if (folderId) {
// Get current parents
const file = await drive.files.get({
fileId: documentId,
fields: 'parents',
});
// Move to Geoffrey folder
await drive.files.update({
fileId: documentId,
addParents: folderId,
removeParents: file.data.parents.join(','),
fields: 'id, parents',
});
}
// Add content if provided
let formattingInfo = null;
if (options.content) {
if (options.raw) {
// Plain text insertion
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: 1 },
text: options.content,
},
}],
},
});
} else {
// Markdown formatted insertion
formattingInfo = await insertFormattedContent(docs, documentId, options.content);
}
}
return {
success: true,
account,
document: {
id: documentId,
title: createResponse.data.title,
url: `https://docs.google.com/document/d/${documentId}/edit`,
folder: options.folder || 'root',
},
formatting: formattingInfo,
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--title':
options.title = args[++i];
break;
case '--content':
options.content = args[++i];
break;
case '--folder':
options.folder = args[++i];
break;
case '--raw':
options.raw = true;
break;
}
}
if (!options.title) {
console.error(JSON.stringify({
error: 'Missing --title option',
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
}));
process.exit(1);
}
try {
const result = await createDoc(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createDoc };

View File

@@ -0,0 +1,282 @@
#!/usr/bin/env node
/**
* Edit Google Doc with Markdown Formatting
*
* Usage: bun edit_doc.js <account> <document-id> --append <markdown>
* bun edit_doc.js <account> <document-id> --replace <old> --with <new>
*
* Options:
* --raw Don't apply markdown formatting (plain text)
*
* Examples:
* bun edit_doc.js psd DOC_ID --append "## New Section\n\nContent here"
* bun edit_doc.js psd DOC_ID --append "Plain text" --raw
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const { parseMarkdown } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
/**
* Parse markdown with custom start index (for appending)
*/
function parseMarkdownAtIndex(markdown, startIndex) {
const requests = [];
let plainText = '';
let currentIndex = startIndex;
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Horizontal rule
if (line.match(/^---+$/)) {
const ruleLine = '─'.repeat(50) + '\n';
plainText += ruleLine;
currentIndex += ruleLine.length;
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const headerContent = headerMatch[2];
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(headerContent, currentIndex);
const fullLine = processedText + '\n';
const headingStyle = level === 1 ? 'HEADING_1' :
level === 2 ? 'HEADING_2' :
level === 3 ? 'HEADING_3' : 'HEADING_4';
requests.push({
updateParagraphStyle: {
range: { startIndex: currentIndex, endIndex: currentIndex + fullLine.length },
paragraphStyle: { namedStyleType: headingStyle },
fields: 'namedStyleType',
},
});
requests.push(...inlineRequests);
plainText += fullLine;
currentIndex += fullLine.length;
continue;
}
// Bullet lists
const bulletMatch = line.match(/^[-*]\s+(.+)$/);
if (bulletMatch) {
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(bulletMatch[1], currentIndex + 2);
const fullLine = '• ' + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Numbered lists
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/);
if (numberedMatch) {
const prefix = numberedMatch[1] + '. ';
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(numberedMatch[2], currentIndex + prefix.length);
const fullLine = prefix + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Regular line
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(line, currentIndex);
const fullLine = processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
}
return { text: plainText, requests };
}
function processInlineFormattingAtIndex(text, startIndex) {
const requests = [];
let plainText = '';
let charIndex = startIndex;
let remaining = text;
while (remaining.length > 0) {
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
if (boldMatch) {
plainText += boldMatch[1];
requests.push({
updateTextStyle: {
range: { startIndex: charIndex, endIndex: charIndex + boldMatch[1].length },
textStyle: { bold: true },
fields: 'bold',
},
});
charIndex += boldMatch[1].length;
remaining = remaining.substring(boldMatch[0].length);
continue;
}
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
plainText += linkMatch[1];
requests.push({
updateTextStyle: {
range: { startIndex: charIndex, endIndex: charIndex + linkMatch[1].length },
textStyle: { link: { url: linkMatch[2] } },
fields: 'link',
},
});
charIndex += linkMatch[1].length;
remaining = remaining.substring(linkMatch[0].length);
continue;
}
plainText += remaining[0];
charIndex++;
remaining = remaining.substring(1);
}
return { plainText, requests };
}
async function editDoc(account, documentId, options) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
if (options.append) {
// Get document to find end index
const doc = await docs.documents.get({ documentId });
const endIndex = doc.data.body.content[doc.data.body.content.length - 1].endIndex - 1;
if (options.raw) {
// Plain text append
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: endIndex },
text: '\n' + options.append,
},
}],
},
});
return {
success: true,
account,
documentId,
updates: 1,
metadata: { timestamp: new Date().toISOString() }
};
}
// Formatted append
const { text, requests } = parseMarkdownAtIndex(options.append, endIndex + 1);
// Insert text
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: endIndex },
text: '\n' + text,
},
}],
},
});
// Apply formatting
if (requests.length > 0) {
await docs.documents.batchUpdate({
documentId,
requestBody: { requests },
});
}
return {
success: true,
account,
documentId,
updates: 1 + requests.length,
formatting: { textLength: text.length, formattingRequests: requests.length },
metadata: { timestamp: new Date().toISOString() }
};
}
if (options.replace && options.with) {
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
replaceAllText: {
containsText: { text: options.replace, matchCase: true },
replaceText: options.with,
},
}],
},
});
return {
success: true,
account,
documentId,
updates: 1,
metadata: { timestamp: new Date().toISOString() }
};
}
return { success: false, error: 'No edit operation specified' };
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const documentId = args[1];
if (!account || !documentId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'bun edit_doc.js <account> <document-id> --append <markdown>'
}));
process.exit(1);
}
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--append': options.append = args[++i]; break;
case '--replace': options.replace = args[++i]; break;
case '--with': options.with = args[++i]; break;
case '--raw': options.raw = true; break;
}
}
try {
const result = await editDoc(account, documentId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
documentId,
}));
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { editDoc };

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Read Google Doc Content
*
* Usage: node read_doc.js <account> <document-id>
*
* Examples:
* node read_doc.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function readDoc(account, documentId) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
const response = await docs.documents.get({
documentId,
});
const doc = response.data;
// Extract text content from document body
let textContent = '';
if (doc.body && doc.body.content) {
for (const element of doc.body.content) {
if (element.paragraph && element.paragraph.elements) {
for (const textElement of element.paragraph.elements) {
if (textElement.textRun && textElement.textRun.content) {
textContent += textElement.textRun.content;
}
}
}
if (element.table) {
textContent += '[TABLE]\n';
}
}
}
return {
success: true,
account,
document: {
id: doc.documentId,
title: doc.title,
textContent: textContent.trim(),
revisionId: doc.revisionId,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const documentId = args[1];
if (!account || !documentId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_doc.js <account> <document-id>'
}));
process.exit(1);
}
try {
const result = await readDoc(account, documentId);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
documentId,
}));
process.exit(1);
}
}
main();
module.exports = { readDoc };

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* Create Drive File (Google Docs, Sheets, Slides)
*
* Usage: node create_file.js <account> --name <name> --type <type> [options]
*
* Options:
* --name File name (required)
* --type File type: doc, sheet, slide (required)
* --content Initial content (for docs)
* --folder Parent folder ID
*
* Examples:
* node create_file.js psd --name "Meeting Notes" --type doc
* node create_file.js personal --name "Budget 2024" --type sheet
* node create_file.js consulting --name "Proposal" --type slide --folder 1abc123
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const MIME_TYPES = {
doc: 'application/vnd.google-apps.document',
sheet: 'application/vnd.google-apps.spreadsheet',
slide: 'application/vnd.google-apps.presentation',
};
async function createFile(account, options) {
const auth = await getAuthClient(account);
const drive = google.drive({ version: 'v3', auth });
const mimeType = MIME_TYPES[options.type];
if (!mimeType) {
throw new Error(`Invalid type: ${options.type}. Use: doc, sheet, slide`);
}
const fileMetadata = {
name: options.name,
mimeType,
};
if (options.folder) {
fileMetadata.parents = [options.folder];
}
const response = await drive.files.create({
requestBody: fileMetadata,
fields: 'id, name, mimeType, webViewLink',
});
const file = response.data;
// If content provided for doc, update it
if (options.content && options.type === 'doc') {
const docs = google.docs({ version: 'v1', auth });
await docs.documents.batchUpdate({
documentId: file.id,
requestBody: {
requests: [{
insertText: {
location: { index: 1 },
text: options.content,
}
}]
}
});
}
return {
success: true,
account,
file: {
id: file.id,
name: file.name,
mimeType: file.mimeType,
webLink: file.webViewLink,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node create_file.js <account> --name <name> --type doc|sheet|slide [options]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--name':
options.name = args[++i];
break;
case '--type':
options.type = args[++i];
break;
case '--content':
options.content = args[++i];
break;
case '--folder':
options.folder = args[++i];
break;
}
}
if (!options.name || !options.type) {
console.error(JSON.stringify({
error: 'Missing required options: --name, --type',
usage: 'node create_file.js <account> --name <name> --type doc|sheet|slide'
}));
process.exit(1);
}
try {
const result = await createFile(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createFile };

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Download Drive File
*
* Downloads a file from Google Drive to local filesystem.
*
* Usage: bun download_file.js <account> <fileId> <outputPath>
*/
const { google } = require('googleapis');
const fs = require('fs');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function downloadFile(account, fileId, outputPath) {
const auth = await getAuthClient(account);
const drive = google.drive({ version: 'v3', auth });
// Get file metadata first
const fileInfo = await drive.files.get({
fileId,
fields: 'name, mimeType',
supportsAllDrives: true
});
// Download file content
const response = await drive.files.get(
{ fileId, alt: 'media', supportsAllDrives: true },
{ responseType: 'stream' }
);
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write to file
const dest = fs.createWriteStream(outputPath);
return new Promise((resolve, reject) => {
response.data
.on('end', () => {
resolve({
success: true,
account,
file: {
id: fileId,
name: fileInfo.data.name,
mimeType: fileInfo.data.mimeType,
outputPath
}
});
})
.on('error', err => {
reject(err);
})
.pipe(dest);
});
}
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const fileId = args[1];
const outputPath = args[2];
if (!account || !fileId || !outputPath) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'bun download_file.js <account> <fileId> <outputPath>'
}));
process.exit(1);
}
try {
const result = await downloadFile(account, fileId, outputPath);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
fileId
}));
process.exit(1);
}
}
main();
module.exports = { downloadFile };

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* List Drive Files
*
* Usage: node list_files.js <account> [options]
*
* Options:
* --query Search query (Drive search syntax)
* --type File type: doc, sheet, slide, pdf, folder
* --max Maximum files to return (default: 20)
* --folder Folder ID to list contents of
*
* Examples:
* node list_files.js psd
* node list_files.js psd --query "budget"
* node list_files.js personal --type sheet
* node list_files.js consulting --type pdf --query "contract"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const MIME_TYPES = {
doc: 'application/vnd.google-apps.document',
sheet: 'application/vnd.google-apps.spreadsheet',
slide: 'application/vnd.google-apps.presentation',
pdf: 'application/pdf',
folder: 'application/vnd.google-apps.folder',
};
async function listFiles(account, options = {}) {
const auth = await getAuthClient(account);
const drive = google.drive({ version: 'v3', auth });
// Build query
const queryParts = [];
if (options.query) {
queryParts.push(`fullText contains '${options.query}'`);
}
if (options.type && MIME_TYPES[options.type]) {
queryParts.push(`mimeType = '${MIME_TYPES[options.type]}'`);
}
if (options.folder) {
queryParts.push(`'${options.folder}' in parents`);
}
// Exclude trashed files
queryParts.push('trashed = false');
const response = await drive.files.list({
q: queryParts.join(' and '),
pageSize: options.max || 20,
fields: 'files(id, name, mimeType, size, createdTime, modifiedTime, webViewLink, parents)',
orderBy: 'modifiedTime desc',
supportsAllDrives: true,
includeItemsFromAllDrives: true,
});
const files = (response.data.files || []).map(file => ({
id: file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
created: file.createdTime,
modified: file.modifiedTime,
webLink: file.webViewLink,
parents: file.parents,
}));
return {
success: true,
account,
files,
metadata: {
timestamp: new Date().toISOString(),
count: files.length,
query: options.query || '(all)',
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node list_files.js <account> [--query "..."] [--type doc|sheet|slide|pdf|folder] [--max N]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--query':
options.query = args[++i];
break;
case '--type':
options.type = args[++i];
break;
case '--max':
options.max = parseInt(args[++i], 10);
break;
case '--folder':
options.folder = args[++i];
break;
}
}
try {
const result = await listFiles(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { listFiles };

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Read Drive File Content
*
* Usage: node read_file.js <account> <file-id> [options]
*
* Options:
* --format Export format for Google Docs (text, html, pdf)
*
* Examples:
* node read_file.js psd 1abc123def456
* node read_file.js personal 1xyz789 --format html
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const EXPORT_FORMATS = {
'application/vnd.google-apps.document': {
text: 'text/plain',
html: 'text/html',
pdf: 'application/pdf',
},
'application/vnd.google-apps.spreadsheet': {
csv: 'text/csv',
pdf: 'application/pdf',
},
'application/vnd.google-apps.presentation': {
text: 'text/plain',
pdf: 'application/pdf',
},
};
async function readFile(account, fileId, options = {}) {
const auth = await getAuthClient(account);
const drive = google.drive({ version: 'v3', auth });
// Get file metadata
const metadata = await drive.files.get({
fileId,
fields: 'id, name, mimeType, size, webViewLink',
supportsAllDrives: true,
});
const file = metadata.data;
let content = '';
// Check if it's a Google Workspace file (needs export)
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const exportFormats = EXPORT_FORMATS[file.mimeType];
if (exportFormats) {
const format = options.format || 'text';
const mimeType = exportFormats[format] || exportFormats.text || Object.values(exportFormats)[0];
const response = await drive.files.export({
fileId,
mimeType,
}, { responseType: 'text' });
content = response.data;
} else {
content = '[Cannot export this file type]';
}
} else {
// Regular file - download content
const response = await drive.files.get({
fileId,
alt: 'media',
supportsAllDrives: true,
}, { responseType: 'text' });
content = response.data;
}
return {
success: true,
account,
file: {
id: file.id,
name: file.name,
mimeType: file.mimeType,
webLink: file.webViewLink,
content,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const fileId = args[1];
if (!account || !fileId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_file.js <account> <file-id> [--format text|html|pdf]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === '--format') {
options.format = args[++i];
}
}
try {
const result = await readFile(account, fileId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
fileId,
}));
process.exit(1);
}
}
main();
module.exports = { readFile };

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* List Gmail Messages
*
* Usage: node list_messages.js <account> [options]
*
* Options:
* --query Gmail search query (default: empty = all)
* --label Filter by label (e.g., INBOX, UNREAD, SENT)
* --max Maximum messages to return (default: 10)
* --unread Show only unread messages
*
* Examples:
* node list_messages.js psd
* node list_messages.js psd --unread --max 5
* node list_messages.js personal --query "from:amazon.com"
* node list_messages.js consulting --label SENT
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function listMessages(account, options = {}) {
const auth = await getAuthClient(account);
const gmail = google.gmail({ version: 'v1', auth });
// Build query
let query = options.query || '';
if (options.unread) {
query = query ? `${query} is:unread` : 'is:unread';
}
// List messages
const listParams = {
userId: 'me',
maxResults: options.max || 10,
};
if (query) {
listParams.q = query;
}
if (options.label) {
listParams.labelIds = [options.label.toUpperCase()];
}
const response = await gmail.users.messages.list(listParams);
const messages = response.data.messages || [];
if (messages.length === 0) {
return {
success: true,
account,
messages: [],
metadata: {
timestamp: new Date().toISOString(),
count: 0,
query: query || '(all)',
}
};
}
// Get message details
const messageDetails = await Promise.all(
messages.map(async (msg) => {
const detail = await gmail.users.messages.get({
userId: 'me',
id: msg.id,
format: 'metadata',
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
});
const headers = detail.data.payload.headers;
const getHeader = (name) => {
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
return header ? header.value : '';
};
return {
id: msg.id,
threadId: msg.threadId,
from: getHeader('From'),
to: getHeader('To'),
subject: getHeader('Subject'),
date: getHeader('Date'),
snippet: detail.data.snippet,
labels: detail.data.labelIds,
isUnread: detail.data.labelIds.includes('UNREAD'),
};
})
);
return {
success: true,
account,
messages: messageDetails,
metadata: {
timestamp: new Date().toISOString(),
count: messageDetails.length,
query: query || '(all)',
nextPageToken: response.data.nextPageToken,
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node list_messages.js <account> [--unread] [--query "..."] [--max N] [--label LABEL]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--unread':
options.unread = true;
break;
case '--query':
options.query = args[++i];
break;
case '--max':
options.max = parseInt(args[++i], 10);
break;
case '--label':
options.label = args[++i];
break;
}
}
try {
const result = await listMessages(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { listMessages };

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* Read Gmail Message
*
* Usage: node read_message.js <account> <message-id>
*
* Examples:
* node read_message.js psd 18d1234567890abc
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function readMessage(account, messageId) {
const auth = await getAuthClient(account);
const gmail = google.gmail({ version: 'v1', auth });
const response = await gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full',
});
const message = response.data;
const headers = message.payload.headers;
const getHeader = (name) => {
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
return header ? header.value : '';
};
// Extract body
let body = '';
let htmlBody = '';
function extractBody(payload) {
if (payload.body && payload.body.data) {
const decoded = Buffer.from(payload.body.data, 'base64').toString('utf8');
if (payload.mimeType === 'text/plain') {
body = decoded;
} else if (payload.mimeType === 'text/html') {
htmlBody = decoded;
}
}
if (payload.parts) {
for (const part of payload.parts) {
extractBody(part);
}
}
}
extractBody(message.payload);
// Get attachments info
const attachments = [];
function findAttachments(payload) {
if (payload.filename && payload.body && payload.body.attachmentId) {
attachments.push({
filename: payload.filename,
mimeType: payload.mimeType,
size: payload.body.size,
attachmentId: payload.body.attachmentId,
});
}
if (payload.parts) {
for (const part of payload.parts) {
findAttachments(part);
}
}
}
findAttachments(message.payload);
return {
success: true,
account,
message: {
id: message.id,
threadId: message.threadId,
from: getHeader('From'),
to: getHeader('To'),
cc: getHeader('Cc'),
subject: getHeader('Subject'),
date: getHeader('Date'),
body: body || htmlBody,
isHtml: !body && !!htmlBody,
labels: message.labelIds,
attachments,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const [account, messageId] = process.argv.slice(2);
if (!account || !messageId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_message.js <account> <message-id>'
}));
process.exit(1);
}
try {
const result = await readMessage(account, messageId);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
messageId,
}));
process.exit(1);
}
}
main();
module.exports = { readMessage };

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Search Gmail Messages
*
* Usage: node search_messages.js <account> <query>
*
* Supports all Gmail search operators:
* from: - Sender
* to: - Recipient
* subject: - Subject line
* has:attachment - Has attachments
* after: - After date (YYYY/MM/DD)
* before: - Before date
* is:unread - Unread only
* is:starred - Starred only
* label: - By label
* filename: - Attachment filename
*
* Examples:
* node search_messages.js psd "from:boss@psd.org"
* node search_messages.js personal "subject:invoice after:2024/01/01"
* node search_messages.js consulting "has:attachment from:client"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function searchMessages(account, query, maxResults = 20) {
const auth = await getAuthClient(account);
const gmail = google.gmail({ version: 'v1', auth });
const response = await gmail.users.messages.list({
userId: 'me',
q: query,
maxResults,
});
const messages = response.data.messages || [];
if (messages.length === 0) {
return {
success: true,
account,
query,
messages: [],
metadata: {
timestamp: new Date().toISOString(),
count: 0,
}
};
}
// Get message details
const messageDetails = await Promise.all(
messages.map(async (msg) => {
const detail = await gmail.users.messages.get({
userId: 'me',
id: msg.id,
format: 'metadata',
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
});
const headers = detail.data.payload.headers;
const getHeader = (name) => {
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
return header ? header.value : '';
};
return {
id: msg.id,
threadId: msg.threadId,
from: getHeader('From'),
to: getHeader('To'),
subject: getHeader('Subject'),
date: getHeader('Date'),
snippet: detail.data.snippet,
labels: detail.data.labelIds,
isUnread: detail.data.labelIds.includes('UNREAD'),
};
})
);
return {
success: true,
account,
query,
messages: messageDetails,
metadata: {
timestamp: new Date().toISOString(),
count: messageDetails.length,
nextPageToken: response.data.nextPageToken,
}
};
}
// CLI interface
async function main() {
const [account, ...queryParts] = process.argv.slice(2);
const query = queryParts.join(' ');
if (!account || !query) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node search_messages.js <account> <query>',
examples: [
'node search_messages.js psd "from:boss@psd.org"',
'node search_messages.js personal "subject:invoice after:2024/01/01"',
]
}));
process.exit(1);
}
try {
const result = await searchMessages(account, query);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
query,
}));
process.exit(1);
}
}
main();
module.exports = { searchMessages };

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* Send Gmail Message
*
* Usage: node send_message.js <account> --to <email> --subject <subject> --body <body>
*
* Options:
* --to Recipient email (required)
* --subject Email subject (required)
* --body Email body (required)
* --cc CC recipients (comma-separated)
* --bcc BCC recipients (comma-separated)
* --reply-to Message ID to reply to
*
* Examples:
* node send_message.js psd --to "john@example.com" --subject "Hello" --body "Message here"
* node send_message.js personal --to "friend@gmail.com" --subject "Lunch?" --body "Are you free?" --cc "other@gmail.com"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function sendMessage(account, options) {
const auth = await getAuthClient(account);
const gmail = google.gmail({ version: 'v1', auth });
// Get sender email
const profile = await gmail.users.getProfile({ userId: 'me' });
const fromEmail = profile.data.emailAddress;
// Build email
const emailLines = [
`From: ${fromEmail}`,
`To: ${options.to}`,
];
if (options.cc) {
emailLines.push(`Cc: ${options.cc}`);
}
if (options.bcc) {
emailLines.push(`Bcc: ${options.bcc}`);
}
emailLines.push(
`Subject: ${options.subject}`,
'Content-Type: text/plain; charset=utf-8',
'',
options.body
);
// If replying, add thread info
if (options.replyTo) {
const original = await gmail.users.messages.get({
userId: 'me',
id: options.replyTo,
format: 'metadata',
metadataHeaders: ['Message-ID', 'References'],
});
const headers = original.data.payload.headers;
const messageIdHeader = headers.find(h => h.name === 'Message-ID');
const referencesHeader = headers.find(h => h.name === 'References');
if (messageIdHeader) {
const references = referencesHeader
? `${referencesHeader.value} ${messageIdHeader.value}`
: messageIdHeader.value;
emailLines.splice(2, 0, `In-Reply-To: ${messageIdHeader.value}`);
emailLines.splice(3, 0, `References: ${references}`);
}
}
const email = emailLines.join('\r\n');
const encodedEmail = Buffer.from(email).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const sendParams = {
userId: 'me',
requestBody: {
raw: encodedEmail,
},
};
if (options.replyTo) {
const original = await gmail.users.messages.get({
userId: 'me',
id: options.replyTo,
format: 'minimal',
});
sendParams.requestBody.threadId = original.data.threadId;
}
const response = await gmail.users.messages.send(sendParams);
return {
success: true,
account,
sent: {
id: response.data.id,
threadId: response.data.threadId,
to: options.to,
subject: options.subject,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node send_message.js <account> --to <email> --subject <subject> --body <body>'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--to':
options.to = args[++i];
break;
case '--subject':
options.subject = args[++i];
break;
case '--body':
options.body = args[++i];
break;
case '--cc':
options.cc = args[++i];
break;
case '--bcc':
options.bcc = args[++i];
break;
case '--reply-to':
options.replyTo = args[++i];
break;
}
}
if (!options.to || !options.subject || !options.body) {
console.error(JSON.stringify({
error: 'Missing required options: --to, --subject, --body',
usage: 'node send_message.js <account> --to <email> --subject <subject> --body <body>'
}));
process.exit(1);
}
try {
const result = await sendMessage(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { sendMessage };

View File

@@ -0,0 +1,17 @@
{
"name": "geoffrey-google-workspace",
"version": "1.0.0",
"description": "Google Workspace integration for Geoffrey",
"private": true,
"scripts": {
"setup:psd": "bun auth/oauth_setup.js psd",
"setup:kh": "bun auth/oauth_setup.js kh",
"setup:hrg": "bun auth/oauth_setup.js hrg",
"list:accounts": "bun auth/token_manager.js list"
},
"dependencies": {
"dotenv": "^16.3.1",
"googleapis": "^128.0.0",
"open": "^9.1.0"
}
}

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Create Google Sheet
*
* Usage: node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]
*
* Examples:
* node create_sheet.js psd --title "Budget 2025"
* node create_sheet.js psd --title "Project Tracker" --sheets "Tasks,Timeline,Resources"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function createSheet(account, options) {
const auth = await getAuthClient(account);
const sheets = google.sheets({ version: 'v4', auth });
const requestBody = {
properties: {
title: options.title,
},
};
// Add custom sheets if specified
if (options.sheets) {
const sheetNames = options.sheets.split(',').map(s => s.trim());
requestBody.sheets = sheetNames.map(name => ({
properties: { title: name },
}));
}
const response = await sheets.spreadsheets.create({
requestBody,
});
return {
success: true,
account,
spreadsheet: {
id: response.data.spreadsheetId,
title: response.data.properties.title,
url: response.data.spreadsheetUrl,
sheets: response.data.sheets.map(s => s.properties.title),
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--title':
options.title = args[++i];
break;
case '--sheets':
options.sheets = args[++i];
break;
}
}
if (!options.title) {
console.error(JSON.stringify({
error: 'Missing --title option',
usage: 'node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]'
}));
process.exit(1);
}
try {
const result = await createSheet(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createSheet };

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Edit Google Sheet
*
* Usage: node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>
*
* Examples:
* node edit_sheet.js psd SHEET_ID --range "A1" --values '[["Hello"]]'
* node edit_sheet.js psd SHEET_ID --range "A1:B2" --values '[["Name","Score"],["Alice",100]]'
* node edit_sheet.js psd SHEET_ID --range "Sheet2!A1" --values '[["Data"]]'
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function editSheet(account, spreadsheetId, options) {
const auth = await getAuthClient(account);
const sheets = google.sheets({ version: 'v4', auth });
const values = JSON.parse(options.values);
const response = await sheets.spreadsheets.values.update({
spreadsheetId,
range: options.range,
valueInputOption: 'USER_ENTERED',
requestBody: {
values,
},
});
return {
success: true,
account,
spreadsheetId,
update: {
range: response.data.updatedRange,
rowsUpdated: response.data.updatedRows,
columnsUpdated: response.data.updatedColumns,
cellsUpdated: response.data.updatedCells,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const spreadsheetId = args[1];
if (!account || !spreadsheetId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--range':
options.range = args[++i];
break;
case '--values':
options.values = args[++i];
break;
}
}
if (!options.range || !options.values) {
console.error(JSON.stringify({
error: 'Missing --range or --values option',
usage: 'node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>'
}));
process.exit(1);
}
try {
const result = await editSheet(account, spreadsheetId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
spreadsheetId,
}));
process.exit(1);
}
}
main();
module.exports = { editSheet };

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Read Google Sheet Data
*
* Usage: node read_sheet.js <account> <spreadsheet-id> [--range <range>]
*
* Examples:
* node read_sheet.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
* node read_sheet.js psd SHEET_ID --range "Sheet1!A1:D10"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function readSheet(account, spreadsheetId, options = {}) {
const auth = await getAuthClient(account);
const sheets = google.sheets({ version: 'v4', auth });
// Get spreadsheet metadata
const metadata = await sheets.spreadsheets.get({
spreadsheetId,
});
// Get values
const range = options.range || metadata.data.sheets[0].properties.title;
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range,
});
return {
success: true,
account,
spreadsheet: {
id: spreadsheetId,
title: metadata.data.properties.title,
sheets: metadata.data.sheets.map(s => s.properties.title),
},
data: {
range: response.data.range,
values: response.data.values || [],
rowCount: (response.data.values || []).length,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const spreadsheetId = args[1];
if (!account || !spreadsheetId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_sheet.js <account> <spreadsheet-id> [--range <range>]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === '--range') {
options.range = args[++i];
}
}
try {
const result = await readSheet(account, spreadsheetId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
spreadsheetId,
}));
process.exit(1);
}
}
main();
module.exports = { readSheet };

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* Create Google Slides Presentation
*
* Usage: node create_presentation.js <account> --title <title>
*
* Examples:
* node create_presentation.js psd --title "Q4 Review"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function createPresentation(account, options) {
const auth = await getAuthClient(account);
const slides = google.slides({ version: 'v1', auth });
const response = await slides.presentations.create({
requestBody: {
title: options.title,
},
});
return {
success: true,
account,
presentation: {
id: response.data.presentationId,
title: response.data.title,
url: `https://docs.google.com/presentation/d/${response.data.presentationId}/edit`,
slideCount: (response.data.slides || []).length,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'node create_presentation.js <account> --title <title>'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
if (args[i] === '--title') {
options.title = args[++i];
}
}
if (!options.title) {
console.error(JSON.stringify({
error: 'Missing --title option',
usage: 'node create_presentation.js <account> --title <title>'
}));
process.exit(1);
}
try {
const result = await createPresentation(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createPresentation };

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Edit Google Slides Presentation
*
* Usage: node edit_presentation.js <account> <presentation-id> <action> [options]
*
* Actions:
* --add-slide Add a blank slide
* --add-text-slide Add slide with title and body
* --replace-text Replace text across presentation
*
* Examples:
* node edit_presentation.js psd PRES_ID --add-slide
* node edit_presentation.js psd PRES_ID --add-text-slide --title "Agenda" --body "Item 1\nItem 2"
* node edit_presentation.js psd PRES_ID --replace-text --find "2024" --replace "2025"
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function editPresentation(account, presentationId, options) {
const auth = await getAuthClient(account);
const slides = google.slides({ version: 'v1', auth });
const requests = [];
if (options.addSlide) {
requests.push({
createSlide: {
slideLayoutReference: {
predefinedLayout: 'BLANK',
},
},
});
}
if (options.addTextSlide) {
const slideId = `slide_${Date.now()}`;
const titleId = `title_${Date.now()}`;
const bodyId = `body_${Date.now()}`;
requests.push({
createSlide: {
objectId: slideId,
slideLayoutReference: {
predefinedLayout: 'TITLE_AND_BODY',
},
placeholderIdMappings: [
{
layoutPlaceholder: { type: 'TITLE' },
objectId: titleId,
},
{
layoutPlaceholder: { type: 'BODY' },
objectId: bodyId,
},
],
},
});
if (options.title) {
requests.push({
insertText: {
objectId: titleId,
text: options.title,
},
});
}
if (options.body) {
requests.push({
insertText: {
objectId: bodyId,
text: options.body,
},
});
}
}
if (options.replaceText && options.find && options.replace) {
requests.push({
replaceAllText: {
containsText: {
text: options.find,
matchCase: true,
},
replaceText: options.replace,
},
});
}
if (requests.length === 0) {
return {
success: false,
error: 'No edit operation specified',
};
}
const response = await slides.presentations.batchUpdate({
presentationId,
requestBody: { requests },
});
return {
success: true,
account,
presentationId,
updates: response.data.replies.length,
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const presentationId = args[1];
if (!account || !presentationId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node edit_presentation.js <account> <presentation-id> --add-slide'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--add-slide':
options.addSlide = true;
break;
case '--add-text-slide':
options.addTextSlide = true;
break;
case '--replace-text':
options.replaceText = true;
break;
case '--title':
options.title = args[++i];
break;
case '--body':
options.body = args[++i];
break;
case '--find':
options.find = args[++i];
break;
case '--replace':
options.replace = args[++i];
break;
}
}
try {
const result = await editPresentation(account, presentationId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
presentationId,
}));
process.exit(1);
}
}
main();
module.exports = { editPresentation };

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Read Google Slides Presentation
*
* Usage: node read_presentation.js <account> <presentation-id>
*
* Examples:
* node read_presentation.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function readPresentation(account, presentationId) {
const auth = await getAuthClient(account);
const slides = google.slides({ version: 'v1', auth });
const response = await slides.presentations.get({
presentationId,
});
const presentation = response.data;
// Extract slide content
const slideContent = (presentation.slides || []).map((slide, index) => {
const texts = [];
// Extract text from page elements
for (const element of (slide.pageElements || [])) {
if (element.shape && element.shape.text) {
for (const textElement of (element.shape.text.textElements || [])) {
if (textElement.textRun && textElement.textRun.content) {
const text = textElement.textRun.content.trim();
if (text) texts.push(text);
}
}
}
}
return {
slideNumber: index + 1,
objectId: slide.objectId,
texts,
};
});
return {
success: true,
account,
presentation: {
id: presentation.presentationId,
title: presentation.title,
slideCount: (presentation.slides || []).length,
slides: slideContent,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const presentationId = args[1];
if (!account || !presentationId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'node read_presentation.js <account> <presentation-id>'
}));
process.exit(1);
}
try {
const result = await readPresentation(account, presentationId);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
presentationId,
}));
process.exit(1);
}
}
main();
module.exports = { readPresentation };

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Geoffrey Folder Manager
*
* Ensures Geoffrey folder structure exists in Google Drive.
* Creates folders if they don't exist, returns folder IDs.
*
* Usage: bun folder_manager.js <account> [subfolder]
*
* Examples:
* bun folder_manager.js hrg # Get/create Geoffrey folder
* bun folder_manager.js hrg Research # Get/create Geoffrey/Research
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const GEOFFREY_FOLDER = 'Geoffrey';
const SUBFOLDERS = ['Research', 'Notes', 'Reports', 'Travel'];
async function findFolder(drive, name, parentId = null) {
let query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
if (parentId) {
query += ` and '${parentId}' in parents`;
}
const response = await drive.files.list({
q: query,
fields: 'files(id, name)',
spaces: 'drive',
});
return response.data.files[0] || null;
}
async function createFolder(drive, name, parentId = null) {
const fileMetadata = {
name,
mimeType: 'application/vnd.google-apps.folder',
};
if (parentId) {
fileMetadata.parents = [parentId];
}
const response = await drive.files.create({
requestBody: fileMetadata,
fields: 'id, name',
});
return response.data;
}
async function ensureGeoffreyFolders(account, subfolder = null) {
const auth = await getAuthClient(account);
const drive = google.drive({ version: 'v3', auth });
// Find or create main Geoffrey folder
let geoffreyFolder = await findFolder(drive, GEOFFREY_FOLDER);
if (!geoffreyFolder) {
geoffreyFolder = await createFolder(drive, GEOFFREY_FOLDER);
}
const result = {
success: true,
account,
folders: {
Geoffrey: geoffreyFolder.id,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
// If specific subfolder requested, ensure it exists
if (subfolder) {
let subfolderObj = await findFolder(drive, subfolder, geoffreyFolder.id);
if (!subfolderObj) {
subfolderObj = await createFolder(drive, subfolder, geoffreyFolder.id);
}
result.folders[subfolder] = subfolderObj.id;
result.targetFolder = subfolderObj.id;
} else {
result.targetFolder = geoffreyFolder.id;
}
return result;
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const subfolder = args[1];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'bun folder_manager.js <account> [subfolder]',
subfolders: SUBFOLDERS,
}));
process.exit(1);
}
try {
const result = await ensureGeoffreyFolders(account, subfolder);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { ensureGeoffreyFolders, findFolder, createFolder };

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env node
/**
* Markdown to Google Docs Converter
*
* Converts markdown text to Google Docs API requests for proper formatting.
*
* Supports:
* - Headers (# ## ###)
* - Bold (**text**)
* - Links [text](url)
* - Bullet lists (- item)
* - Numbered lists (1. item)
* - Horizontal rules (---)
*/
/**
* Process inline formatting (bold, links) and return plain text + formatting requests
*/
function processInlineFormatting(text, startIndex) {
const requests = [];
let plainText = '';
let charIndex = startIndex;
let remaining = text;
while (remaining.length > 0) {
// Check for bold
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
if (boldMatch) {
const boldText = boldMatch[1];
plainText += boldText;
requests.push({
updateTextStyle: {
range: {
startIndex: charIndex,
endIndex: charIndex + boldText.length,
},
textStyle: {
bold: true,
},
fields: 'bold',
},
});
charIndex += boldText.length;
remaining = remaining.substring(boldMatch[0].length);
continue;
}
// Check for links
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const linkUrl = linkMatch[2];
plainText += linkText;
requests.push({
updateTextStyle: {
range: {
startIndex: charIndex,
endIndex: charIndex + linkText.length,
},
textStyle: {
link: {
url: linkUrl,
},
},
fields: 'link',
},
});
charIndex += linkText.length;
remaining = remaining.substring(linkMatch[0].length);
continue;
}
// Regular character
plainText += remaining[0];
charIndex++;
remaining = remaining.substring(1);
}
return { plainText, requests, endIndex: charIndex };
}
/**
* Parse markdown and return plain text + formatting requests
* @param {string} markdown - The markdown text
* @returns {Object} { text: string, requests: Array }
*/
function parseMarkdown(markdown) {
const requests = [];
let plainText = '';
let currentIndex = 1; // Google Docs starts at index 1
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Horizontal rule
if (line.match(/^---+$/)) {
const ruleLine = '─'.repeat(50) + '\n';
plainText += ruleLine;
currentIndex += ruleLine.length;
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const headerContent = headerMatch[2];
// Process inline formatting in header
const { plainText: processedText, requests: inlineRequests } =
processInlineFormatting(headerContent, currentIndex);
const fullLine = processedText + '\n';
const headingStyle = level === 1 ? 'HEADING_1' :
level === 2 ? 'HEADING_2' :
level === 3 ? 'HEADING_3' :
level === 4 ? 'HEADING_4' :
level === 5 ? 'HEADING_5' : 'HEADING_6';
requests.push({
updateParagraphStyle: {
range: {
startIndex: currentIndex,
endIndex: currentIndex + fullLine.length,
},
paragraphStyle: {
namedStyleType: headingStyle,
},
fields: 'namedStyleType',
},
});
requests.push(...inlineRequests);
plainText += fullLine;
currentIndex += fullLine.length;
continue;
}
// Bullet lists - process inline formatting within
const bulletMatch = line.match(/^[-*]\s+(.+)$/);
if (bulletMatch) {
const bulletContent = bulletMatch[1];
const { plainText: processedText, requests: inlineRequests } =
processInlineFormatting(bulletContent, currentIndex + 2); // +2 for "• "
const fullLine = '• ' + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Numbered lists - process inline formatting within
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/);
if (numberedMatch) {
const num = numberedMatch[1];
const listContent = numberedMatch[2];
const prefix = num + '. ';
const { plainText: processedText, requests: inlineRequests } =
processInlineFormatting(listContent, currentIndex + prefix.length);
const fullLine = prefix + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Regular line - process inline formatting
const { plainText: processedText, requests: inlineRequests } =
processInlineFormatting(line, currentIndex);
const fullLine = processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
}
return { text: plainText, requests };
}
/**
* Create a formatted Google Doc from markdown
* @param {Object} docs - Google Docs API instance
* @param {string} documentId - Document ID
* @param {string} markdown - Markdown content
*/
async function insertFormattedContent(docs, documentId, markdown) {
const { text, requests } = parseMarkdown(markdown);
// First, insert the plain text
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: 1 },
text,
},
}],
},
});
// Then apply formatting if there are any requests
if (requests.length > 0) {
await docs.documents.batchUpdate({
documentId,
requestBody: { requests },
});
}
return { textLength: text.length, formattingRequests: requests.length };
}
// CLI test
async function main() {
const testMarkdown = `# Main Title
## Section One
This is **bold text** and this is a [link](https://example.com).
- First bullet with [a link](https://test.com)
- Second bullet with **bold**
- Third bullet
### Subsection
1. Numbered item with [link](https://one.com)
2. Numbered item two
---
## Sources
- [Source One](https://source1.com)
- [Source Two](https://source2.com)
`;
const result = parseMarkdown(testMarkdown);
console.log('Plain text:');
console.log(result.text);
console.log('\nFormatting requests:', result.requests.length);
console.log('\nSample requests:');
result.requests.slice(0, 3).forEach(r => console.log(JSON.stringify(r, null, 2)));
}
if (require.main === module) {
main();
}
module.exports = { parseMarkdown, insertFormattedContent };