runline 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1161 @@
1
+ /**
2
+ * Google Drive plugin for runline.
3
+ *
4
+ * Authentication mirrors gmail / googleCalendar: OAuth2 user flow,
5
+ * seeded via `runline auth googleDrive`. Token refresh is lazy
6
+ * (60 s skew) and cached on the connection.
7
+ *
8
+ * Surface area:
9
+ *
10
+ * file.upload / file.createFromText / file.download /
11
+ * file.copy / file.move / file.update / file.delete /
12
+ * file.share / file.get
13
+ *
14
+ * folder.create / folder.delete / folder.share
15
+ *
16
+ * fileFolder.search (unified search with filter sugar)
17
+ *
18
+ * drive.create / drive.get / drive.list / drive.update / drive.delete
19
+ * (Shared Drives / Team Drives)
20
+ *
21
+ * Binary content conventions — every upload/download surface
22
+ * speaks base64 or filesystem paths:
23
+ *
24
+ * • upload / createFromText / update accept either:
25
+ * contentBase64 — base64-encoded bytes
26
+ * contentPath — filesystem path, read at call time
27
+ * content — utf-8 string (createFromText only)
28
+ *
29
+ * • download returns { name, mimeType, contentBase64 } by default,
30
+ * or writes to disk when `savePath` is provided and returns the
31
+ * path it wrote to.
32
+ *
33
+ * Uploads use multipart/related (`uploadType=multipart`) up to ~5 MB,
34
+ * and resumable uploads (`uploadType=resumable`) with 2 MiB chunks
35
+ * for larger files, driven off whether the caller supplies bytes
36
+ * (Buffer) vs a path (streamed).
37
+ */
38
+ import { createReadStream, readFileSync, statSync, writeFileSync } from "node:fs";
39
+ // ─── MIME constants ──────────────────────────────────────────────
40
+ const DRIVE = {
41
+ FOLDER: "application/vnd.google-apps.folder",
42
+ DOCUMENT: "application/vnd.google-apps.document",
43
+ SPREADSHEET: "application/vnd.google-apps.spreadsheet",
44
+ PRESENTATION: "application/vnd.google-apps.presentation",
45
+ DRAWING: "application/vnd.google-apps.drawing",
46
+ FORM: "application/vnd.google-apps.form",
47
+ AUDIO: "application/vnd.google-apps.audio",
48
+ VIDEO: "application/vnd.google-apps.video",
49
+ PHOTO: "application/vnd.google-apps.photo",
50
+ MAP: "application/vnd.google-apps.map",
51
+ SITES: "application/vnd.google-apps.sites",
52
+ APP_SCRIPTS: "application/vnd.google-apps.script",
53
+ SDK: "application/vnd.google-apps.drive-sdk",
54
+ FILE: "application/vnd.google-apps.file",
55
+ FUSIONTABLE: "application/vnd.google-apps.fusiontable",
56
+ UNKNOWN: "application/vnd.google-apps.unknown",
57
+ };
58
+ // ─── OAuth ───────────────────────────────────────────────────────
59
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
60
+ const REFRESH_SKEW_MS = 60_000;
61
+ async function refreshAccessToken(ctx) {
62
+ const cfg = ctx.connection.config;
63
+ const { clientId, clientSecret, refreshToken } = cfg;
64
+ if (!clientId || !clientSecret || !refreshToken) {
65
+ throw new Error("googleDrive: missing clientId/clientSecret/refreshToken. Run the Google Drive OAuth helper to seed these.");
66
+ }
67
+ const body = new URLSearchParams({
68
+ client_id: clientId,
69
+ client_secret: clientSecret,
70
+ refresh_token: refreshToken,
71
+ grant_type: "refresh_token",
72
+ });
73
+ const res = await fetch(TOKEN_ENDPOINT, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
76
+ body: body.toString(),
77
+ });
78
+ if (!res.ok) {
79
+ const text = await res.text();
80
+ throw new Error(`googleDrive: token refresh failed (${res.status}): ${text}`);
81
+ }
82
+ const data = (await res.json());
83
+ const expiresAt = Date.now() + data.expires_in * 1000;
84
+ await ctx.updateConnection({
85
+ accessToken: data.access_token,
86
+ accessTokenExpiresAt: expiresAt,
87
+ });
88
+ return data.access_token;
89
+ }
90
+ async function accessToken(ctx) {
91
+ const cfg = ctx.connection.config;
92
+ if (cfg.accessToken &&
93
+ typeof cfg.accessTokenExpiresAt === "number" &&
94
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
95
+ return cfg.accessToken;
96
+ }
97
+ return refreshAccessToken(ctx);
98
+ }
99
+ // ─── Request ─────────────────────────────────────────────────────
100
+ const API_BASE = "https://www.googleapis.com";
101
+ async function driveRequest(ctx, method, path, body, qs) {
102
+ const token = await accessToken(ctx);
103
+ const url = new URL(`${API_BASE}${path}`);
104
+ if (qs) {
105
+ for (const [k, v] of Object.entries(qs)) {
106
+ if (v === undefined || v === null)
107
+ continue;
108
+ if (Array.isArray(v)) {
109
+ for (const entry of v)
110
+ url.searchParams.append(k, String(entry));
111
+ }
112
+ else {
113
+ url.searchParams.set(k, String(v));
114
+ }
115
+ }
116
+ }
117
+ const init = {
118
+ method,
119
+ headers: {
120
+ Authorization: `Bearer ${token}`,
121
+ Accept: "application/json",
122
+ },
123
+ };
124
+ if (body && Object.keys(body).length > 0) {
125
+ init.headers["Content-Type"] = "application/json";
126
+ init.body = JSON.stringify(body);
127
+ }
128
+ const res = await fetch(url.toString(), init);
129
+ if (res.status === 204)
130
+ return { success: true };
131
+ const text = await res.text();
132
+ if (!res.ok) {
133
+ throw new Error(`googleDrive: ${method} ${path} → ${res.status} ${text}`);
134
+ }
135
+ return text ? JSON.parse(text) : { success: true };
136
+ }
137
+ /**
138
+ * Paginate any Drive list endpoint by repeatedly following
139
+ * `nextPageToken`. Works for `/drive/v3/files`, `/drive/v3/drives`,
140
+ * etc. Returns the concatenated `key` arrays.
141
+ */
142
+ async function paginateAll(ctx, path, key, qs) {
143
+ const out = [];
144
+ const query = { ...qs, pageSize: qs.pageSize ?? 100 };
145
+ do {
146
+ const page = (await driveRequest(ctx, "GET", path, undefined, query));
147
+ const items = page[key] ?? [];
148
+ out.push(...items);
149
+ query.pageToken = page.nextPageToken;
150
+ } while (query.pageToken);
151
+ return out;
152
+ }
153
+ // ─── Binary I/O helpers ─────────────────────────────────────────
154
+ /**
155
+ * Resolve whichever of `contentBase64` / `contentPath` / `content` the
156
+ * caller provided into a Buffer, plus a best-guess filename and
157
+ * mimeType. For resumable uploads we keep paths as paths so we can
158
+ * stream instead of buffering 500 MB in memory.
159
+ */
160
+ function resolveContent(p) {
161
+ if (typeof p.contentBase64 === "string") {
162
+ const buf = Buffer.from(p.contentBase64, "base64");
163
+ return {
164
+ buffer: buf,
165
+ size: buf.byteLength,
166
+ mimeType: p.mimeType,
167
+ fileName: p.name,
168
+ };
169
+ }
170
+ if (typeof p.content === "string") {
171
+ const buf = Buffer.from(p.content, "utf-8");
172
+ return {
173
+ buffer: buf,
174
+ size: buf.byteLength,
175
+ mimeType: p.mimeType ?? "text/plain",
176
+ fileName: p.name,
177
+ };
178
+ }
179
+ if (typeof p.contentPath === "string") {
180
+ const stat = statSync(p.contentPath);
181
+ // Under 5 MiB → buffer it for a simple multipart upload. Larger
182
+ // files go through resumable with a streamed ReadStream.
183
+ const BUFFER_THRESHOLD = 5 * 1024 * 1024;
184
+ const fileName = p.name ?? p.contentPath.split("/").pop();
185
+ if (stat.size <= BUFFER_THRESHOLD) {
186
+ return {
187
+ buffer: readFileSync(p.contentPath),
188
+ size: stat.size,
189
+ mimeType: p.mimeType,
190
+ fileName,
191
+ };
192
+ }
193
+ return {
194
+ path: p.contentPath,
195
+ size: stat.size,
196
+ mimeType: p.mimeType,
197
+ fileName,
198
+ };
199
+ }
200
+ throw new Error("googleDrive: provide one of contentBase64 / contentPath / content");
201
+ }
202
+ const MULTIPART_BOUNDARY = "runline_drive_boundary";
203
+ /**
204
+ * Build a multipart/related body for Drive's simple upload endpoint.
205
+ * Spec: https://developers.google.com/drive/api/guides/manage-uploads#multipart
206
+ */
207
+ function buildMultipart(metadata, content, mimeType) {
208
+ const boundary = MULTIPART_BOUNDARY;
209
+ const meta = `--${boundary}\r\n` +
210
+ `Content-Type: application/json; charset=UTF-8\r\n\r\n` +
211
+ `${JSON.stringify(metadata)}\r\n` +
212
+ `--${boundary}\r\n` +
213
+ `Content-Type: ${mimeType}\r\n\r\n`;
214
+ const tail = `\r\n--${boundary}--`;
215
+ return Buffer.concat([Buffer.from(meta, "utf-8"), content, Buffer.from(tail, "utf-8")]);
216
+ }
217
+ /**
218
+ * Upload bytes to Drive. Picks multipart vs resumable based on whether
219
+ * `c` holds an in-memory Buffer (small) or a filesystem path (streamed).
220
+ * Returns Drive's file resource (`{ id, name, ... }`).
221
+ */
222
+ async function uploadBytes(ctx, metadata, c, extraQs = {}) {
223
+ const mimeType = c.mimeType ?? "application/octet-stream";
224
+ const token = await accessToken(ctx);
225
+ if (c.buffer) {
226
+ const body = buildMultipart(metadata, c.buffer, mimeType);
227
+ const url = new URL(`${API_BASE}/upload/drive/v3/files`);
228
+ url.searchParams.set("uploadType", "multipart");
229
+ url.searchParams.set("supportsAllDrives", "true");
230
+ for (const [k, v] of Object.entries(extraQs)) {
231
+ if (v !== undefined)
232
+ url.searchParams.set(k, String(v));
233
+ }
234
+ const res = await fetch(url.toString(), {
235
+ method: "POST",
236
+ headers: {
237
+ Authorization: `Bearer ${token}`,
238
+ "Content-Type": `multipart/related; boundary=${MULTIPART_BOUNDARY}`,
239
+ "Content-Length": String(body.byteLength),
240
+ },
241
+ // Buffer is a Uint8Array so it's a valid BodyInit.
242
+ body: new Uint8Array(body),
243
+ });
244
+ const text = await res.text();
245
+ if (!res.ok) {
246
+ throw new Error(`googleDrive: upload failed (${res.status}): ${text}`);
247
+ }
248
+ return JSON.parse(text);
249
+ }
250
+ // Resumable: initiate, then PUT chunks.
251
+ const initUrl = new URL(`${API_BASE}/upload/drive/v3/files`);
252
+ initUrl.searchParams.set("uploadType", "resumable");
253
+ initUrl.searchParams.set("supportsAllDrives", "true");
254
+ for (const [k, v] of Object.entries(extraQs)) {
255
+ if (v !== undefined)
256
+ initUrl.searchParams.set(k, String(v));
257
+ }
258
+ const initRes = await fetch(initUrl.toString(), {
259
+ method: "POST",
260
+ headers: {
261
+ Authorization: `Bearer ${token}`,
262
+ "Content-Type": "application/json; charset=UTF-8",
263
+ "X-Upload-Content-Type": mimeType,
264
+ "X-Upload-Content-Length": String(c.size),
265
+ },
266
+ body: JSON.stringify(metadata),
267
+ });
268
+ if (!initRes.ok) {
269
+ const t = await initRes.text();
270
+ throw new Error(`googleDrive: resumable init failed (${initRes.status}): ${t}`);
271
+ }
272
+ const uploadUrl = initRes.headers.get("location");
273
+ if (!uploadUrl)
274
+ throw new Error("googleDrive: resumable session missing Location header");
275
+ // Stream in 2 MiB chunks. Must be a multiple of 256 KiB per Drive docs.
276
+ const CHUNK_SIZE = 2 * 1024 * 1024;
277
+ const stream = createReadStream(c.path, { highWaterMark: CHUNK_SIZE });
278
+ let offset = 0;
279
+ let pending = Buffer.alloc(0);
280
+ let lastBody = "";
281
+ let lastStatus = 0;
282
+ const flushChunk = async (chunk, isLast) => {
283
+ const start = offset;
284
+ const end = offset + chunk.byteLength - 1;
285
+ const res = await fetch(uploadUrl, {
286
+ method: "PUT",
287
+ headers: {
288
+ "Content-Length": String(chunk.byteLength),
289
+ "Content-Range": `bytes ${start}-${end}/${c.size}`,
290
+ },
291
+ body: new Uint8Array(chunk),
292
+ });
293
+ offset += chunk.byteLength;
294
+ lastStatus = res.status;
295
+ // 308 == "Resume Incomplete" — expected for all but the final chunk.
296
+ if (res.status === 200 || res.status === 201) {
297
+ lastBody = await res.text();
298
+ }
299
+ else if (res.status === 308) {
300
+ // Discard body, keep streaming.
301
+ await res.text();
302
+ }
303
+ else {
304
+ const t = await res.text();
305
+ throw new Error(`googleDrive: resumable chunk failed (${res.status}): ${t}`);
306
+ }
307
+ void isLast;
308
+ };
309
+ for await (const chunk of stream) {
310
+ pending = Buffer.concat([pending, chunk]);
311
+ while (pending.byteLength >= CHUNK_SIZE) {
312
+ const head = pending.subarray(0, CHUNK_SIZE);
313
+ pending = pending.subarray(CHUNK_SIZE);
314
+ await flushChunk(head, false);
315
+ }
316
+ }
317
+ if (pending.byteLength > 0) {
318
+ await flushChunk(pending, true);
319
+ }
320
+ if (lastStatus !== 200 && lastStatus !== 201) {
321
+ throw new Error(`googleDrive: resumable upload ended with status ${lastStatus} and no file resource`);
322
+ }
323
+ return JSON.parse(lastBody);
324
+ }
325
+ // ─── Shared helpers ─────────────────────────────────────────────
326
+ function toStringArray(v) {
327
+ if (v === undefined || v === null)
328
+ return undefined;
329
+ if (Array.isArray(v))
330
+ return v.map(String);
331
+ if (typeof v === "string") {
332
+ return v
333
+ .split(",")
334
+ .map((s) => s.trim())
335
+ .filter((s) => s.length > 0);
336
+ }
337
+ return undefined;
338
+ }
339
+ /**
340
+ * Apply the "My Drive / shared drive / folder" scoping rules on
341
+ * file/folder list & search queries. A non-default `driveId`
342
+ * narrows corpora to that drive; otherwise we list the user's
343
+ * corpus.
344
+ */
345
+ function applyDriveScopes(qs, driveId) {
346
+ if (driveId) {
347
+ qs.driveId = driveId;
348
+ qs.corpora = "drive";
349
+ qs.includeItemsFromAllDrives = true;
350
+ qs.supportsAllDrives = true;
351
+ }
352
+ else {
353
+ qs.corpora = "user";
354
+ qs.spaces = "drive";
355
+ qs.includeItemsFromAllDrives = false;
356
+ qs.supportsAllDrives = false;
357
+ }
358
+ }
359
+ function resolveParent(folderId, driveId) {
360
+ if (folderId && folderId !== "root")
361
+ return folderId;
362
+ if (driveId)
363
+ return driveId;
364
+ return "root";
365
+ }
366
+ function uuid() {
367
+ const g = globalThis;
368
+ if (g.crypto?.randomUUID)
369
+ return g.crypto.randomUUID();
370
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
371
+ }
372
+ // ─── Plugin ──────────────────────────────────────────────────────
373
+ const SCOPES = ["https://www.googleapis.com/auth/drive"];
374
+ export default function googleDrive(rl) {
375
+ rl.setName("googleDrive");
376
+ rl.setVersion("0.1.0");
377
+ rl.setOAuth({
378
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
379
+ tokenUrl: "https://oauth2.googleapis.com/token",
380
+ scopes: SCOPES,
381
+ authParams: { access_type: "offline", prompt: "consent" },
382
+ setupHelp: [
383
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
384
+ "",
385
+ "1. Create or pick a Google Cloud project:",
386
+ " https://console.cloud.google.com/projectcreate",
387
+ "",
388
+ "2. Enable the Google Drive API:",
389
+ " https://console.cloud.google.com/apis/library/drive.googleapis.com",
390
+ "",
391
+ "3. Configure the OAuth consent screen:",
392
+ " https://console.cloud.google.com/apis/credentials/consent",
393
+ " • Audience: External",
394
+ "",
395
+ "4. Add yourself as a test user:",
396
+ " https://console.cloud.google.com/auth/audience",
397
+ "",
398
+ "5. Create the OAuth client:",
399
+ " https://console.cloud.google.com/apis/credentials",
400
+ " • + Create credentials → OAuth client ID",
401
+ " • Application type: Web application",
402
+ " • Authorized redirect URIs → + Add URI: {{redirectUri}}",
403
+ "",
404
+ "6. Paste the Client ID and Client Secret below, or export",
405
+ " GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET.",
406
+ ],
407
+ });
408
+ rl.setConnectionSchema({
409
+ clientId: {
410
+ type: "string",
411
+ required: true,
412
+ description: "Google OAuth2 client ID",
413
+ env: "GOOGLE_DRIVE_CLIENT_ID",
414
+ },
415
+ clientSecret: {
416
+ type: "string",
417
+ required: true,
418
+ description: "Google OAuth2 client secret",
419
+ env: "GOOGLE_DRIVE_CLIENT_SECRET",
420
+ },
421
+ refreshToken: {
422
+ type: "string",
423
+ required: true,
424
+ description: "OAuth2 refresh token",
425
+ env: "GOOGLE_DRIVE_REFRESH_TOKEN",
426
+ },
427
+ accessToken: { type: "string", required: false },
428
+ accessTokenExpiresAt: { type: "number", required: false },
429
+ });
430
+ // ── File ──────────────────────────────────────────────
431
+ rl.registerAction("file.upload", {
432
+ description: "Upload a file to Drive. Supply one of contentBase64 / contentPath / content. Uses multipart for small files, resumable (2 MiB chunks) for large streamed paths.",
433
+ inputSchema: {
434
+ name: { type: "string", required: false, description: "File name in Drive" },
435
+ folderId: { type: "string", required: false, description: "Parent folder (default: root)" },
436
+ driveId: { type: "string", required: false, description: "Target shared drive" },
437
+ mimeType: { type: "string", required: false },
438
+ contentBase64: { type: "string", required: false },
439
+ contentPath: { type: "string", required: false, description: "Local filesystem path" },
440
+ content: { type: "string", required: false, description: "Inline utf-8 content" },
441
+ properties: {
442
+ type: "object",
443
+ required: false,
444
+ description: "Public key-value properties",
445
+ },
446
+ appProperties: {
447
+ type: "object",
448
+ required: false,
449
+ description: "App-private key-value properties",
450
+ },
451
+ keepRevisionForever: { type: "boolean", required: false },
452
+ ocrLanguage: { type: "string", required: false },
453
+ useContentAsIndexableText: { type: "boolean", required: false },
454
+ fields: {
455
+ type: "string",
456
+ required: false,
457
+ description: "Fields projection (default: '*' returns everything)",
458
+ },
459
+ },
460
+ async execute(input, ctx) {
461
+ const p = (input ?? {});
462
+ const content = resolveContent(p);
463
+ const metadata = {
464
+ name: content.fileName ?? "Untitled",
465
+ parents: [resolveParent(p.folderId, p.driveId)],
466
+ };
467
+ if (p.mimeType)
468
+ metadata.mimeType = p.mimeType;
469
+ if (p.properties)
470
+ metadata.properties = p.properties;
471
+ if (p.appProperties)
472
+ metadata.appProperties = p.appProperties;
473
+ const uploaded = await uploadBytes(ctx, metadata, content);
474
+ // The multipart upload endpoint returns a minimal resource
475
+ // (id, name, mimeType) and doesn't honor the `keepRevisionForever`
476
+ // / `ocrLanguage` / `useContentAsIndexableText` family, so
477
+ // we follow every upload with a PATCH to apply those and to
478
+ // project the full resource.
479
+ const qs = {
480
+ supportsAllDrives: true,
481
+ fields: p.fields ?? "*",
482
+ };
483
+ if (p.keepRevisionForever)
484
+ qs.keepRevisionForever = p.keepRevisionForever;
485
+ if (p.ocrLanguage)
486
+ qs.ocrLanguage = p.ocrLanguage;
487
+ if (p.useContentAsIndexableText)
488
+ qs.useContentAsIndexableText = p.useContentAsIndexableText;
489
+ return driveRequest(ctx, "PATCH", `/drive/v3/files/${uploaded.id}`, {}, qs);
490
+ },
491
+ });
492
+ rl.registerAction("file.createFromText", {
493
+ description: "Create a text file from inline content. Set convertToGoogleDocument=true to convert to a Google Doc.",
494
+ inputSchema: {
495
+ name: { type: "string", required: false, description: 'Default: "Untitled"' },
496
+ content: { type: "string", required: true },
497
+ folderId: { type: "string", required: false },
498
+ driveId: { type: "string", required: false },
499
+ convertToGoogleDocument: { type: "boolean", required: false },
500
+ properties: { type: "object", required: false },
501
+ appProperties: { type: "object", required: false },
502
+ },
503
+ async execute(input, ctx) {
504
+ const p = (input ?? {});
505
+ const name = p.name || "Untitled";
506
+ const asDoc = p.convertToGoogleDocument === true;
507
+ const mimeType = asDoc ? DRIVE.DOCUMENT : "text/plain";
508
+ if (asDoc) {
509
+ // For docs: create the file then fill it via docs.batchUpdate,
510
+ // Drive's upload would upload the raw text as an attachment
511
+ // instead of populating the document body.
512
+ const metadata = {
513
+ name,
514
+ mimeType,
515
+ parents: [resolveParent(p.folderId, p.driveId)],
516
+ };
517
+ if (p.properties)
518
+ metadata.properties = p.properties;
519
+ if (p.appProperties)
520
+ metadata.appProperties = p.appProperties;
521
+ const doc = (await driveRequest(ctx, "POST", "/drive/v3/files", metadata, {
522
+ supportsAllDrives: true,
523
+ }));
524
+ const token = await accessToken(ctx);
525
+ const res = await fetch(`https://docs.googleapis.com/v1/documents/${doc.id}:batchUpdate`, {
526
+ method: "POST",
527
+ headers: {
528
+ Authorization: `Bearer ${token}`,
529
+ "Content-Type": "application/json",
530
+ },
531
+ body: JSON.stringify({
532
+ requests: [
533
+ {
534
+ insertText: {
535
+ text: p.content,
536
+ endOfSegmentLocation: { segmentId: "" },
537
+ },
538
+ },
539
+ ],
540
+ }),
541
+ });
542
+ if (!res.ok) {
543
+ throw new Error(`googleDrive: docs.batchUpdate failed (${res.status}): ${await res.text()}`);
544
+ }
545
+ return { id: doc.id };
546
+ }
547
+ // Plain text: ordinary multipart upload.
548
+ const buffer = Buffer.from(String(p.content), "utf-8");
549
+ const metadata = {
550
+ name,
551
+ parents: [resolveParent(p.folderId, p.driveId)],
552
+ mimeType,
553
+ };
554
+ if (p.properties)
555
+ metadata.properties = p.properties;
556
+ if (p.appProperties)
557
+ metadata.appProperties = p.appProperties;
558
+ return uploadBytes(ctx, metadata, {
559
+ buffer,
560
+ size: buffer.byteLength,
561
+ mimeType,
562
+ });
563
+ },
564
+ });
565
+ rl.registerAction("file.download", {
566
+ description: "Download a file. Google-native docs are exported to the chosen format; regular files are downloaded as-is. Returns base64 by default, or writes to disk when savePath is set.",
567
+ inputSchema: {
568
+ fileId: { type: "string", required: true },
569
+ savePath: {
570
+ type: "string",
571
+ required: false,
572
+ description: "Write bytes to this filesystem path instead of returning base64",
573
+ },
574
+ googleDocFormat: {
575
+ type: "string",
576
+ required: false,
577
+ description: "Export MIME type for Google Docs (default: DOCX / PPTX / XLSX / image/jpeg by type)",
578
+ },
579
+ },
580
+ async execute(input, ctx) {
581
+ const p = (input ?? {});
582
+ const fileId = p.fileId;
583
+ const meta = (await driveRequest(ctx, "GET", `/drive/v3/files/${fileId}`, undefined, { fields: "mimeType,name", supportsAllDrives: true }));
584
+ const isGoogleNative = meta.mimeType?.includes("vnd.google-apps");
585
+ const token = await accessToken(ctx);
586
+ let url;
587
+ let contentType = meta.mimeType;
588
+ if (isGoogleNative) {
589
+ const type = meta.mimeType.split(".")[2];
590
+ const defaults = {
591
+ document: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
592
+ spreadsheet: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
593
+ presentation: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
594
+ drawing: "image/jpeg",
595
+ };
596
+ const mime = p.googleDocFormat ?? defaults[type] ?? "application/pdf";
597
+ contentType = mime;
598
+ const u = new URL(`${API_BASE}/drive/v3/files/${fileId}/export`);
599
+ u.searchParams.set("mimeType", mime);
600
+ u.searchParams.set("supportsAllDrives", "true");
601
+ url = u.toString();
602
+ }
603
+ else {
604
+ const u = new URL(`${API_BASE}/drive/v3/files/${fileId}`);
605
+ u.searchParams.set("alt", "media");
606
+ u.searchParams.set("supportsAllDrives", "true");
607
+ url = u.toString();
608
+ }
609
+ const res = await fetch(url, {
610
+ headers: { Authorization: `Bearer ${token}` },
611
+ });
612
+ if (!res.ok) {
613
+ throw new Error(`googleDrive: download failed (${res.status}): ${await res.text()}`);
614
+ }
615
+ const bytes = Buffer.from(await res.arrayBuffer());
616
+ const fileName = meta.name;
617
+ if (typeof p.savePath === "string") {
618
+ writeFileSync(p.savePath, bytes);
619
+ return { path: p.savePath, name: fileName, mimeType: contentType, size: bytes.byteLength };
620
+ }
621
+ return {
622
+ name: fileName,
623
+ mimeType: contentType,
624
+ size: bytes.byteLength,
625
+ contentBase64: bytes.toString("base64"),
626
+ };
627
+ },
628
+ });
629
+ rl.registerAction("file.copy", {
630
+ description: "Copy a file",
631
+ inputSchema: {
632
+ fileId: { type: "string", required: true },
633
+ name: { type: "string", required: false, description: 'Default: "Copy of {original}"' },
634
+ folderId: {
635
+ type: "string",
636
+ required: false,
637
+ description: "If omitted, copy stays in the same folder(s)",
638
+ },
639
+ driveId: { type: "string", required: false },
640
+ description: { type: "string", required: false },
641
+ copyRequiresWriterPermission: { type: "boolean", required: false },
642
+ },
643
+ async execute(input, ctx) {
644
+ const p = (input ?? {});
645
+ const body = {};
646
+ if (p.name)
647
+ body.name = p.name;
648
+ if (p.description)
649
+ body.description = p.description;
650
+ if (p.copyRequiresWriterPermission !== undefined) {
651
+ body.copyRequiresWriterPermission = p.copyRequiresWriterPermission;
652
+ }
653
+ if (p.folderId || p.driveId) {
654
+ body.parents = [resolveParent(p.folderId, p.driveId)];
655
+ }
656
+ return driveRequest(ctx, "POST", `/drive/v3/files/${p.fileId}/copy`, body, { supportsAllDrives: true });
657
+ },
658
+ });
659
+ rl.registerAction("file.move", {
660
+ description: "Move a file to another folder. Resolves current parents and swaps them in a single PATCH.",
661
+ inputSchema: {
662
+ fileId: { type: "string", required: true },
663
+ folderId: { type: "string", required: false, description: "Destination folder" },
664
+ driveId: { type: "string", required: false, description: "Destination shared drive" },
665
+ },
666
+ async execute(input, ctx) {
667
+ const p = (input ?? {});
668
+ const current = (await driveRequest(ctx, "GET", `/drive/v3/files/${p.fileId}`, undefined, { fields: "parents", supportsAllDrives: true }));
669
+ const removeParents = (current.parents ?? []).join(",");
670
+ const addParents = resolveParent(p.folderId, p.driveId);
671
+ return driveRequest(ctx, "PATCH", `/drive/v3/files/${p.fileId}`, undefined, {
672
+ supportsAllDrives: true,
673
+ addParents,
674
+ removeParents,
675
+ });
676
+ },
677
+ });
678
+ rl.registerAction("file.update", {
679
+ description: "Patch file metadata and/or replace its bytes. Supply content{Base64,Path} to update bytes.",
680
+ inputSchema: {
681
+ fileId: { type: "string", required: true },
682
+ name: { type: "string", required: false },
683
+ mimeType: { type: "string", required: false },
684
+ trashed: { type: "boolean", required: false, description: "Move to trash" },
685
+ properties: { type: "object", required: false },
686
+ appProperties: { type: "object", required: false },
687
+ contentBase64: { type: "string", required: false },
688
+ contentPath: { type: "string", required: false },
689
+ keepRevisionForever: { type: "boolean", required: false },
690
+ ocrLanguage: { type: "string", required: false },
691
+ useContentAsIndexableText: { type: "boolean", required: false },
692
+ fields: { type: "string", required: false, description: "Fields projection" },
693
+ },
694
+ async execute(input, ctx) {
695
+ const p = (input ?? {});
696
+ const fileId = p.fileId;
697
+ const hasBytes = p.contentBase64 !== undefined || p.contentPath !== undefined;
698
+ // Step 1: upload new bytes if provided. Drive requires PATCH on
699
+ // the upload endpoint for content replacement.
700
+ if (hasBytes) {
701
+ const c = resolveContent(p);
702
+ const mimeType = c.mimeType ?? p.mimeType ?? "application/octet-stream";
703
+ const token = await accessToken(ctx);
704
+ if (c.buffer) {
705
+ const url = new URL(`${API_BASE}/upload/drive/v3/files/${fileId}`);
706
+ url.searchParams.set("uploadType", "media");
707
+ url.searchParams.set("supportsAllDrives", "true");
708
+ const res = await fetch(url.toString(), {
709
+ method: "PATCH",
710
+ headers: {
711
+ Authorization: `Bearer ${token}`,
712
+ "Content-Type": mimeType,
713
+ "Content-Length": String(c.buffer.byteLength),
714
+ },
715
+ body: new Uint8Array(c.buffer),
716
+ });
717
+ if (!res.ok) {
718
+ throw new Error(`googleDrive: content update failed (${res.status}): ${await res.text()}`);
719
+ }
720
+ }
721
+ else if (c.path) {
722
+ // Resumable PATCH
723
+ const initUrl = new URL(`${API_BASE}/upload/drive/v3/files/${fileId}`);
724
+ initUrl.searchParams.set("uploadType", "resumable");
725
+ initUrl.searchParams.set("supportsAllDrives", "true");
726
+ const initRes = await fetch(initUrl.toString(), {
727
+ method: "PATCH",
728
+ headers: {
729
+ Authorization: `Bearer ${token}`,
730
+ "X-Upload-Content-Type": mimeType,
731
+ "X-Upload-Content-Length": String(c.size),
732
+ },
733
+ });
734
+ if (!initRes.ok) {
735
+ throw new Error(`googleDrive: resumable update init failed (${initRes.status}): ${await initRes.text()}`);
736
+ }
737
+ const uploadUrl = initRes.headers.get("location");
738
+ if (!uploadUrl)
739
+ throw new Error("googleDrive: missing Location on resumable init");
740
+ const CHUNK = 2 * 1024 * 1024;
741
+ const stream = createReadStream(c.path, { highWaterMark: CHUNK });
742
+ let offset = 0;
743
+ let pending = Buffer.alloc(0);
744
+ const flush = async (chunk) => {
745
+ const res = await fetch(uploadUrl, {
746
+ method: "PUT",
747
+ headers: {
748
+ "Content-Length": String(chunk.byteLength),
749
+ "Content-Range": `bytes ${offset}-${offset + chunk.byteLength - 1}/${c.size}`,
750
+ },
751
+ body: new Uint8Array(chunk),
752
+ });
753
+ offset += chunk.byteLength;
754
+ if (res.status !== 200 && res.status !== 201 && res.status !== 308) {
755
+ throw new Error(`googleDrive: resumable chunk failed (${res.status}): ${await res.text()}`);
756
+ }
757
+ await res.text();
758
+ };
759
+ for await (const chunk of stream) {
760
+ pending = Buffer.concat([pending, chunk]);
761
+ while (pending.byteLength >= CHUNK) {
762
+ await flush(pending.subarray(0, CHUNK));
763
+ pending = pending.subarray(CHUNK);
764
+ }
765
+ }
766
+ if (pending.byteLength > 0)
767
+ await flush(pending);
768
+ }
769
+ }
770
+ // Step 2: metadata patch
771
+ const body = {};
772
+ if (p.name !== undefined)
773
+ body.name = p.name;
774
+ if (p.mimeType !== undefined)
775
+ body.mimeType = p.mimeType;
776
+ if (p.properties !== undefined)
777
+ body.properties = p.properties;
778
+ if (p.appProperties !== undefined)
779
+ body.appProperties = p.appProperties;
780
+ const qs = { supportsAllDrives: true };
781
+ if (p.trashed !== undefined)
782
+ qs.trashed = p.trashed;
783
+ if (p.keepRevisionForever)
784
+ qs.keepRevisionForever = p.keepRevisionForever;
785
+ if (p.ocrLanguage)
786
+ qs.ocrLanguage = p.ocrLanguage;
787
+ if (p.useContentAsIndexableText)
788
+ qs.useContentAsIndexableText = p.useContentAsIndexableText;
789
+ if (p.fields)
790
+ qs.fields = p.fields;
791
+ if (Object.keys(body).length === 0 && !p.trashed && !p.fields) {
792
+ return { id: fileId, success: true };
793
+ }
794
+ return driveRequest(ctx, "PATCH", `/drive/v3/files/${fileId}`, body, qs);
795
+ },
796
+ });
797
+ rl.registerAction("file.delete", {
798
+ description: "Delete a file. Moves to trash by default; pass deletePermanently=true to erase.",
799
+ inputSchema: {
800
+ fileId: { type: "string", required: true },
801
+ deletePermanently: { type: "boolean", required: false },
802
+ },
803
+ async execute(input, ctx) {
804
+ const p = (input ?? {});
805
+ if (p.deletePermanently) {
806
+ await driveRequest(ctx, "DELETE", `/drive/v3/files/${p.fileId}`, undefined, {
807
+ supportsAllDrives: true,
808
+ });
809
+ }
810
+ else {
811
+ await driveRequest(ctx, "PATCH", `/drive/v3/files/${p.fileId}`, { trashed: true }, { supportsAllDrives: true });
812
+ }
813
+ return { id: p.fileId, success: true };
814
+ },
815
+ });
816
+ rl.registerAction("file.get", {
817
+ description: "Get file metadata",
818
+ inputSchema: {
819
+ fileId: { type: "string", required: true },
820
+ fields: { type: "string", required: false, description: "Default: '*'" },
821
+ },
822
+ async execute(input, ctx) {
823
+ const p = (input ?? {});
824
+ return driveRequest(ctx, "GET", `/drive/v3/files/${p.fileId}`, undefined, { supportsAllDrives: true, fields: p.fields ?? "*" });
825
+ },
826
+ });
827
+ rl.registerAction("file.share", {
828
+ description: "Add a permission to a file. Create one permission per call; list the existing permissions via file.listPermissions.",
829
+ inputSchema: {
830
+ fileId: { type: "string", required: true },
831
+ role: {
832
+ type: "string",
833
+ required: true,
834
+ description: "owner | organizer | fileOrganizer | writer | commenter | reader",
835
+ },
836
+ type: {
837
+ type: "string",
838
+ required: true,
839
+ description: "user | group | domain | anyone",
840
+ },
841
+ emailAddress: { type: "string", required: false },
842
+ domain: { type: "string", required: false },
843
+ allowFileDiscovery: { type: "boolean", required: false },
844
+ emailMessage: { type: "string", required: false },
845
+ sendNotificationEmail: { type: "boolean", required: false },
846
+ transferOwnership: { type: "boolean", required: false },
847
+ moveToNewOwnersRoot: { type: "boolean", required: false },
848
+ useDomainAdminAccess: { type: "boolean", required: false },
849
+ },
850
+ async execute(input, ctx) {
851
+ const p = (input ?? {});
852
+ const body = {
853
+ role: p.role,
854
+ type: p.type,
855
+ };
856
+ if (p.emailAddress)
857
+ body.emailAddress = p.emailAddress;
858
+ if (p.domain)
859
+ body.domain = p.domain;
860
+ if (p.allowFileDiscovery !== undefined)
861
+ body.allowFileDiscovery = p.allowFileDiscovery;
862
+ const qs = { supportsAllDrives: true };
863
+ if (p.emailMessage)
864
+ qs.emailMessage = p.emailMessage;
865
+ if (p.sendNotificationEmail !== undefined)
866
+ qs.sendNotificationEmail = p.sendNotificationEmail;
867
+ if (p.transferOwnership !== undefined)
868
+ qs.transferOwnership = p.transferOwnership;
869
+ if (p.moveToNewOwnersRoot !== undefined)
870
+ qs.moveToNewOwnersRoot = p.moveToNewOwnersRoot;
871
+ if (p.useDomainAdminAccess !== undefined)
872
+ qs.useDomainAdminAccess = p.useDomainAdminAccess;
873
+ return driveRequest(ctx, "POST", `/drive/v3/files/${p.fileId}/permissions`, body, qs);
874
+ },
875
+ });
876
+ rl.registerAction("file.listPermissions", {
877
+ description: "List permissions on a file",
878
+ inputSchema: {
879
+ fileId: { type: "string", required: true },
880
+ useDomainAdminAccess: { type: "boolean", required: false },
881
+ },
882
+ async execute(input, ctx) {
883
+ const p = (input ?? {});
884
+ return driveRequest(ctx, "GET", `/drive/v3/files/${p.fileId}/permissions`, undefined, {
885
+ supportsAllDrives: true,
886
+ useDomainAdminAccess: p.useDomainAdminAccess,
887
+ fields: "*",
888
+ });
889
+ },
890
+ });
891
+ rl.registerAction("file.deletePermission", {
892
+ description: "Revoke a permission on a file",
893
+ inputSchema: {
894
+ fileId: { type: "string", required: true },
895
+ permissionId: { type: "string", required: true },
896
+ },
897
+ async execute(input, ctx) {
898
+ const p = (input ?? {});
899
+ await driveRequest(ctx, "DELETE", `/drive/v3/files/${p.fileId}/permissions/${p.permissionId}`, undefined, { supportsAllDrives: true });
900
+ return { success: true };
901
+ },
902
+ });
903
+ // ── Folder ────────────────────────────────────────────
904
+ rl.registerAction("folder.create", {
905
+ description: "Create a folder",
906
+ inputSchema: {
907
+ name: { type: "string", required: false, description: 'Default: "Untitled"' },
908
+ folderId: { type: "string", required: false, description: "Parent folder" },
909
+ driveId: { type: "string", required: false },
910
+ folderColorRgb: { type: "string", required: false, description: "Hex RGB" },
911
+ fields: { type: "string", required: false, description: "Fields projection" },
912
+ },
913
+ async execute(input, ctx) {
914
+ const p = (input ?? {});
915
+ const body = {
916
+ name: p.name || "Untitled",
917
+ mimeType: DRIVE.FOLDER,
918
+ parents: [resolveParent(p.folderId, p.driveId)],
919
+ };
920
+ if (p.folderColorRgb)
921
+ body.folderColorRgb = p.folderColorRgb;
922
+ const qs = {
923
+ supportsAllDrives: true,
924
+ fields: p.fields ?? "*",
925
+ };
926
+ return driveRequest(ctx, "POST", "/drive/v3/files", body, qs);
927
+ },
928
+ });
929
+ rl.registerAction("folder.delete", {
930
+ description: "Delete a folder. Moves to trash by default; pass deletePermanently=true to erase.",
931
+ inputSchema: {
932
+ folderId: { type: "string", required: true },
933
+ deletePermanently: { type: "boolean", required: false },
934
+ },
935
+ async execute(input, ctx) {
936
+ const p = (input ?? {});
937
+ if (p.deletePermanently) {
938
+ await driveRequest(ctx, "DELETE", `/drive/v3/files/${p.folderId}`, undefined, {
939
+ supportsAllDrives: true,
940
+ });
941
+ }
942
+ else {
943
+ await driveRequest(ctx, "PATCH", `/drive/v3/files/${p.folderId}`, { trashed: true }, { supportsAllDrives: true });
944
+ }
945
+ return { id: p.folderId, success: true };
946
+ },
947
+ });
948
+ rl.registerAction("folder.share", {
949
+ description: "Add a permission to a folder (same shape as file.share)",
950
+ inputSchema: {
951
+ folderId: { type: "string", required: true },
952
+ role: { type: "string", required: true },
953
+ type: { type: "string", required: true },
954
+ emailAddress: { type: "string", required: false },
955
+ domain: { type: "string", required: false },
956
+ allowFileDiscovery: { type: "boolean", required: false },
957
+ emailMessage: { type: "string", required: false },
958
+ sendNotificationEmail: { type: "boolean", required: false },
959
+ transferOwnership: { type: "boolean", required: false },
960
+ moveToNewOwnersRoot: { type: "boolean", required: false },
961
+ useDomainAdminAccess: { type: "boolean", required: false },
962
+ },
963
+ async execute(input, ctx) {
964
+ const p = (input ?? {});
965
+ const body = { role: p.role, type: p.type };
966
+ if (p.emailAddress)
967
+ body.emailAddress = p.emailAddress;
968
+ if (p.domain)
969
+ body.domain = p.domain;
970
+ if (p.allowFileDiscovery !== undefined)
971
+ body.allowFileDiscovery = p.allowFileDiscovery;
972
+ const qs = { supportsAllDrives: true };
973
+ for (const k of [
974
+ "emailMessage",
975
+ "sendNotificationEmail",
976
+ "transferOwnership",
977
+ "moveToNewOwnersRoot",
978
+ "useDomainAdminAccess",
979
+ ]) {
980
+ if (p[k] !== undefined)
981
+ qs[k] = p[k];
982
+ }
983
+ return driveRequest(ctx, "POST", `/drive/v3/files/${p.folderId}/permissions`, body, qs);
984
+ },
985
+ });
986
+ // ── File / folder search ──────────────────────────────
987
+ rl.registerAction("fileFolder.search", {
988
+ description: "Search files and folders. `query` is passed directly; `name` wraps it as `name contains '…'`. Combine with folderId/driveId/whatToSearch/fileTypes filters.",
989
+ inputSchema: {
990
+ name: {
991
+ type: "string",
992
+ required: false,
993
+ description: "Convenience: matches `name contains '<value>'`",
994
+ },
995
+ query: {
996
+ type: "string",
997
+ required: false,
998
+ description: "Raw Drive search query; takes precedence over `name`",
999
+ },
1000
+ folderId: { type: "string", required: false },
1001
+ driveId: { type: "string", required: false },
1002
+ whatToSearch: {
1003
+ type: "string",
1004
+ required: false,
1005
+ description: "all (default) | files | folders",
1006
+ },
1007
+ fileTypes: {
1008
+ type: "array",
1009
+ required: false,
1010
+ description: "MIME type filter (ignored when whatToSearch=folders)",
1011
+ },
1012
+ includeTrashed: { type: "boolean", required: false },
1013
+ fields: {
1014
+ type: "array",
1015
+ required: false,
1016
+ description: "Per-file fields to return (default: id,name)",
1017
+ },
1018
+ returnAll: { type: "boolean", required: false },
1019
+ maxResults: { type: "number", required: false },
1020
+ pageToken: { type: "string", required: false },
1021
+ },
1022
+ async execute(input, ctx) {
1023
+ const p = (input ?? {});
1024
+ const clauses = [];
1025
+ if (typeof p.query === "string" && p.query.length > 0) {
1026
+ clauses.push(p.query);
1027
+ }
1028
+ else if (typeof p.name === "string" && p.name.length > 0) {
1029
+ // Drive expects single-quoted strings with \' escaping.
1030
+ const escaped = p.name.replace(/'/g, "\\'");
1031
+ clauses.push(`name contains '${escaped}'`);
1032
+ }
1033
+ if (typeof p.folderId === "string" && p.folderId && p.folderId !== "root") {
1034
+ clauses.push(`'${p.folderId}' in parents`);
1035
+ }
1036
+ const whatToSearch = p.whatToSearch ?? "all";
1037
+ if (whatToSearch === "folders") {
1038
+ clauses.push(`mimeType = '${DRIVE.FOLDER}'`);
1039
+ }
1040
+ else {
1041
+ if (whatToSearch === "files") {
1042
+ clauses.push(`mimeType != '${DRIVE.FOLDER}'`);
1043
+ }
1044
+ const types = toStringArray(p.fileTypes);
1045
+ if (types && !types.includes("*")) {
1046
+ const typeClause = types.map((t) => `mimeType = '${t}'`).join(" or ");
1047
+ if (typeClause)
1048
+ clauses.push(`(${typeClause})`);
1049
+ }
1050
+ }
1051
+ if (!p.includeTrashed)
1052
+ clauses.push("trashed = false");
1053
+ const fieldsArr = toStringArray(p.fields);
1054
+ const perFileFields = fieldsArr && fieldsArr.length > 0
1055
+ ? fieldsArr.includes("*")
1056
+ ? "*"
1057
+ : fieldsArr.join(", ")
1058
+ : "id, name";
1059
+ const qs = {
1060
+ q: clauses.join(" and "),
1061
+ fields: `nextPageToken, files(${perFileFields})`,
1062
+ };
1063
+ applyDriveScopes(qs, p.driveId);
1064
+ if (p.pageToken)
1065
+ qs.pageToken = p.pageToken;
1066
+ if (p.returnAll)
1067
+ return paginateAll(ctx, "/drive/v3/files", "files", qs);
1068
+ if (p.maxResults)
1069
+ qs.pageSize = p.maxResults;
1070
+ const res = (await driveRequest(ctx, "GET", "/drive/v3/files", undefined, qs));
1071
+ return res.files ?? [];
1072
+ },
1073
+ });
1074
+ // ── Shared drive ──────────────────────────────────────
1075
+ rl.registerAction("drive.create", {
1076
+ description: "Create a shared drive. requestId is generated automatically.",
1077
+ inputSchema: {
1078
+ name: { type: "string", required: true },
1079
+ colorRgb: { type: "string", required: false },
1080
+ hidden: { type: "boolean", required: false },
1081
+ capabilities: { type: "object", required: false },
1082
+ restrictions: { type: "object", required: false },
1083
+ },
1084
+ async execute(input, ctx) {
1085
+ const p = (input ?? {});
1086
+ const body = { name: p.name };
1087
+ for (const k of ["colorRgb", "hidden", "capabilities", "restrictions"]) {
1088
+ if (p[k] !== undefined)
1089
+ body[k] = p[k];
1090
+ }
1091
+ return driveRequest(ctx, "POST", "/drive/v3/drives", body, { requestId: uuid() });
1092
+ },
1093
+ });
1094
+ rl.registerAction("drive.get", {
1095
+ description: "Get a shared drive",
1096
+ inputSchema: {
1097
+ driveId: { type: "string", required: true },
1098
+ useDomainAdminAccess: { type: "boolean", required: false },
1099
+ },
1100
+ async execute(input, ctx) {
1101
+ const p = (input ?? {});
1102
+ const qs = {};
1103
+ if (p.useDomainAdminAccess !== undefined)
1104
+ qs.useDomainAdminAccess = p.useDomainAdminAccess;
1105
+ return driveRequest(ctx, "GET", `/drive/v3/drives/${p.driveId}`, undefined, qs);
1106
+ },
1107
+ });
1108
+ rl.registerAction("drive.list", {
1109
+ description: "List shared drives",
1110
+ inputSchema: {
1111
+ q: { type: "string", required: false, description: "Shared-drive search syntax" },
1112
+ useDomainAdminAccess: { type: "boolean", required: false },
1113
+ returnAll: { type: "boolean", required: false },
1114
+ maxResults: { type: "number", required: false },
1115
+ pageToken: { type: "string", required: false },
1116
+ },
1117
+ async execute(input, ctx) {
1118
+ const p = (input ?? {});
1119
+ const qs = {};
1120
+ if (p.q)
1121
+ qs.q = p.q;
1122
+ if (p.useDomainAdminAccess !== undefined)
1123
+ qs.useDomainAdminAccess = p.useDomainAdminAccess;
1124
+ if (p.pageToken)
1125
+ qs.pageToken = p.pageToken;
1126
+ if (p.returnAll)
1127
+ return paginateAll(ctx, "/drive/v3/drives", "drives", qs);
1128
+ if (p.maxResults)
1129
+ qs.pageSize = p.maxResults;
1130
+ const res = (await driveRequest(ctx, "GET", "/drive/v3/drives", undefined, qs));
1131
+ return res.drives ?? [];
1132
+ },
1133
+ });
1134
+ rl.registerAction("drive.update", {
1135
+ description: "Patch a shared drive",
1136
+ inputSchema: {
1137
+ driveId: { type: "string", required: true },
1138
+ name: { type: "string", required: false },
1139
+ colorRgb: { type: "string", required: false },
1140
+ restrictions: { type: "object", required: false },
1141
+ },
1142
+ async execute(input, ctx) {
1143
+ const p = (input ?? {});
1144
+ const body = {};
1145
+ for (const k of ["name", "colorRgb", "restrictions"]) {
1146
+ if (p[k] !== undefined)
1147
+ body[k] = p[k];
1148
+ }
1149
+ return driveRequest(ctx, "PATCH", `/drive/v3/drives/${p.driveId}`, body);
1150
+ },
1151
+ });
1152
+ rl.registerAction("drive.delete", {
1153
+ description: "Delete a shared drive",
1154
+ inputSchema: { driveId: { type: "string", required: true } },
1155
+ async execute(input, ctx) {
1156
+ const p = (input ?? {});
1157
+ await driveRequest(ctx, "DELETE", `/drive/v3/drives/${p.driveId}`);
1158
+ return { success: true };
1159
+ },
1160
+ });
1161
+ }