Commit 9d7b36888b85

Vincent Demeester <vincent@sbr.pm>
2026-02-18 14:18:38
feat: add GoogleWorkspace skill for agent API access
Added local OAuth-based Google Workspace skill for Drive, Docs, Calendar, Gmail, Sheets, Slides, and People APIs. Provides auth management and both generic and convenience API commands without requiring an MCP server.
1 parent 029c82f
Changed files (6)
dots/config/claude/skills/GoogleWorkspace/scripts/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+package-lock.json
dots/config/claude/skills/GoogleWorkspace/scripts/auth.js
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+
+import {
+  CREDENTIALS_PATH,
+  TOKEN_PATH,
+  DEFAULT_SCOPES,
+  authorize,
+  clearToken,
+  credentialsExist,
+  formatScopes,
+  loadToken,
+} from "./common.js";
+
+function printHelp() {
+  console.log(`Google Workspace auth helper
+
+Usage:
+  node auth.js login [--scopes scope1,scope2,...]
+  node auth.js status
+  node auth.js clear
+
+Environment overrides:
+  GOOGLE_WORKSPACE_CONFIG_DIR
+  GOOGLE_WORKSPACE_CREDENTIALS
+  GOOGLE_WORKSPACE_TOKEN
+`);
+}
+
+function parseScopes(args) {
+  const idx = args.indexOf("--scopes");
+  if (idx === -1) {
+    return DEFAULT_SCOPES;
+  }
+  const raw = args[idx + 1];
+  if (!raw) {
+    throw new Error("--scopes requires a value");
+  }
+  const scopes = formatScopes(raw);
+  if (scopes.length === 0) {
+    throw new Error("--scopes produced an empty scope list");
+  }
+  return scopes;
+}
+
+async function doLogin(args) {
+  const scopes = parseScopes(args);
+  await authorize({ scopes, interactive: true });
+  console.log("✅ Login successful. Token stored at:");
+  console.log(`   ${TOKEN_PATH}`);
+}
+
+function doStatus() {
+  console.log("Credentials file:");
+  console.log(`  ${CREDENTIALS_PATH}`);
+  console.log(`  Exists: ${credentialsExist() ? "yes" : "no"}`);
+
+  const token = loadToken();
+
+  console.log("\nToken file:");
+  console.log(`  ${TOKEN_PATH}`);
+  console.log(`  Exists: ${token ? "yes" : "no"}`);
+
+  if (!token) {
+    return;
+  }
+
+  const now = Date.now();
+  const expiry = token.expiry_date || null;
+  const expired = expiry ? expiry < now : null;
+  const scopeCount = formatScopes(token.scope).length;
+
+  console.log("\nToken details:");
+  console.log(`  access_token: ${token.access_token ? "present" : "missing"}`);
+  console.log(
+    `  refresh_token: ${token.refresh_token ? "present" : "missing"}`
+  );
+  console.log(`  scopes: ${scopeCount}`);
+
+  if (expiry) {
+    console.log(`  expiry_date: ${new Date(expiry).toISOString()}`);
+    console.log(`  expired: ${expired ? "yes" : "no"}`);
+  } else {
+    console.log("  expiry_date: n/a");
+  }
+}
+
+function doClear() {
+  clearToken();
+  console.log("✅ Token cleared.");
+}
+
+async function main() {
+  const [command, ...args] = process.argv.slice(2);
+
+  if (
+    !command ||
+    command === "help" ||
+    command === "--help" ||
+    command === "-h"
+  ) {
+    printHelp();
+    return;
+  }
+
+  if (command === "login") {
+    await doLogin(args);
+    return;
+  }
+
+  if (command === "status") {
+    doStatus();
+    return;
+  }
+
+  if (command === "clear") {
+    doClear();
+    return;
+  }
+
+  throw new Error(`Unknown command: ${command}`);
+}
+
+main().catch((error) => {
+  console.error(`❌ ${error.message}`);
+  process.exit(1);
+});
dots/config/claude/skills/GoogleWorkspace/scripts/common.js
@@ -0,0 +1,275 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { createRequire } from "node:module";
+
+// --- Configuration paths ---
+
+export const CONFIG_DIR =
+  process.env.GOOGLE_WORKSPACE_CONFIG_DIR ||
+  path.join(os.homedir(), ".config", "google-workspace");
+
+export const CREDENTIALS_PATH =
+  process.env.GOOGLE_WORKSPACE_CREDENTIALS ||
+  path.join(CONFIG_DIR, "credentials.json");
+
+export const TOKEN_PATH =
+  process.env.GOOGLE_WORKSPACE_TOKEN || path.join(CONFIG_DIR, "token.json");
+
+const SKILL_ROOT = path.join(path.dirname(new URL(import.meta.url).pathname));
+
+// --- Scopes ---
+
+export const DEFAULT_SCOPES = [
+  "https://www.googleapis.com/auth/documents",
+  "https://www.googleapis.com/auth/drive",
+  "https://www.googleapis.com/auth/calendar",
+  "https://www.googleapis.com/auth/userinfo.profile",
+  "https://www.googleapis.com/auth/gmail.modify",
+  "https://www.googleapis.com/auth/directory.readonly",
+  "https://www.googleapis.com/auth/presentations.readonly",
+  "https://www.googleapis.com/auth/spreadsheets.readonly",
+];
+
+export const DEFAULT_VERSIONS = {
+  calendar: "v3",
+  docs: "v1",
+  drive: "v3",
+  gmail: "v1",
+  people: "v1",
+  sheets: "v4",
+  slides: "v1",
+};
+
+let runtimeDeps;
+
+// --- Helpers ---
+
+function ensureConfigDir() {
+  fs.mkdirSync(CONFIG_DIR, { recursive: true });
+}
+
+function readJson(filePath) {
+  return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+function installDependencies() {
+  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
+  console.error("ℹ️  Installing Google Workspace skill dependencies...");
+
+  const result = spawnSync(npm, ["install", "--no-audit", "--no-fund"], {
+    cwd: SKILL_ROOT,
+    stdio: "inherit",
+  });
+
+  if (result.error) {
+    throw new Error(`Failed to run npm install: ${result.error.message}`);
+  }
+
+  if (result.status !== 0) {
+    throw new Error(`npm install failed with exit code ${result.status}`);
+  }
+}
+
+function loadRuntimeDeps() {
+  if (runtimeDeps) {
+    return runtimeDeps;
+  }
+
+  const require = createRequire(import.meta.url);
+
+  try {
+    const { google } = require("googleapis");
+    const { authenticate } = require("@google-cloud/local-auth");
+    runtimeDeps = { google, authenticate };
+    return runtimeDeps;
+  } catch (error) {
+    if (error && error.code === "MODULE_NOT_FOUND") {
+      installDependencies();
+      const { google } = require("googleapis");
+      const { authenticate } = require("@google-cloud/local-auth");
+      runtimeDeps = { google, authenticate };
+      return runtimeDeps;
+    }
+    throw error;
+  }
+}
+
+export function getGoogleApis() {
+  return loadRuntimeDeps().google;
+}
+
+export function credentialsExist() {
+  return fs.existsSync(CREDENTIALS_PATH);
+}
+
+function tokenExists() {
+  return fs.existsSync(TOKEN_PATH);
+}
+
+function loadCredentialsFile() {
+  if (!credentialsExist()) {
+    throw new Error(
+      `Missing OAuth credentials file at ${CREDENTIALS_PATH}.\n` +
+        "Create a Google Cloud OAuth Desktop client and save the JSON there.\n" +
+        "See: https://console.cloud.google.com/apis/credentials"
+    );
+  }
+  return readJson(CREDENTIALS_PATH);
+}
+
+export function loadToken() {
+  if (!tokenExists()) {
+    return null;
+  }
+  return readJson(TOKEN_PATH);
+}
+
+function createOAuthClientFromCredentials(credentialsJson) {
+  const creds = credentialsJson.installed || credentialsJson.web;
+  if (!creds) {
+    throw new Error(
+      'Invalid credentials.json: expected an "installed" or "web" key.'
+    );
+  }
+
+  if (!creds.client_id || !creds.client_secret) {
+    throw new Error(
+      "Invalid credentials.json: missing client_id or client_secret."
+    );
+  }
+
+  const redirectUri = Array.isArray(creds.redirect_uris)
+    ? creds.redirect_uris[0]
+    : undefined;
+
+  const google = getGoogleApis();
+  return new google.auth.OAuth2(
+    creds.client_id,
+    creds.client_secret,
+    redirectUri || "http://localhost"
+  );
+}
+
+function saveToken(token) {
+  ensureConfigDir();
+  fs.writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2));
+  try {
+    fs.chmodSync(TOKEN_PATH, 0o600);
+  } catch {
+    // Non-POSIX file systems can fail chmod. Ignore.
+  }
+}
+
+export function clearToken() {
+  if (tokenExists()) {
+    fs.rmSync(TOKEN_PATH);
+  }
+}
+
+function isExpiringSoon(credentials) {
+  if (!credentials || !credentials.expiry_date) {
+    return false;
+  }
+  return credentials.expiry_date < Date.now() + 60_000;
+}
+
+async function interactiveLogin(scopes) {
+  const { authenticate } = loadRuntimeDeps();
+
+  if (!credentialsExist()) {
+    throw new Error(
+      `Missing OAuth credentials file at ${CREDENTIALS_PATH}.\n\n` +
+        "Setup instructions:\n" +
+        "1. Go to https://console.cloud.google.com/apis/credentials\n" +
+        '2. Create an OAuth 2.0 Client ID (type: "Desktop app")\n' +
+        "3. Download the JSON and save it to:\n" +
+        `   ${CREDENTIALS_PATH}\n` +
+        "4. Enable the required APIs in your Google Cloud project:\n" +
+        "   - Google Calendar API\n" +
+        "   - Google Drive API\n" +
+        "   - Gmail API\n" +
+        "   - Google Docs API\n" +
+        "   - Google Sheets API\n" +
+        "   - Google Slides API\n" +
+        "   - People API"
+    );
+  }
+
+  console.error("ℹ️  Opening browser for Google OAuth login...");
+
+  const authClient = await authenticate({
+    keyfilePath: CREDENTIALS_PATH,
+    scopes,
+  });
+
+  if (!authClient.credentials || !authClient.credentials.access_token) {
+    throw new Error("Authentication failed: no access token returned.");
+  }
+
+  saveToken(authClient.credentials);
+  return authClient;
+}
+
+export async function authorize(options = {}) {
+  const scopes = options.scopes || DEFAULT_SCOPES;
+  const interactive = options.interactive !== false;
+
+  ensureConfigDir();
+  loadRuntimeDeps();
+
+  const token = loadToken();
+  const client = createOAuthClientFromCredentials(loadCredentialsFile());
+
+  if (!token) {
+    if (!interactive) {
+      throw new Error(
+        `No token found at ${TOKEN_PATH}. Run: node scripts/auth.js login`
+      );
+    }
+    return interactiveLogin(scopes);
+  }
+
+  client.setCredentials(token);
+
+  if (!isExpiringSoon(client.credentials)) {
+    return client;
+  }
+
+  // Try to refresh
+  if (client.credentials.refresh_token) {
+    const refreshed = await client.refreshAccessToken();
+    const merged = {
+      ...refreshed.credentials,
+      refresh_token:
+        refreshed.credentials.refresh_token || client.credentials.refresh_token,
+    };
+    client.setCredentials(merged);
+    saveToken(merged);
+    return client;
+  }
+
+  if (!interactive) {
+    throw new Error(
+      "Token is expired and no refresh token is available. Run login again."
+    );
+  }
+
+  return interactiveLogin(scopes);
+}
+
+export function formatScopes(value) {
+  if (!value) {
+    return [];
+  }
+  if (Array.isArray(value)) {
+    return value;
+  }
+  return String(value)
+    .split(/[\s,]+/)
+    .map((part) => part.trim())
+    .filter(Boolean);
+}
dots/config/claude/skills/GoogleWorkspace/scripts/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "google-workspace-skill",
+  "private": true,
+  "type": "module",
+  "dependencies": {
+    "@google-cloud/local-auth": "^3.0.1",
+    "googleapis": "^148.0.0"
+  }
+}
dots/config/claude/skills/GoogleWorkspace/scripts/workspace.js
@@ -0,0 +1,385 @@
+#!/usr/bin/env node
+
+import {
+  DEFAULT_VERSIONS,
+  authorize,
+  formatScopes,
+  getGoogleApis,
+} from "./common.js";
+
+function printHelp() {
+  console.log(`Google Workspace API helper
+
+Usage:
+  node workspace.js call <service> <method.path> [params-json] [--version vX] [--scopes s1,s2]
+  node workspace.js calendar-today [calendarId]
+  node workspace.js calendar-upcoming [days] [calendarId]
+  node workspace.js drive-search <query>
+  node workspace.js gmail-search <query>
+  node workspace.js gmail-read <messageId>
+
+Examples:
+  node workspace.js call drive files.list '{"pageSize":5,"fields":"files(id,name)"}'
+  node workspace.js call calendar events.list '{"calendarId":"primary","maxResults":10,"singleEvents":true,"orderBy":"startTime"}'
+  node workspace.js drive-search "name contains 'Roadmap' and trashed=false"
+  node workspace.js gmail-search "from:alice@example.com newer_than:7d"
+  node workspace.js gmail-read 18e1234abcd5678
+  node workspace.js calendar-upcoming 3
+`);
+}
+
+function parseOptions(argv) {
+  const positional = [];
+  const options = {
+    version: undefined,
+    scopes: undefined,
+  };
+
+  for (let i = 0; i < argv.length; i += 1) {
+    const arg = argv[i];
+    if (arg === "--version") {
+      options.version = argv[i + 1];
+      i += 1;
+      continue;
+    }
+    if (arg === "--scopes") {
+      options.scopes = formatScopes(argv[i + 1]);
+      i += 1;
+      continue;
+    }
+    positional.push(arg);
+  }
+
+  return { positional, options };
+}
+
+function resolveMethod(root, methodPath) {
+  const parts = methodPath.split(".").filter(Boolean);
+  if (parts.length === 0) {
+    throw new Error("method.path is empty");
+  }
+
+  let parent = root;
+  for (let i = 0; i < parts.length - 1; i += 1) {
+    parent = parent?.[parts[i]];
+    if (!parent) {
+      throw new Error(`Invalid method path (missing segment: ${parts[i]})`);
+    }
+  }
+
+  const methodName = parts[parts.length - 1];
+  const method = parent?.[methodName];
+
+  if (typeof method !== "function") {
+    throw new Error(
+      `Invalid method path: ${methodPath}. Final segment is not callable.`
+    );
+  }
+
+  return { parent, method };
+}
+
+function parseJsonObject(raw, label) {
+  if (!raw) {
+    return {};
+  }
+
+  let parsed;
+  try {
+    parsed = JSON.parse(raw);
+  } catch (error) {
+    throw new Error(`${label} is not valid JSON: ${error.message}`);
+  }
+
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+    throw new Error(`${label} must be a JSON object`);
+  }
+
+  return parsed;
+}
+
+async function callApi({ service, methodPath, params, version, scopes }) {
+  const google = getGoogleApis();
+  const factory = google[service];
+  if (typeof factory !== "function") {
+    throw new Error(`Unknown Google API service: ${service}`);
+  }
+
+  const auth = await authorize({
+    interactive: true,
+    scopes: scopes && scopes.length > 0 ? scopes : undefined,
+  });
+
+  const api = factory({
+    version: version || DEFAULT_VERSIONS[service] || "v1",
+    auth,
+  });
+
+  const { parent, method } = resolveMethod(api, methodPath);
+  const response = await method.call(parent, params);
+  return response?.data ?? response;
+}
+
+// --- Convenience commands ---
+
+async function cmdCall(args, options) {
+  const [service, methodPath, paramsRaw] = args;
+
+  if (!service || !methodPath) {
+    throw new Error("Usage: call <service> <method.path> [params-json]");
+  }
+
+  const params = parseJsonObject(paramsRaw, "params-json");
+  const data = await callApi({
+    service,
+    methodPath,
+    params,
+    version: options.version,
+    scopes: options.scopes,
+  });
+
+  console.log(JSON.stringify(data, null, 2));
+}
+
+async function cmdCalendarToday(args, options) {
+  const calendarId = args[0] || "primary";
+
+  const start = new Date();
+  start.setHours(0, 0, 0, 0);
+
+  const end = new Date(start);
+  end.setDate(end.getDate() + 1);
+
+  const data = await callApi({
+    service: "calendar",
+    methodPath: "events.list",
+    version: options.version,
+    scopes: options.scopes,
+    params: {
+      calendarId,
+      timeMin: start.toISOString(),
+      timeMax: end.toISOString(),
+      singleEvents: true,
+      orderBy: "startTime",
+    },
+  });
+
+  console.log(JSON.stringify(data, null, 2));
+}
+
+async function cmdCalendarUpcoming(args, options) {
+  const days = parseInt(args[0], 10) || 7;
+  const calendarId = args[1] || "primary";
+
+  const start = new Date();
+  const end = new Date(start);
+  end.setDate(end.getDate() + days);
+
+  const data = await callApi({
+    service: "calendar",
+    methodPath: "events.list",
+    version: options.version,
+    scopes: options.scopes,
+    params: {
+      calendarId,
+      timeMin: start.toISOString(),
+      timeMax: end.toISOString(),
+      singleEvents: true,
+      orderBy: "startTime",
+    },
+  });
+
+  console.log(JSON.stringify(data, null, 2));
+}
+
+async function cmdDriveSearch(args, options) {
+  const query = args.join(" ").trim();
+  if (!query) {
+    throw new Error("Usage: drive-search <query>");
+  }
+
+  const data = await callApi({
+    service: "drive",
+    methodPath: "files.list",
+    version: options.version,
+    scopes: options.scopes,
+    params: {
+      q: query,
+      pageSize: 20,
+      fields:
+        "nextPageToken, files(id,name,mimeType,modifiedTime,webViewLink)",
+    },
+  });
+
+  console.log(JSON.stringify(data, null, 2));
+}
+
+async function cmdGmailSearch(args, options) {
+  const query = args.join(" ").trim();
+  if (!query) {
+    throw new Error("Usage: gmail-search <query>");
+  }
+
+  const list = await callApi({
+    service: "gmail",
+    methodPath: "users.messages.list",
+    version: options.version,
+    scopes: options.scopes,
+    params: {
+      userId: "me",
+      q: query,
+      maxResults: 20,
+    },
+  });
+
+  const messages = list.messages || [];
+  const details = [];
+
+  for (const message of messages.slice(0, 10)) {
+    const full = await callApi({
+      service: "gmail",
+      methodPath: "users.messages.get",
+      version: options.version,
+      scopes: options.scopes,
+      params: {
+        userId: "me",
+        id: message.id,
+        format: "metadata",
+        metadataHeaders: ["From", "To", "Subject", "Date"],
+      },
+    });
+
+    details.push({
+      id: message.id,
+      threadId: message.threadId,
+      snippet: full.snippet,
+      payload: full.payload,
+    });
+  }
+
+  console.log(
+    JSON.stringify(
+      {
+        resultCount: messages.length,
+        messages: details,
+      },
+      null,
+      2
+    )
+  );
+}
+
+async function cmdGmailRead(args, options) {
+  const messageId = args[0];
+  if (!messageId) {
+    throw new Error("Usage: gmail-read <messageId>");
+  }
+
+  const data = await callApi({
+    service: "gmail",
+    methodPath: "users.messages.get",
+    version: options.version,
+    scopes: options.scopes,
+    params: {
+      userId: "me",
+      id: messageId,
+      format: "full",
+    },
+  });
+
+  // Extract headers
+  const headers = {};
+  if (data.payload && data.payload.headers) {
+    for (const h of data.payload.headers) {
+      headers[h.name] = h.value;
+    }
+  }
+
+  // Extract body text
+  let body = "";
+  function extractText(part) {
+    if (part.mimeType === "text/plain" && part.body && part.body.data) {
+      body += Buffer.from(part.body.data, "base64url").toString("utf8");
+    }
+    if (part.parts) {
+      for (const p of part.parts) {
+        extractText(p);
+      }
+    }
+  }
+
+  if (data.payload) {
+    extractText(data.payload);
+  }
+
+  console.log(
+    JSON.stringify(
+      {
+        id: data.id,
+        threadId: data.threadId,
+        from: headers["From"],
+        to: headers["To"],
+        subject: headers["Subject"],
+        date: headers["Date"],
+        snippet: data.snippet,
+        body: body || "(no plain text body)",
+      },
+      null,
+      2
+    )
+  );
+}
+
+// --- Main ---
+
+async function main() {
+  const { positional, options } = parseOptions(process.argv.slice(2));
+  const [command, ...args] = positional;
+
+  if (
+    !command ||
+    command === "help" ||
+    command === "--help" ||
+    command === "-h"
+  ) {
+    printHelp();
+    return;
+  }
+
+  if (command === "call") {
+    await cmdCall(args, options);
+    return;
+  }
+
+  if (command === "calendar-today") {
+    await cmdCalendarToday(args, options);
+    return;
+  }
+
+  if (command === "calendar-upcoming") {
+    await cmdCalendarUpcoming(args, options);
+    return;
+  }
+
+  if (command === "drive-search") {
+    await cmdDriveSearch(args, options);
+    return;
+  }
+
+  if (command === "gmail-search") {
+    await cmdGmailSearch(args, options);
+    return;
+  }
+
+  if (command === "gmail-read") {
+    await cmdGmailRead(args, options);
+    return;
+  }
+
+  throw new Error(`Unknown command: ${command}`);
+}
+
+main().catch((error) => {
+  console.error(`❌ ${error.message}`);
+  process.exit(1);
+});
dots/config/claude/skills/GoogleWorkspace/SKILL.md
@@ -0,0 +1,169 @@
+---
+name: GoogleWorkspace
+description: Access Google Workspace APIs (Drive, Docs, Calendar, Gmail, Sheets, Slides, People) via local helper scripts. Handles OAuth login and direct API calls. USE WHEN user wants to search Google Drive, check calendar, search Gmail, read Google Docs, or interact with any Google Workspace service.
+---
+
+# Google Workspace
+
+Access Google Workspace APIs from the agent via local Node.js helper scripts — no MCP server needed.
+
+Supports: Drive, Docs, Calendar, Gmail, Sheets, Slides, People.
+
+## Files
+
+All scripts live in the skill directory:
+
+- `scripts/auth.js` — login / status / clear token
+- `scripts/workspace.js` — call APIs (generic and convenience commands)
+- `scripts/common.js` — shared auth logic
+
+**Script base directory:** `~/.config/claude/skills/GoogleWorkspace/scripts`
+
+## One-Time Setup
+
+### 1. Create OAuth Credentials
+
+1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials)
+2. Create an OAuth 2.0 Client ID (type: **Desktop app**)
+3. Download the JSON and save it to:
+
+```
+~/.config/google-workspace/credentials.json
+```
+
+4. Enable these APIs in your Google Cloud project:
+   - Google Calendar API
+   - Google Drive API
+   - Gmail API
+   - Google Docs API
+   - Google Sheets API
+   - Google Slides API
+   - People API
+
+### 2. Install Dependencies
+
+Dependencies auto-install on first script run. To prewarm manually:
+
+```bash
+cd ~/.config/claude/skills/GoogleWorkspace/scripts
+npm install
+```
+
+### 3. Authenticate
+
+```bash
+cd ~/.config/claude/skills/GoogleWorkspace/scripts
+node auth.js login
+```
+
+This opens the browser for OAuth consent. Token is stored at `~/.config/google-workspace/token.json`.
+
+## Operational Guidance for the Agent
+
+1. **Always check auth first:** Run `node auth.js status` before making API calls.
+2. **If auth is missing/expired:** Run `node auth.js login` and wait for the user to complete browser consent.
+3. **Do not explain setup** unless a command actually failed and its error output requires user action.
+4. **Use `workspace.js call`** for precise operations and return raw JSON results.
+5. **For user-friendly output:** Post-process JSON after the call — summarize, format tables, extract key info.
+6. **Never print token contents** back to the user.
+7. **Always `cd` into the scripts directory** before running commands.
+
+## API Usage
+
+### Generic API Call
+
+```bash
+cd ~/.config/claude/skills/GoogleWorkspace/scripts
+node workspace.js call <service> <method.path> '<json params>'
+```
+
+Services: `drive`, `docs`, `calendar`, `gmail`, `sheets`, `slides`, `people`
+
+Examples:
+
+```bash
+# List 5 Drive files
+node workspace.js call drive files.list '{"pageSize":5,"fields":"files(id,name)"}'
+
+# Get today's calendar events
+node workspace.js call calendar events.list '{"calendarId":"primary","maxResults":10,"singleEvents":true,"orderBy":"startTime"}'
+
+# Get a Google Doc
+node workspace.js call docs documents.get '{"documentId":"<DOC_ID>"}'
+
+# List Gmail labels
+node workspace.js call gmail users.labels.list '{"userId":"me"}'
+
+# Read a spreadsheet
+node workspace.js call sheets spreadsheets.values.get '{"spreadsheetId":"<SHEET_ID>","range":"Sheet1!A1:D10"}'
+```
+
+### Convenience Commands
+
+```bash
+cd ~/.config/claude/skills/GoogleWorkspace/scripts
+
+# Today's calendar events
+node workspace.js calendar-today
+
+# Next N days of events (default: 7)
+node workspace.js calendar-upcoming 3
+
+# Search Google Drive
+node workspace.js drive-search "name contains 'Roadmap' and trashed=false"
+
+# Search Gmail
+node workspace.js gmail-search "from:alice@example.com newer_than:7d"
+
+# Read a specific Gmail message (full body)
+node workspace.js gmail-read <messageId>
+```
+
+### Auth Commands
+
+```bash
+cd ~/.config/claude/skills/GoogleWorkspace/scripts
+
+# Check auth status
+node auth.js status
+
+# Login (opens browser)
+node auth.js login
+
+# Clear stored token
+node auth.js clear
+```
+
+## Environment Overrides
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `GOOGLE_WORKSPACE_CONFIG_DIR` | `~/.config/google-workspace` | Config directory |
+| `GOOGLE_WORKSPACE_CREDENTIALS` | `<config_dir>/credentials.json` | OAuth credentials file |
+| `GOOGLE_WORKSPACE_TOKEN` | `<config_dir>/token.json` | Stored token file |
+
+## Common Google API Patterns
+
+### Drive: Search by type
+```bash
+node workspace.js drive-search "mimeType='application/vnd.google-apps.document'"
+node workspace.js drive-search "mimeType='application/vnd.google-apps.spreadsheet'"
+node workspace.js drive-search "mimeType='application/vnd.google-apps.presentation'"
+```
+
+### Drive: Recent files
+```bash
+node workspace.js call drive files.list '{"pageSize":10,"orderBy":"modifiedTime desc","fields":"files(id,name,mimeType,modifiedTime,webViewLink)"}'
+```
+
+### Gmail: Common queries
+```bash
+node workspace.js gmail-search "is:unread newer_than:1d"
+node workspace.js gmail-search "has:attachment newer_than:7d"
+node workspace.js gmail-search "in:sent newer_than:1d"
+```
+
+### Calendar: Specific calendar
+```bash
+node workspace.js calendar-today "work@redhat.com"
+```