
Gdrive Access
- 345 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Use gdrive-access for development tasks
About
gdrive-access: A skill for development. This provides functionality for development workflows.
- gdrive-access
Gdrive Access by the numbers
- 345 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,170 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill gdrive-accessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 345 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Use gdrive-access for development tasks
Files
Google Drive Access
List, download, and sync files from Google Drive programmatically via Claude Code.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
MANDATORY PREFLIGHT (Execute Before Any Drive Operation)
CRITICAL: You MUST complete this preflight checklist before running any gdrive commands. Do NOT skip steps.
Step 1: Check CLI Binary Exists
ls -la "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gdrive-tools/skills/gdrive-access/scripts/gdrive" 2>/dev/null || echo "BINARY_NOT_FOUND"If BINARY_NOT_FOUND: Build it first:
cd ~/.claude/plugins/marketplaces/cc-skills/plugins/gdrive-tools/skills/gdrive-access/scripts && bun install && bun run buildStep 2: Check GDRIVE_OP_UUID Environment Variable
echo "GDRIVE_OP_UUID: ${GDRIVE_OP_UUID:-NOT_SET}"If NOT_SET: You MUST run the Setup Flow below. Do NOT proceed to gdrive commands.
Step 3: Verify 1Password Authentication
op account list 2>&1 | head -3If error or not signed in: Inform user to run op signin first.
---
Setup Flow (When GDRIVE_OP_UUID is NOT_SET)
Follow these steps IN ORDER. Use AskUserQuestion at decision points.
Setup Step 1: Check 1Password CLI
command -v op && echo "OP_CLI_INSTALLED" || echo "OP_CLI_MISSING"If OP_CLI_MISSING: Stop and inform user:
1Password CLI is required. Install with: brew install 1password-cliSetup Step 2: Discover Drive OAuth Items in 1Password
op item list --vault Employee --format json 2>/dev/null | jq -r '.[] | select(.title | test("drive|oauth|google"; "i")) | "\(.id)\t\(.title)"'Parse the output and proceed based on results:
Setup Step 3: User Selects OAuth Credentials
If items found, use AskUserQuestion with discovered items:
AskUserQuestion({
questions: [{
question: "Which 1Password item contains your Google Drive OAuth credentials?",
header: "Drive OAuth",
options: [
// POPULATE FROM op item list RESULTS - example:
{ label: "Google Drive API (56peh...)", description: "OAuth client in Employee vault" },
{ label: "Gmail API - dental-quizzes (abc12...)", description: "Can also access Drive" },
],
multiSelect: false
}]
})If NO items found, use AskUserQuestion to guide setup:
AskUserQuestion({
questions: [{
question: "No Google Drive OAuth credentials found in 1Password. How would you like to proceed?",
header: "Setup",
options: [
{ label: "Create new OAuth credentials (Recommended)", description: "I'll guide you through Google Cloud Console setup" },
{ label: "I have credentials elsewhere", description: "Help me add them to 1Password" },
{ label: "Skip for now", description: "I'll set this up later" }
],
multiSelect: false
}]
})- If "Create new OAuth credentials": Read and present references/gdrive-api-setup.md
- If "I have credentials elsewhere": Guide user to add to 1Password with required fields
- If "Skip for now": Inform user the skill won't work until configured
Setup Step 4: Confirm mise Configuration
After user selects an item (with UUID), use AskUserQuestion:
AskUserQuestion({
questions: [{
question: "Add GDRIVE_OP_UUID to .mise.local.toml in current project?",
header: "Configure",
options: [
{ label: "Yes, add to .mise.local.toml (Recommended)", description: "Creates/updates gitignored config file" },
{ label: "Show me the config only", description: "I'll add it manually" }
],
multiSelect: false
}]
})If "Yes, add to .mise.local.toml":
1. Check if .mise.local.toml exists 2. If exists, append GDRIVE_OP_UUID to [env] section 3. If not exists, create with:
[env]
GDRIVE_OP_UUID = "<selected-uuid>"1. Verify .mise.local.toml is in .gitignore
If "Show me the config only": Output the TOML for user to add manually.
Setup Step 5: Reload and Verify
mise trust 2>/dev/null || true
cd . && echo "GDRIVE_OP_UUID after reload: ${GDRIVE_OP_UUID:-NOT_SET}"If still NOT_SET: Inform user to restart their shell or run source ~/.zshrc.
Setup Step 6: Test Connection
GDRIVE_OP_UUID="${GDRIVE_OP_UUID}" $HOME/.claude/plugins/marketplaces/cc-skills/plugins/gdrive-tools/skills/gdrive-access/scripts/gdrive list 1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mASIf OAuth prompt appears: This is expected on first run. Browser will open for Google consent.
---
Drive Commands (Only After Preflight Passes)
GDRIVE_CLI="$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gdrive-tools/skills/gdrive-access/scripts/gdrive"
# List files in a folder
$GDRIVE_CLI list <folder_id>
# List with details (size, modified date)
$GDRIVE_CLI list <folder_id> --verbose
# Search for files
$GDRIVE_CLI search "name contains 'training'"
# Get file info
$GDRIVE_CLI info <file_id>
# Download a single file
$GDRIVE_CLI download <file_id> -o ./output.pdf
# Sync entire folder to local directory
$GDRIVE_CLI sync <folder_id> -o ./output_dir
# Sync with subfolders
$GDRIVE_CLI sync <folder_id> -o ./output_dir -r
# JSON output (for parsing)
$GDRIVE_CLI list <folder_id> --jsonCreating a native Google Doc (write)
create-doc uploads a local HTML (or .docx) source and has Drive convert it into a native Google Doc (application/vnd.google-apps.document) — the team can open + comment on it directly, no "convert" step. This is the recommended Drive pattern: files.create with the target mimeType in the metadata and the source bytes as media, sent as a _multipart_ upload (a simple upload silently skips conversion).
# Markdown is the usual author format → HTML (highest-fidelity import) → native Google Doc
pandoc notes.md -o /tmp/notes.html --standalone
$GDRIVE_CLI create-doc /tmp/notes.html --name "Meeting Notes" --parent <folder_id>
# → prints the new Doc's id, mimeType (application/vnd.google-apps.document), and docs.google.com link
# Overwrite an existing Doc's contents in place (keeps the same id / link / comments):
$GDRIVE_CLI create-doc /tmp/notes.html --update <doc_id>Why not rclone / `.docx`? rclone's --drive-import-formats is unreliable for this (name-collision and sync-confusion gotchas), and a plain .docx in Drive opens in Docs but isn't a _native_ Doc. The Drive API conversion above is the robust FOSS path.
Write scope (one-time re-auth). create-doc needs the drive.file scope (create/manage files this app makes — least-privilege, NOT full-drive). It was added alongside the original drive.readonly, so the first write re-prompts for consent. If it doesn't, force it: rm ~/.claude/tools/gdrive-tokens/$GDRIVE_OP_UUID.json then re-run. --update only works on Docs this app created (drive.file limitation); to overwrite externally-created Docs, broaden the scope to auth/drive.
Rate limits & retries (built in)
Drive returns HTTP 403 `rateLimitExceeded` / `userRateLimitExceeded` (and sometimes 429) when a project bursts too many queries — we hit this doing several writes back-to-back. The CLI now wraps every Drive call in exponential backoff with jitter (withBackoff in lib/drive.ts): wait min(2^n·1000 + random_ms, 64s), finite retries, per Google's official guidance. Non-rate-limit errors still fail loud. To stay under the limit proactively: batch / space out bulk operations, avoid redundant metadata calls, and don't refresh the token on every call.
Extracting Folder ID from URL
Google Drive folder URL:
https://drive.google.com/drive/folders/1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mAS
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the folder IDDrive Search Syntax
| Query | Description |
|---|---|
name contains 'keyword' | Name contains keyword |
name = 'exact name' | Exact name match |
mimeType = 'application/pdf' | By file type |
modifiedTime > '2026-01-01' | Modified after date |
trashed = false | Not in trash |
'<folderId>' in parents | In specific folder |
Reference: <https://developers.google.com/drive/api/guides/search-files>
Environment Variables
| Variable | Required | Description |
|---|---|---|
GDRIVE_OP_UUID | Yes | 1Password item UUID for OAuth credentials |
GDRIVE_OP_VAULT | No | 1Password vault (default: Employee) |
Token Storage
OAuth tokens stored at: ~/.claude/tools/gdrive-tokens/<uuid>.json
- Central location (not in plugin, not in project)
- Organized by 1Password UUID (supports multi-account)
- Created with chmod 600
Google Docs Export
Google Docs (Docs, Sheets, Slides) are automatically exported:
| Google Type | Export Format |
|---|---|
| Document | .docx |
| Spreadsheet | .xlsx |
| Presentation | .pptx |
| Drawing | .png |
References
- gdrive-api-setup.md - Google Cloud OAuth setup guide
Post-Change Checklist
- [ ] YAML frontmatter valid (no colons in description)
- [ ] Trigger keywords current
- [ ] Path patterns use $HOME not hardcoded paths
- [ ] References exist and are linked
Post-Execution Reflection
After this skill completes, reflect before closing the task:
0. Locate yourself. — Find this SKILL.md's canonical path before editing. 1. What failed? — Fix the instruction that caused it. 2. What worked better than expected? — Promote to recommended practice. 3. What drifted? — Fix any script, reference, or dependency that no longer matches reality. 4. Log it. — Evolution-log entry with trigger, fix, and evidence.
Do NOT defer. The next invocation inherits whatever you leave behind.
Evolution Log
Convention: Reverse chronological order (newest on top, oldest at bottom). Prepend new entries.
---
2026-06-05: Add write — create-doc (native Google Docs) + rate-limit backoff
Trigger: Real task needed to publish a team document to Drive as a _native_ Google Doc (so the team can comment inline), and a burst of writes hit HTTP 403 `rateLimitExceeded`. The skill was read-only (list/search/info/download/sync) with no create path, and rclone's --drive-import-formats was unreliable for native-Doc conversion on the target remote.
What changed:
- New `create-doc` command (
cli.ts+createDoc()inlib/drive.ts): converts a local HTML/.docx
source into a native Google Doc via files.create (target mimeType in metadata + source as media, the multipart-conversion pattern — confirmed against Google's docs as the recommended approach). --update overwrites an existing Doc in place.
- `withBackoff()` retry wrapper (
lib/drive.ts), now wrapping every Drive call (list/search/info +
create): exponential backoff + jitter on 403 rateLimitExceeded/userRateLimitExceeded and 429, min(2^n·1000 + rand, 64s), finite ceiling, non-rate-limit errors rethrow loud. Matches Google's official guidance (handle-errors guide).
- OAuth scope (
lib/config.ts): added least-privilegedrive.filenext todrive.readonlyso writes
work; first write re-prompts for consent (documented; same pattern as gmail-commander adding gmail.compose).
- SKILL.md: new "Creating a native Google Doc (write)" + "Rate limits & retries" sections.
- Fixed pre-existing biome nits surfaced on the touched files (parseInt radix,
import type).
Why update this skill (not a new plugin): gdrive-access is the natural home for Drive read+write, and a sibling plugin (gmail-commander) already shows one plugin doing both read and write for a Google service.
Files: scripts/cli.ts, scripts/lib/drive.ts, scripts/lib/config.ts, SKILL.md, references/evolution-log.md. Rebuild the binary with bun run build.
Evidence: the same files.create/multipart pattern (prototyped via the rclone OAuth token) produced a verified native Doc (application/vnd.google-apps.document + docs.google.com/document/... link); the binary compiles (bun build) and gdrive --help lists create-doc.
---
2026-02-26: Initial Evolution Log
Status: Skill is in use and maintained. Track improvements here.
Purpose
This evolution log tracks updates to the skill. Each entry should note:
- What changed (content, structure, tooling)
- Why it changed (bug fix, feature request, best practice)
- Files affected
How to Use
1. When updating SKILL.md or references, add an entry here with the date 2. Keep entries reverse-chronological (newest first) 3. Link to ADRs or GitHub issues when relevant 4. Reference specific line changes when helpful
---
Google Drive API OAuth Setup
Guide for creating OAuth credentials to access Google Drive API.
Prerequisites
- Google Account
- Access to Google Cloud Console
- 1Password CLI installed (
brew install 1password-cli)
Step 1: Create or Select Project
1. Go to Google Cloud Console 2. Click project dropdown (top-left) → New Project 3. Name: gdrive-cli (or any name) 4. Click Create
Step 2: Enable Drive API
1. Go to APIs & Services → Library 2. Search for "Google Drive API" 3. Click Google Drive API → Enable
Step 3: Configure OAuth Consent Screen
1. Go to APIs & Services → OAuth consent screen 2. Select External (unless you have Google Workspace) 3. Click Create 4. Fill in:
- App name:
gdrive-cli - User support email: Your email
- Developer contact: Your email
5. Click Save and Continue 6. Scopes: Click Add or Remove Scopes
- Add:
https://www.googleapis.com/auth/drive.readonly - Click Update → Save and Continue
7. Test users: Add your email 8. Click Save and Continue → Back to Dashboard
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: gdrive-cli-desktop 5. Click Create 6. Download JSON (you'll need values from this)
Step 5: Store in 1Password
Create a new item in 1Password with these fields:
| Field | Value |
|---|---|
client_id | From downloaded JSON (client_id) |
client_secret | From downloaded JSON (client_secret) |
redirect_uris | http://localhost (default) |
auth_uri | https://accounts.google.com/o/oauth2/auth (optional) |
token_uri | https://oauth2.googleapis.com/token (optional) |
Using 1Password CLI
# Create new item
op item create \
--category=api_credential \
--title="Google Drive API - gdrive-cli" \
--vault="Employee" \
'client_id[text]=YOUR_CLIENT_ID' \
'client_secret[password]=YOUR_CLIENT_SECRET' \
'redirect_uris[text]=http://localhost'
# Get the UUID
op item list --vault Employee | grep -i driveStep 6: Configure mise
Add the UUID to your project's .mise.local.toml:
[env]
GDRIVE_OP_UUID = "<uuid-from-step-5>"Then reload:
mise trust && cd .Step 7: First Run Authorization
On first run, the CLI will:
1. Open your browser to Google OAuth consent 2. Ask you to authorize the app 3. Redirect to localhost (handled by CLI) 4. Store the token at ~/.claude/tools/gdrive-tokens/<uuid>.json
# Test the connection
gdrive list <any-folder-id>Troubleshooting
"Access blocked: This app's request is invalid"
- Ensure redirect URI matches:
http://localhost - Check OAuth consent screen is configured
"This app isn't verified"
- Click Advanced → Go to gdrive-cli (unsafe)
- This is normal for personal OAuth apps
"Token expired"
- The CLI handles refresh automatically
- If issues persist, delete token file and re-auth:
rm ~/.claude/tools/gdrive-tokens/<uuid>.json
gdrive list <folder-id> # Will re-auth"1Password error"
- Ensure you're signed in:
op signin - Check vault name matches:
op vault list - Verify UUID exists:
op item get <uuid>
Security Notes
- OAuth tokens are stored with
chmod 600 - Only
drive.readonlyscope is requested - Credentials never leave 1Password (only accessed at runtime)
- Token file location:
~/.claude/tools/gdrive-tokens/
OAuth Client Setup Reference
This file documents how to configure OAuth clients for gdrive-tools.
1Password Item Structure
Your 1Password item should have these fields:
| Field | Required | Description |
|---|---|---|
client_id | Yes | Google OAuth Client ID |
client_secret | Yes | Google OAuth Client Secret |
redirect_uris | No | Default: http://localhost |
Alternative field names (for existing login items):
username→ maps toclient_idpassword→ maps toclient_secret
Configuration
Add to your project's .mise.local.toml (gitignored):
[env]
GDRIVE_OP_UUID = "<your-1password-item-uuid>"
# GDRIVE_OP_VAULT = "Employee" # Optional, defaults to EmployeeOr add to ~/.config/mise/config.local.toml for global access across all projects.
Finding Your UUID
# List items matching "drive" or "google" in Employee vault
op item list --vault Employee | grep -i "drive\|google\|oauth"
# Get item details
op item get <uuid> --vault Employee --format json | jq '.fields[] | {label, value}'Token Storage
OAuth tokens are stored at: ~/.claude/tools/gdrive-tokens/<uuid>.json
- Each 1Password UUID gets its own token file
- Supports multi-account access (work/personal Google accounts)
- Created with
chmod 600for security
Troubleshooting
Error 401: deleted_client
The OAuth client has been deleted in Google Cloud Console. Create a new one or use a different 1Password item.
Access blocked: Authorization Error
1. Check OAuth consent screen is configured in Google Cloud Console 2. Verify the app is in "Testing" mode with your email as test user 3. Ensure Drive API is enabled in the project
Missing fields error
Your 1Password item needs client_id/client_secret (or username/password as fallback).
Creating New OAuth Credentials
See gdrive-api-setup.md for step-by-step Google Cloud Console setup.
node_modules/
gdrive
*.bun-build
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "gdrive-access",
"dependencies": {
"@googleapis/drive": "^8.0.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.0.0",
"typescript": "^5",
},
},
},
"packages": {
"@googleapis/drive": ["@googleapis/drive@8.16.0", "", { "dependencies": { "googleapis-common": "^7.0.0" } }, "sha512-Xi2mMrUTQ+gsfyouRGd0pfnL+jjg4n4sjKsJruM1y4DknuRfdSBTk5E//WrL0YJ/CqpcBgyd7L8DvaPRtxZD3Q=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"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=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"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=="],
"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=="],
"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-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=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"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.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"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=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"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=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"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=="],
}
}
#!/usr/bin/env bun
/**
* Google Drive CLI - Access Google Drive via command line
*
* Configuration via mise environment variables:
* - GDRIVE_OP_UUID: 1Password item UUID for OAuth credentials
* - GDRIVE_OP_VAULT: 1Password vault (optional, default: Employee)
*/
import { parseArgs } from "node:util";
import {
createDoc,
createDriveClient,
downloadFile,
getFile,
listFiles,
printFiles,
printJson,
printProgress,
searchFiles,
syncFolder,
} from "./lib/index.ts";
const USAGE = `
Google Drive CLI - Access Google Drive via command line
USAGE:
gdrive <command> [options]
COMMANDS:
list <folder_id> List files in a folder
search <query> Search files using Drive query syntax
info <file_id> Get file metadata
download <file_id> Download a single file
sync <folder_id> Download all files from a folder
create-doc <file> Create a NATIVE Google Doc from an HTML (or .docx) source
OPTIONS:
-n, --number Number of files to fetch (default: 100)
-o, --output Output path for download/sync
-r, --recursive Include subfolders in sync
-v, --verbose Show detailed file information
--json Output as JSON
--name <title> Doc title (create-doc, on create)
--parent <id> Destination folder (create-doc, on create)
--update <file_id> Replace an existing Doc's contents in place (create-doc)
ENVIRONMENT:
GDRIVE_OP_UUID 1Password item UUID for OAuth credentials (required)
GDRIVE_OP_VAULT 1Password vault (default: Employee)
EXAMPLES:
gdrive list 1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mAS
gdrive list 1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mAS --verbose
gdrive search "name contains 'training'"
gdrive info 1abc123def456
gdrive download 1abc123def456 -o ./file.pdf
gdrive sync 1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mAS -o ./output -r
gdrive create-doc ./report.html --name "Q1 Report" --parent 1wqqqv...
gdrive create-doc ./report.html --update 1abc123def456 # overwrite an existing Doc
# Markdown -> native Google Doc (HTML is the highest-fidelity import source):
pandoc notes.md -o /tmp/notes.html --standalone && gdrive create-doc /tmp/notes.html --name "Notes" --parent <folder_id>
DRIVE SEARCH SYNTAX:
name contains 'keyword' Name contains keyword
name = 'exact name' Exact name match
mimeType = 'application/pdf' By file type
modifiedTime > '2026-01-01' Modified after date
trashed = false Not in trash
'folderId' in parents In specific folder
FOLDER ID:
Extract from Google Drive URL:
https://drive.google.com/drive/folders/1wqqqvBmeUFYuwOOEQhzoChC7KzAk-mAS
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the folder ID
`;
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
number: { type: "string", short: "n", default: "100" },
output: { type: "string", short: "o" },
recursive: { type: "boolean", short: "r", default: false },
verbose: { type: "boolean", short: "v", default: false },
json: { type: "boolean", default: false },
name: { type: "string" },
parent: { type: "string" },
update: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
if (values.help || positionals.length === 0) {
console.log(USAGE);
process.exit(0);
}
const [command, ...args] = positionals;
const maxResults = parseInt(values.number!, 10);
const asJson = values.json;
const verbose = values.verbose;
try {
const client = await createDriveClient();
switch (command) {
case "list": {
const folderId = args[0];
if (!folderId) {
console.error("Error: Folder ID required");
console.error("Usage: gdrive list <folder_id>");
process.exit(1);
}
const files = await listFiles(client, {
folderId,
maxResults,
verbose,
});
if (asJson) {
printJson(files);
} else {
printFiles(files, verbose);
}
break;
}
case "search": {
const query = args.join(" ");
if (!query) {
console.error("Error: Search query required");
console.error("Usage: gdrive search <query>");
process.exit(1);
}
const files = await searchFiles(client, { query, maxResults });
if (asJson) {
printJson(files);
} else {
printFiles(files, verbose);
}
break;
}
case "info": {
const fileId = args[0];
if (!fileId) {
console.error("Error: File ID required");
console.error("Usage: gdrive info <file_id>");
process.exit(1);
}
const file = await getFile(client, fileId);
if (!file) {
console.error("Error: File not found");
process.exit(1);
}
if (asJson) {
printJson(file);
} else {
console.log(`ID: ${file.id}`);
console.log(`Name: ${file.name}`);
console.log(`Type: ${file.mimeType}`);
if (file.size)
console.log(
`Size: ${Math.round(parseInt(file.size, 10) / 1024)}KB`,
);
if (file.modifiedTime) console.log(`Modified: ${file.modifiedTime}`);
if (file.webViewLink) console.log(`Link: ${file.webViewLink}`);
}
break;
}
case "download": {
const fileId = args[0];
if (!fileId) {
console.error("Error: File ID required");
console.error("Usage: gdrive download <file_id> -o <output_path>");
process.exit(1);
}
// Get file info first for default output name
const file = await getFile(client, fileId);
if (!file) {
console.error("Error: File not found");
process.exit(1);
}
const outputPath = values.output ?? file.name;
console.error(`Downloading: ${file.name}`);
await downloadFile(client, { fileId, outputPath }, (bytes, total) => {
if (total > 0) {
const pct = Math.round((bytes / total) * 100);
process.stderr.write(
`\r${pct}% (${Math.round(bytes / 1024)}KB / ${Math.round(total / 1024)}KB)`,
);
}
});
console.error(`\nSaved to: ${outputPath}`);
break;
}
case "sync": {
const folderId = args[0];
if (!folderId) {
console.error("Error: Folder ID required");
console.error("Usage: gdrive sync <folder_id> -o <output_dir>");
process.exit(1);
}
const outputDir = values.output ?? "./gdrive-sync";
const recursive = values.recursive;
console.error(`Syncing folder to: ${outputDir}`);
if (recursive) console.error("(including subfolders)");
const files = await syncFolder(
client,
{ folderId, outputDir, recursive, maxResults },
printProgress,
);
console.error(`\nSynced ${files.length} files to ${outputDir}`);
if (asJson) {
printJson(files);
}
break;
}
case "create-doc": {
const htmlPath = args[0];
if (!htmlPath) {
console.error("Error: source file required");
console.error(
'Usage: gdrive create-doc <file.html> --name "Title" --parent <folder_id>',
);
console.error(
" gdrive create-doc <file.html> --update <existing_doc_id>",
);
process.exit(1);
}
if (!values.update && (!values.name || !values.parent)) {
console.error(
"Error: --name and --parent are required when creating a new Doc",
);
console.error(
" (or pass --update <doc_id> to overwrite an existing Doc)",
);
process.exit(1);
}
const file = await createDoc(client, {
htmlPath,
name: values.name ?? "",
parentId: values.parent,
updateId: values.update,
});
if (asJson) {
printJson(file);
} else {
console.log(
values.update
? "Updated native Google Doc:"
: "Created native Google Doc:",
);
console.log(` ID: ${file.id}`);
console.log(` Name: ${file.name}`);
console.log(` Type: ${file.mimeType}`);
if (file.webViewLink) console.log(` Link: ${file.webViewLink}`);
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(USAGE);
process.exit(1);
}
} catch (error) {
console.error("Error:", error instanceof Error ? error.message : error);
process.exit(1);
}
}
main();
/**
* OAuth2 authentication for Google Drive API
*
* Retrieves credentials from 1Password using UUID from environment.
* Tokens stored at ~/.claude/tools/gdrive-tokens/<uuid>.json
*/
import { mkdir } from "node:fs/promises";
import { auth } from "@googleapis/drive";
import type { OAuthCredentials, SavedToken } from "./types.ts";
import {
getOpUuid,
getOpVault,
getTokenPath,
getTokensDir,
SCOPES,
AUTH_TIMEOUT_MS,
EPHEMERAL_PORT_START,
EPHEMERAL_PORT_RANGE,
} from "./config.ts";
// Use OAuth2 client from @googleapis/drive for type compatibility
type OAuth2Client = InstanceType<typeof auth.OAuth2>;
/**
* Retrieve OAuth credentials from 1Password using UUID
*/
export async function getCredentialsFrom1Password(): Promise<OAuthCredentials> {
const uuid = getOpUuid();
const vault = getOpVault();
const proc = Bun.spawn(["op", "item", "get", uuid, "--vault", vault, "--format", "json"], {
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`1Password error: ${stderr}`);
}
const item = JSON.parse(output);
const fields: Record<string, string> = {};
for (const field of item.fields ?? []) {
const key = field.label ?? field.id;
if (key && field.value) {
fields[key] = field.value;
}
}
// Support multiple field naming conventions:
// - client_id/client_secret (standard)
// - username/password (1Password login items)
const clientId = fields.client_id ?? fields.username;
const clientSecret = fields.client_secret ?? fields.password;
if (!clientId || !clientSecret) {
throw new Error(
"1Password item missing OAuth credentials. Expected fields: client_id/client_secret or username/password"
);
}
return {
installed: {
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [fields.redirect_uris ?? "http://localhost"],
auth_uri: fields.auth_uri ?? "https://accounts.google.com/o/oauth2/auth",
token_uri: fields.token_uri ?? "https://oauth2.googleapis.com/token",
},
};
}
/**
* Load saved token from disk
*/
export async function loadToken(): Promise<SavedToken | null> {
try {
const tokenPath = getTokenPath();
const file = Bun.file(tokenPath);
if (await file.exists()) {
return await file.json();
}
} catch {
// Token doesn't exist or is invalid
}
return null;
}
/**
* Save token to disk with secure permissions
*/
export async function saveToken(token: SavedToken): Promise<void> {
const tokensDir = getTokensDir();
const tokenPath = getTokenPath();
// Ensure tokens directory exists
await mkdir(tokensDir, { recursive: true, mode: 0o700 });
await Bun.write(tokenPath, JSON.stringify(token, null, 2));
Bun.spawn(["chmod", "600", tokenPath]);
}
/**
* Start local server to receive OAuth callback
*/
async function waitForAuthCode(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const server = Bun.serve({
port,
fetch(req) {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
if (error) {
server.stop();
reject(new Error(`OAuth error: ${error}`));
return new Response(
`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`,
{ headers: { "Content-Type": "text/html" } }
);
}
if (code) {
server.stop();
resolve(code);
return new Response(
`<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>`,
{ headers: { "Content-Type": "text/html" } }
);
}
return new Response("Waiting for authorization...", { status: 400 });
},
});
setTimeout(() => {
server.stop();
reject(new Error("Authorization timeout"));
}, AUTH_TIMEOUT_MS);
});
}
/**
* Create authenticated OAuth2 client
*/
export async function getAuthClient(): Promise<OAuth2Client> {
const credentials = await getCredentialsFrom1Password();
const { client_id, client_secret } = credentials.installed;
const port = EPHEMERAL_PORT_START + Math.floor(Math.random() * EPHEMERAL_PORT_RANGE);
const redirectUri = `http://localhost:${port}`;
const oauth2Client = new auth.OAuth2(client_id, client_secret, redirectUri);
const savedToken = await loadToken();
if (savedToken) {
oauth2Client.setCredentials(savedToken);
if (savedToken.expiry_date && savedToken.expiry_date < Date.now()) {
console.error("Token expired, refreshing...");
const { credentials: newCreds } = await oauth2Client.refreshAccessToken();
await saveToken(newCreds as SavedToken);
}
return oauth2Client;
}
// No token - need to authorize via local server
const authUrl = oauth2Client.generateAuthUrl({
access_type: "offline",
scope: [...SCOPES],
});
console.error("Opening browser for authorization...");
console.error(`If browser doesn't open, visit: ${authUrl}\n`);
Bun.spawn(["open", authUrl]);
const code = await waitForAuthCode(port);
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
await saveToken(tokens as SavedToken);
console.error("Token saved successfully!");
return oauth2Client;
}
/**
* Configuration constants and environment variable handling
*
* Uses mise environment variables for agnostic, multi-account Google Drive access.
* Shows self-guiding errors when configuration is missing.
*/
import { homedir } from "node:os";
import { join } from "node:path";
// Environment variable names
const ENV_GDRIVE_OP_UUID = "GDRIVE_OP_UUID";
const ENV_GDRIVE_OP_VAULT = "GDRIVE_OP_VAULT";
/**
* Get 1Password UUID from environment
* Exits with self-guiding error if not configured
*/
export function getOpUuid(): string {
const uuid = process.env[ENV_GDRIVE_OP_UUID];
if (!uuid) {
printSetupError();
process.exit(1);
}
return uuid;
}
/**
* Get 1Password vault from environment (default: Employee)
*/
export function getOpVault(): string {
return process.env[ENV_GDRIVE_OP_VAULT] ?? "Employee";
}
/**
* Get token storage path for a given 1Password UUID
* Tokens stored centrally at ~/.claude/tools/gdrive-tokens/<uuid>.json
*/
export function getTokenPath(uuid?: string): string {
const actualUuid = uuid ?? getOpUuid();
const tokensDir = join(homedir(), ".claude", "tools", "gdrive-tokens");
return join(tokensDir, `${actualUuid}.json`);
}
/**
* Get tokens directory path
*/
export function getTokensDir(): string {
return join(homedir(), ".claude", "tools", "gdrive-tokens");
}
// Google Drive API scopes.
// - drive.readonly: read/list/download any file the user can access (the original read-only surface).
// - drive.file: create + manage files this app creates (powers `create-doc`; least-privilege write —
// NOT full-drive access). Adding it changes the consent set, so existing read-only users re-consent
// the first time they use a write command (delete ~/.claude/tools/gdrive-tokens/<uuid>.json to force it).
export const SCOPES = [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/drive.file",
] as const;
// OAuth callback server configuration
export const AUTH_TIMEOUT_MS = 120_000;
export const EPHEMERAL_PORT_START = 49152;
export const EPHEMERAL_PORT_RANGE = 16383;
/**
* Print self-guiding setup error
*/
function printSetupError(): void {
const message = `
╔══════════════════════════════════════════════════════════════╗
║ GDRIVE TOOL - SETUP REQUIRED ║
╚══════════════════════════════════════════════════════════════╝
Missing: ${ENV_GDRIVE_OP_UUID} environment variable
━━━ QUICK FIX ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ask Claude Code: "Help me set up Google Drive access"
- OR -
Run: /gdrive-tools:setup
━━━ MANUAL SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Find your 1Password UUID:
op item list --vault Employee | grep -i drive
2. Add to .mise.local.toml:
[env]
GDRIVE_OP_UUID = "<your-uuid>"
3. Reload: cd . && mise trust
━━━ NEED OAUTH CREDENTIALS? ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
See: ~/.claude/plugins/marketplaces/cc-skills/plugins/gdrive-tools/
skills/gdrive-access/references/gdrive-api-setup.md
`;
console.error(message);
}
/**
* Google Drive API client wrapper
*
* Uses @googleapis/drive for lighter dependency footprint
*/
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { drive, type drive_v3 } from "@googleapis/drive";
import { getAuthClient } from "./auth.ts";
import type {
DownloadOptions,
DriveFile,
ListOptions,
SearchOptions,
SyncOptions,
} from "./types.ts";
/**
* Create authenticated Google Drive API service
*/
export async function createDriveClient(): Promise<drive_v3.Drive> {
const auth = await getAuthClient();
return drive({ version: "v3", auth });
}
/**
* Transform Drive API file to DriveFile type
*/
function formatFile(file: drive_v3.Schema$File): DriveFile {
return {
id: file.id ?? "",
name: file.name ?? "",
mimeType: file.mimeType ?? "",
size: file.size ?? undefined,
modifiedTime: file.modifiedTime ?? undefined,
createdTime: file.createdTime ?? undefined,
parents: file.parents ?? undefined,
webViewLink: file.webViewLink ?? undefined,
webContentLink: file.webContentLink ?? undefined,
};
}
/**
* Retry a Drive API call with exponential backoff + jitter on transient rate-limit errors.
*
* Per Google's official guidance (developers.google.com/workspace/drive/api/guides/handle-errors):
* retry on HTTP 429 and on 403 with reason rateLimitExceeded / userRateLimitExceeded, waiting
* min(2^n * 1000 + random_ms, maxBackoffMs) between attempts with a finite retry ceiling. Any
* non-rate-limit error rethrows immediately so real failures stay loud.
*/
export async function withBackoff<T>(
fn: () => Promise<T>,
opts: { maxRetries?: number; maxBackoffMs?: number; label?: string } = {},
): Promise<T> {
const maxRetries = opts.maxRetries ?? 6;
const maxBackoffMs = opts.maxBackoffMs ?? 64_000;
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (err: unknown) {
const e = err as {
code?: number;
status?: number;
message?: string;
errors?: Array<{ reason?: string }>;
response?: {
status?: number;
data?: { error?: { errors?: Array<{ reason?: string }> } };
};
};
const status = e.code ?? e.status ?? e.response?.status;
const reason =
e.errors?.[0]?.reason ??
e.response?.data?.error?.errors?.[0]?.reason ??
"";
const isRateLimit =
status === 429 ||
(status === 403 &&
/rateLimitExceeded|userRateLimitExceeded/i.test(reason)) ||
/rate limit/i.test(e.message ?? "");
if (!isRateLimit || attempt >= maxRetries) throw err;
const waitMs = Math.min(
2 ** attempt * 1000 + Math.floor(Math.random() * 1000),
maxBackoffMs,
);
console.error(
`[gdrive] ${opts.label ?? "request"}: rate-limited (${status}${reason ? ` ${reason}` : ""}); retry ${attempt + 1}/${maxRetries} in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((resolve) => setTimeout(resolve, waitMs));
attempt++;
}
}
}
export interface CreateDocOptions {
htmlPath: string;
name: string;
parentId?: string;
sourceMimeType?: string;
targetMimeType?: string;
updateId?: string;
}
/**
* Create a NATIVE Google Workspace file (default: a Google Doc) by converting a local source file.
*
* Uses the recommended Drive pattern: files.create with the TARGET mimeType in the metadata
* (application/vnd.google-apps.document) and the SOURCE bytes as media (default text/html). Drive
* converts on upload via a multipart upload — a *simple* upload silently skips conversion. HTML is
* the highest-fidelity importable source for Docs (headings, bold, lists, tables). Pass updateId to
* replace an existing Doc's contents in place (keeps the same id / link / comments).
*/
export async function createDoc(
client: drive_v3.Drive,
options: CreateDocOptions,
): Promise<DriveFile> {
const sourceMimeType = options.sourceMimeType ?? "text/html";
const targetMimeType =
options.targetMimeType ?? "application/vnd.google-apps.document";
const fields = "id, name, mimeType, webViewLink, parents";
const media = {
mimeType: sourceMimeType,
body: createReadStream(options.htmlPath),
};
const updateId = options.updateId;
if (updateId) {
const res = await withBackoff(
() =>
client.files.update({
fileId: updateId,
media,
fields,
supportsAllDrives: true,
requestBody: { mimeType: targetMimeType },
}),
{ label: "create-doc(update)" },
);
return formatFile(res.data);
}
const requestBody: drive_v3.Schema$File = {
name: options.name,
mimeType: targetMimeType,
};
if (options.parentId) requestBody.parents = [options.parentId];
const res = await withBackoff(
() =>
client.files.create({
requestBody,
media,
fields,
supportsAllDrives: true,
}),
{ label: "create-doc" },
);
return formatFile(res.data);
}
/**
* List files in a folder
*/
export async function listFiles(
client: drive_v3.Drive,
options: ListOptions,
): Promise<DriveFile[]> {
const { folderId, maxResults = 100 } = options;
const query = `'${folderId}' in parents and trashed = false`;
const res = await withBackoff(
() =>
client.files.list({
q: query,
pageSize: maxResults,
fields:
"files(id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, webContentLink)",
orderBy: "name",
}),
{ label: "list" },
);
return (res.data.files ?? []).map(formatFile);
}
/**
* Search files across Drive
*/
export async function searchFiles(
client: drive_v3.Drive,
options: SearchOptions,
): Promise<DriveFile[]> {
const { query, maxResults = 100 } = options;
const res = await withBackoff(
() =>
client.files.list({
q: `${query} and trashed = false`,
pageSize: maxResults,
fields:
"files(id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, webContentLink)",
orderBy: "modifiedTime desc",
}),
{ label: "search" },
);
return (res.data.files ?? []).map(formatFile);
}
/**
* Get file metadata
*/
export async function getFile(
client: drive_v3.Drive,
fileId: string,
): Promise<DriveFile | null> {
try {
const res = await withBackoff(
() =>
client.files.get({
fileId,
fields:
"id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, webContentLink",
}),
{ label: "info" },
);
return formatFile(res.data);
} catch (err) {
console.error("Error getting file:", err);
return null;
}
}
/**
* Download a file to local path
*/
export async function downloadFile(
client: drive_v3.Drive,
options: DownloadOptions,
onProgress?: (bytes: number, total: number) => void,
): Promise<void> {
const { fileId, outputPath } = options;
// Get file metadata first for size info
const meta = await client.files.get({
fileId,
fields: "id, name, mimeType, size",
});
const mimeType = meta.data.mimeType ?? "";
const totalSize = parseInt(meta.data.size ?? "0", 10);
// Ensure output directory exists
await mkdir(dirname(outputPath), { recursive: true });
// Handle Google Docs export vs regular download
if (mimeType.startsWith("application/vnd.google-apps.")) {
// Google Docs need to be exported
const exportMimeType = getExportMimeType(mimeType);
const res = await client.files.export(
{ fileId, mimeType: exportMimeType },
{ responseType: "stream" },
);
const stream = res.data as unknown as Readable;
const writeStream = createWriteStream(outputPath);
await pipeline(stream, writeStream);
} else {
// Regular file download
const res = await client.files.get(
{ fileId, alt: "media" },
{ responseType: "stream" },
);
const stream = res.data as unknown as Readable;
const writeStream = createWriteStream(outputPath);
let downloaded = 0;
stream.on("data", (chunk: Buffer) => {
downloaded += chunk.length;
onProgress?.(downloaded, totalSize);
});
await pipeline(stream, writeStream);
}
}
/**
* Get export MIME type for Google Docs
*/
function getExportMimeType(googleMimeType: string): string {
const exportMap: Record<string, string> = {
"application/vnd.google-apps.document":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.google-apps.spreadsheet":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.google-apps.presentation":
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.google-apps.drawing": "image/png",
};
return exportMap[googleMimeType] ?? "application/pdf";
}
/**
* Get file extension for export
*/
function getExportExtension(googleMimeType: string): string {
const extMap: Record<string, string> = {
"application/vnd.google-apps.document": ".docx",
"application/vnd.google-apps.spreadsheet": ".xlsx",
"application/vnd.google-apps.presentation": ".pptx",
"application/vnd.google-apps.drawing": ".png",
};
return extMap[googleMimeType] ?? ".pdf";
}
/**
* Sync (download) all files from a folder
*/
export async function syncFolder(
client: drive_v3.Drive,
options: SyncOptions,
onProgress?: (current: number, total: number, fileName: string) => void,
): Promise<DriveFile[]> {
const { folderId, outputDir, recursive = false, maxResults = 1000 } = options;
// Ensure output directory exists
await mkdir(outputDir, { recursive: true });
// List all files in folder
const files = await listFiles(client, { folderId, maxResults });
const downloaded: DriveFile[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
onProgress?.(i + 1, files.length, file.name);
if (file.mimeType === "application/vnd.google-apps.folder") {
// Handle subfolders if recursive
if (recursive) {
const subDir = join(outputDir, file.name);
const subFiles = await syncFolder(
client,
{ folderId: file.id, outputDir: subDir, recursive, maxResults },
onProgress,
);
downloaded.push(...subFiles);
}
} else {
// Download file
let outputPath = join(outputDir, file.name);
// Add extension for Google Docs exports
if (file.mimeType.startsWith("application/vnd.google-apps.")) {
outputPath += getExportExtension(file.mimeType);
}
try {
await downloadFile(client, { fileId: file.id, outputPath });
downloaded.push(file);
} catch (err) {
console.error(`Failed to download ${file.name}:`, err);
}
}
}
return downloaded;
}
/**
* Print file list in human-readable format
*/
export function printFiles(files: DriveFile[], verbose = false): void {
if (files.length === 0) {
console.log("No files found.");
return;
}
for (const file of files) {
if (verbose) {
const size = file.size
? `${Math.round(parseInt(file.size, 10) / 1024)}KB`
: "-";
const modified = file.modifiedTime
? new Date(file.modifiedTime).toLocaleDateString()
: "-";
console.log(`${file.id}\t${size}\t${modified}\t${file.name}`);
} else {
console.log(`${file.id}\t${file.name}`);
}
}
}
/**
* Print JSON output
*/
export function printJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
/**
* Print progress
*/
export function printProgress(
current: number,
total: number,
fileName?: string,
): void {
if (fileName) {
console.error(`[${current}/${total}] ${fileName}`);
} else {
console.error(`Progress: ${current}/${total}`);
}
}
/**
* Main entry point for Google Drive access library
*/
export * from "./auth.ts";
export * from "./config.ts";
export * from "./drive.ts";
export * from "./types.ts";
/**
* TypeScript interfaces for Google Drive access
*/
export interface DriveFile {
id: string;
name: string;
mimeType: string;
size?: string;
modifiedTime?: string;
createdTime?: string;
parents?: string[];
webViewLink?: string;
webContentLink?: string;
}
export interface OAuthCredentials {
installed: {
client_id: string;
client_secret: string;
redirect_uris: string[];
auth_uri: string;
token_uri: string;
};
}
export interface SavedToken {
access_token: string;
refresh_token?: string;
scope: string;
token_type: string;
expiry_date?: number;
}
export interface ListOptions {
folderId: string;
maxResults?: number;
verbose?: boolean;
}
export interface SearchOptions {
query: string;
maxResults?: number;
}
export interface DownloadOptions {
fileId: string;
outputPath: string;
}
export interface SyncOptions {
folderId: string;
outputDir: string;
recursive?: boolean;
maxResults?: number;
}
{
"name": "gdrive-access",
"version": "1.0.0",
"description": "Google Drive API client for Claude Code",
"type": "module",
"private": true,
"main": "lib/index.ts",
"bin": {
"gdrive": "cli.ts"
},
"scripts": {
"start": "bun run cli.ts",
"build": "bun build cli.ts --compile --outfile gdrive",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.0.0",
"typescript": "^5"
},
"dependencies": {
"@googleapis/drive": "^8.0.0"
}
}
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}