From efde3b00a56e338d5207c0dbc9d275a1fd29d88a Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 19 Feb 2026 16:32:24 -0500 Subject: [PATCH 1/2] Add nested syncables support to Twister SDK and Google Drive tool - Add optional `children` field to Syncable type for hierarchical resources - Add listSharedDrives() API and update listFolders() to include shared drives - Rewrite Google Drive getSyncables() to return folder tree with shared drives Co-Authored-By: Claude Opus 4.6 --- tools/google-drive/src/google-api.ts | 30 +++++++++++- tools/google-drive/src/google-drive.ts | 64 ++++++++++++++++++++++++-- twister/src/tools/integrations.ts | 2 + 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/tools/google-drive/src/google-api.ts b/tools/google-drive/src/google-api.ts index 913bfc9..f504856 100644 --- a/tools/google-drive/src/google-api.ts +++ b/tools/google-drive/src/google-api.ts @@ -109,7 +109,7 @@ export class GoogleApi { const DRIVE_API = "https://www.googleapis.com/drive/v3"; /** - * List folders accessible by the user. + * List folders accessible by the user, including shared drive folders. */ export async function listFolders(api: GoogleApi): Promise { const folders: GoogleDriveFile[] = []; @@ -120,6 +120,8 @@ export async function listFolders(api: GoogleApi): Promise { q: "mimeType='application/vnd.google-apps.folder' and trashed=false", fields: "nextPageToken,files(id,name,description,parents)", pageSize: 100, + includeItemsFromAllDrives: true, + supportsAllDrives: true, pageToken, })) as { files: GoogleDriveFile[]; nextPageToken?: string } | null; @@ -131,6 +133,32 @@ export async function listFolders(api: GoogleApi): Promise { return folders; } +export type SharedDrive = { + id: string; + name: string; +}; + +/** + * List shared drives accessible by the user. + */ +export async function listSharedDrives(api: GoogleApi): Promise { + const drives: SharedDrive[] = []; + let pageToken: string | undefined; + + do { + const data = (await api.call("GET", `${DRIVE_API}/drives`, { + pageSize: 100, + pageToken, + })) as { drives?: SharedDrive[]; nextPageToken?: string } | null; + + if (!data) break; + drives.push(...(data.drives || [])); + pageToken = data.nextPageToken; + } while (pageToken); + + return drives; +} + /** * List files in a folder (non-folder items only). */ diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 201fb17..9859872 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -42,6 +42,7 @@ import { listComments, listFilesInFolder, listFolders, + listSharedDrives, } from "./google-api"; /** @@ -96,15 +97,72 @@ export class GoogleDrive extends Tool implements DocumentTool { } /** - * Returns available Google Drive folders as syncable resources. + * Returns available Google Drive folders as a syncable tree. + * Shared drives and root-level My Drive folders appear at the top level, + * with subfolders nested under their parents. */ async getSyncables( _auth: Authorization, token: AuthToken ): Promise { const api = new GoogleApi(token.token); - const files = await listFolders(api); - return files.map((f) => ({ id: f.id, title: f.name })); + const [folders, sharedDrives] = await Promise.all([ + listFolders(api), + listSharedDrives(api), + ]); + + // Build node map for all folders + type SyncableNode = { id: string; title: string; children: SyncableNode[] }; + const nodeMap = new Map(); + for (const f of folders) { + nodeMap.set(f.id, { id: f.id, title: f.name, children: [] }); + } + + // Build shared drive node map + const sharedDriveMap = new Map(); + for (const drive of sharedDrives) { + sharedDriveMap.set(drive.id, { + id: drive.id, + title: drive.name, + children: [], + }); + } + + // Link children to parents + const roots: SyncableNode[] = []; + for (const f of folders) { + const node = nodeMap.get(f.id)!; + const parentId = f.parents?.[0]; + if (parentId) { + const parentFolder = nodeMap.get(parentId); + if (parentFolder) { + parentFolder.children.push(node); + continue; + } + const parentDrive = sharedDriveMap.get(parentId); + if (parentDrive) { + parentDrive.children.push(node); + continue; + } + } + // No known parent in our set → root folder (My Drive) + roots.push(node); + } + + // Combine: shared drives first, then root My Drive folders + const allRoots = [...sharedDriveMap.values(), ...roots]; + + // Strip empty children arrays for clean output + const clean = (nodes: SyncableNode[]): Syncable[] => { + return nodes.map((n) => { + if (n.children.length > 0) { + return { id: n.id, title: n.title, children: clean(n.children) }; + } + return { id: n.id, title: n.title }; + }); + }; + + return clean(allRoots); } /** diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 9fefebd..0993624 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -10,6 +10,8 @@ export type Syncable = { id: string; /** Display name shown in the UI */ title: string; + /** Optional nested syncable resources (e.g., subfolders) */ + children?: Syncable[]; }; /** From 60f02d02d0045db4412d2d3f46726a2b778f4681 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 19 Feb 2026 17:10:56 -0500 Subject: [PATCH 2/2] Get shared drives --- tools/google-drive/src/google-api.ts | 29 ++++++++++++++------------ tools/google-drive/src/google-drive.ts | 14 ++++++++++--- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/tools/google-drive/src/google-api.ts b/tools/google-drive/src/google-api.ts index f504856..adb9de9 100644 --- a/tools/google-drive/src/google-api.ts +++ b/tools/google-drive/src/google-api.ts @@ -73,9 +73,10 @@ export class GoogleApi { } } - const query = Object.keys(filteredParams).length > 0 - ? `?${new URLSearchParams(filteredParams)}` - : ""; + const query = + Object.keys(filteredParams).length > 0 + ? `?${new URLSearchParams(filteredParams)}` + : ""; const headers = { Authorization: `Bearer ${this.accessToken}`, Accept: "application/json", @@ -109,7 +110,7 @@ export class GoogleApi { const DRIVE_API = "https://www.googleapis.com/drive/v3"; /** - * List folders accessible by the user, including shared drive folders. + * List folders accessible by the user, including those in shared drives. */ export async function listFolders(api: GoogleApi): Promise { const folders: GoogleDriveFile[] = []; @@ -118,10 +119,11 @@ export async function listFolders(api: GoogleApi): Promise { do { const data = (await api.call("GET", `${DRIVE_API}/files`, { q: "mimeType='application/vnd.google-apps.folder' and trashed=false", - fields: "nextPageToken,files(id,name,description,parents)", - pageSize: 100, + corpora: "allDrives", includeItemsFromAllDrives: true, supportsAllDrives: true, + fields: "nextPageToken,files(id,name,description,parents)", + pageSize: 100, pageToken, })) as { files: GoogleDriveFile[]; nextPageToken?: string } | null; @@ -169,6 +171,8 @@ export async function listFilesInFolder( ): Promise<{ files: GoogleDriveFile[]; nextPageToken?: string }> { const data = (await api.call("GET", `${DRIVE_API}/files`, { q: `'${folderId}' in parents and mimeType!='application/vnd.google-apps.folder' and trashed=false`, + includeItemsFromAllDrives: true, + supportsAllDrives: true, fields: "nextPageToken,files(id,name,mimeType,description,webViewLink,iconLink,createdTime,modifiedTime,owners,permissions(emailAddress,displayName),parents)", pageSize: 50, @@ -245,13 +249,10 @@ export async function createReply( /** * Get the changes start page token for incremental sync. */ -export async function getChangesStartToken( - api: GoogleApi -): Promise { - const data = (await api.call( - "GET", - `${DRIVE_API}/changes/startPageToken` - )) as { startPageToken: string }; +export async function getChangesStartToken(api: GoogleApi): Promise { + const data = (await api.call("GET", `${DRIVE_API}/changes/startPageToken`, { + supportsAllDrives: true, + })) as { startPageToken: string }; return data.startPageToken; } @@ -272,6 +273,8 @@ export async function listChanges( }> { const data = (await api.call("GET", `${DRIVE_API}/changes`, { pageToken, + includeItemsFromAllDrives: true, + supportsAllDrives: true, fields: "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,description,webViewLink,iconLink,createdTime,modifiedTime,owners,permissions(emailAddress,displayName),parents))", pageSize: 100, diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 9859872..9c2118f 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -259,13 +259,21 @@ export class GoogleDrive extends Tool implements DocumentTool { async getFolders(folderId: string): Promise { const api = await this.getApi(folderId); - const files = await listFolders(api); + const [files, drives] = await Promise.all([ + listFolders(api), + listSharedDrives(api), + ]); + + const driveIds = new Set(drives.map((d) => d.id)); return files.map((file) => ({ id: file.id, name: file.name, description: file.description || null, - root: !file.parents || file.parents.length === 0, + root: + !file.parents || + file.parents.length === 0 || + file.parents.every((p) => driveIds.has(p)), })); } @@ -405,7 +413,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const watchData = (await api.call( "POST", "https://www.googleapis.com/drive/v3/changes/watch", - { pageToken: changesToken }, + { pageToken: changesToken, supportsAllDrives: true }, { id: watchId, type: "web_hook",