granola-toolkit 0.30.0 → 0.31.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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/dist/cli.js +504 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,6 +35,7 @@ granola --help
35
35
  granola attach --help
36
36
  granola auth login
37
37
  granola exports --help
38
+ granola folder --help
38
39
  granola meeting --help
39
40
  granola notes --help
40
41
  granola serve --help
@@ -52,6 +53,7 @@ vp pack
52
53
  node dist/cli.js --help
53
54
  node dist/cli.js attach --help
54
55
  node dist/cli.js exports --help
56
+ node dist/cli.js folder --help
55
57
  node dist/cli.js meeting --help
56
58
  node dist/cli.js notes --help
57
59
  node dist/cli.js serve --help
@@ -94,8 +96,11 @@ node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
94
96
  Inspect individual meetings:
95
97
 
96
98
  ```bash
99
+ granola folder list
100
+ granola folder view Team
97
101
  granola meeting list --limit 10
98
102
  granola meeting list --search planning
103
+ granola meeting list --folder Team
99
104
  granola meeting view 1234abcd
100
105
  granola meeting notes 1234abcd
101
106
  granola meeting transcript 1234abcd --format json
@@ -201,6 +206,24 @@ The machine-readable `export` command includes:
201
206
  - structured note data plus rendered Markdown
202
207
  - structured transcript data plus rendered transcript text when available
203
208
 
209
+ ### Folders
210
+
211
+ `folder` exposes Granola document lists as a first-class concept instead of leaving meetings in one flat global list.
212
+
213
+ The flow is:
214
+
215
+ 1. reuse the shared auth path that `notes` and `meeting` already use
216
+ 2. call Granola's document-list API, with `v2` first and `v1` fallback
217
+ 3. normalise folder metadata and document membership into shared folder records
218
+ 4. attach folder membership to meetings in the shared app core
219
+ 5. let folder commands and meeting filters resolve folders by id, prefix, or unique name
220
+
221
+ The current CLI surface includes:
222
+
223
+ - `folder list`
224
+ - `folder view <id|name>`
225
+ - `meeting list --folder <id|name>`
226
+
204
227
  ### Server
205
228
 
206
229
  `serve` starts a long-lived local `Granola Toolkit` server on one shared app instance.
@@ -214,7 +237,11 @@ The initial server API includes:
214
237
  - `GET /auth/status`
215
238
  - `GET /state`
216
239
  - `GET /events` for server-sent state updates
240
+ - `GET /folders`
241
+ - `GET /folders/resolve?q=<query>`
242
+ - `GET /folders/:id`
217
243
  - `GET /meetings`
244
+ - `GET /meetings?folderId=<id>` for folder-scoped meeting lists
218
245
  - `GET /meetings?refresh=true` to bypass the local meeting index and force a live refresh
219
246
  - `GET /meetings/resolve?q=<query>`
220
247
  - `GET /meetings/:id`
package/dist/cli.js CHANGED
@@ -22,6 +22,8 @@ const granolaTransportPaths = {
22
22
  exportJobs: "/exports/jobs",
23
23
  exportNotes: "/exports/notes",
24
24
  exportTranscripts: "/exports/transcripts",
25
+ folderResolve: "/folders/resolve",
26
+ folders: "/folders",
25
27
  health: "/health",
26
28
  meetingResolve: "/meetings/resolve",
27
29
  meetings: "/meetings",
@@ -48,6 +50,7 @@ function granolaMeetingResolvePath(query, options = {}) {
48
50
  }
49
51
  function granolaMeetingsPath(options = {}) {
50
52
  return appendSearchParams(granolaTransportPaths.meetings, {
53
+ folderId: options.folderId,
51
54
  limit: options.limit,
52
55
  refresh: options.forceRefresh ? "true" : void 0,
53
56
  search: options.search,
@@ -56,6 +59,19 @@ function granolaMeetingsPath(options = {}) {
56
59
  updatedTo: options.updatedTo
57
60
  });
58
61
  }
62
+ function granolaFolderPath(id) {
63
+ return `${granolaTransportPaths.folders}/${encodeURIComponent(id)}`;
64
+ }
65
+ function granolaFolderResolvePath(query) {
66
+ return appendSearchParams(granolaTransportPaths.folderResolve, { q: query });
67
+ }
68
+ function granolaFoldersPath(options = {}) {
69
+ return appendSearchParams(granolaTransportPaths.folders, {
70
+ limit: options.limit,
71
+ refresh: options.forceRefresh ? "true" : void 0,
72
+ search: options.search
73
+ });
74
+ }
59
75
  function granolaExportJobsPath(options = {}) {
60
76
  return appendSearchParams(granolaTransportPaths.exportJobs, { limit: options.limit });
61
77
  }
@@ -131,7 +147,7 @@ var GranolaServerClient = class GranolaServerClient {
131
147
  }) });
132
148
  if (!infoResponse.ok) throw await responseError(infoResponse);
133
149
  const info = await infoResponse.json();
134
- if (info.protocolVersion !== 1) throw new Error(`unsupported Granola transport protocol: expected 1, got ${info.protocolVersion}`);
150
+ if (info.protocolVersion !== 2) throw new Error(`unsupported Granola transport protocol: expected 2, got ${info.protocolVersion}`);
135
151
  const response = await fetchImpl(new URL(granolaTransportPaths.state, url), { headers: mergeHeaders({
136
152
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
137
153
  accept: "application/json"
@@ -181,6 +197,15 @@ var GranolaServerClient = class GranolaServerClient {
181
197
  method: "POST"
182
198
  });
183
199
  }
200
+ async listFolders(options = {}) {
201
+ return await this.requestJson(granolaFoldersPath(options));
202
+ }
203
+ async getFolder(id) {
204
+ return await this.requestJson(granolaFolderPath(id));
205
+ }
206
+ async findFolder(query) {
207
+ return await this.requestJson(granolaFolderResolvePath(query));
208
+ }
184
209
  async listMeetings(options = {}) {
185
210
  return await this.requestJson(granolaMeetingsPath(options));
186
211
  }
@@ -1075,6 +1100,10 @@ function matchesMeetingSearch(document, search) {
1075
1100
  ...document.tags
1076
1101
  ].some((value) => value.toLowerCase().includes(query));
1077
1102
  }
1103
+ function matchesMeetingFolders(documentId, folderId, foldersByDocumentId) {
1104
+ if (!folderId) return true;
1105
+ return (foldersByDocumentId?.get(documentId) ?? []).some((folder) => folder.id === folderId);
1106
+ }
1078
1107
  function matchesMeetingSummarySearch(meeting, search) {
1079
1108
  const query = search.trim().toLowerCase();
1080
1109
  if (!query) return true;
@@ -1084,6 +1113,9 @@ function matchesMeetingSummarySearch(meeting, search) {
1084
1113
  ...meeting.tags
1085
1114
  ].some((value) => value.toLowerCase().includes(query));
1086
1115
  }
1116
+ function meetingFolders(meeting) {
1117
+ return Array.isArray(meeting.folders) ? meeting.folders : [];
1118
+ }
1087
1119
  function parseDateFilter(value, label) {
1088
1120
  const trimmed = value?.trim();
1089
1121
  if (!trimmed) return;
@@ -1110,7 +1142,7 @@ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1110
1142
  if (to != null && updatedAt > to) return false;
1111
1143
  return true;
1112
1144
  }
1113
- function truncate(value, width) {
1145
+ function truncate$1(value, width) {
1114
1146
  if (value.length <= width) return value.padEnd(width);
1115
1147
  return `${value.slice(0, Math.max(0, width - 1))}…`;
1116
1148
  }
@@ -1126,11 +1158,12 @@ function formatTranscriptLines(transcript) {
1126
1158
  if (!transcript || transcript.segments.length === 0) return "";
1127
1159
  return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
1128
1160
  }
1129
- function buildMeetingSummary(document, cacheData) {
1161
+ function buildMeetingSummary(document, cacheData, folders = []) {
1130
1162
  const note = buildNoteExport(document);
1131
1163
  const transcript = buildMeetingTranscript(document, cacheData);
1132
1164
  return {
1133
1165
  createdAt: document.createdAt,
1166
+ folders: folders.map((folder) => ({ ...folder })),
1134
1167
  id: document.id,
1135
1168
  noteContentSource: note.contentSource,
1136
1169
  tags: [...document.tags],
@@ -1140,12 +1173,13 @@ function buildMeetingSummary(document, cacheData) {
1140
1173
  updatedAt: latestDocumentTimestamp(document)
1141
1174
  };
1142
1175
  }
1143
- function buildMeetingRecord(document, cacheData) {
1176
+ function buildMeetingRecord(document, cacheData, folders = []) {
1144
1177
  const note = buildNoteExport(document);
1145
1178
  const transcript = buildMeetingTranscript(document, cacheData);
1146
1179
  return {
1147
1180
  meeting: {
1148
1181
  createdAt: document.createdAt,
1182
+ folders: folders.map((folder) => ({ ...folder })),
1149
1183
  id: document.id,
1150
1184
  noteContentSource: note.contentSource,
1151
1185
  tags: [...document.tags],
@@ -1163,13 +1197,14 @@ function buildMeetingRecord(document, cacheData) {
1163
1197
  function listMeetings(documents, options = {}) {
1164
1198
  const limit = options.limit ?? 20;
1165
1199
  const sort = options.sort ?? "updated-desc";
1166
- return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1200
+ return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesMeetingFolders(document.id, options.folderId, options.foldersByDocumentId)).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData, options.foldersByDocumentId?.get(document.id)));
1167
1201
  }
1168
1202
  function filterMeetingSummaries(meetings, options = {}) {
1169
1203
  const limit = options.limit ?? 20;
1170
1204
  const sort = options.sort ?? "updated-desc";
1171
- return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1205
+ return meetings.filter((meeting) => options.folderId ? meetingFolders(meeting).some((folder) => folder.id === options.folderId) : true).filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1172
1206
  ...meeting,
1207
+ folders: meetingFolders(meeting).map((folder) => ({ ...folder })),
1173
1208
  tags: [...meeting.tags]
1174
1209
  }));
1175
1210
  }
@@ -1206,14 +1241,18 @@ function renderMeetingList(meetings, format = "text") {
1206
1241
  case "text": break;
1207
1242
  }
1208
1243
  if (meetings.length === 0) return "No meetings found\n";
1209
- const lines = [`${"ID".padEnd(10)} ${"DATE".padEnd(10)} ${"TITLE".padEnd(42)} ${"NOTE".padEnd(18)} TRANSCRIPT`, `${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(42)} ${"-".repeat(18)} ${"-".repeat(10)}`];
1210
- for (const meeting of meetings) lines.push([
1211
- meeting.id.slice(0, 8).padEnd(10),
1212
- formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1213
- truncate(meeting.title || meeting.id, 42),
1214
- truncate(meeting.noteContentSource, 18),
1215
- formatTranscriptStatus(meeting)
1216
- ].join(" "));
1244
+ const lines = [`${"ID".padEnd(10)} ${"DATE".padEnd(10)} ${"TITLE".padEnd(34)} ${"FOLDERS".padEnd(18)} ${"NOTE".padEnd(18)} TRANSCRIPT`, `${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(34)} ${"-".repeat(18)} ${"-".repeat(18)} ${"-".repeat(10)}`];
1245
+ for (const meeting of meetings) {
1246
+ const folderLabel = meetingFolders(meeting).length === 0 ? "-" : meetingFolders(meeting).length === 1 ? meetingFolders(meeting)[0].name : `${meetingFolders(meeting)[0].name} +${meetingFolders(meeting).length - 1}`;
1247
+ lines.push([
1248
+ meeting.id.slice(0, 8).padEnd(10),
1249
+ formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1250
+ truncate$1(meeting.title || meeting.id, 34),
1251
+ truncate$1(folderLabel, 18),
1252
+ truncate$1(meeting.noteContentSource, 18),
1253
+ formatTranscriptStatus(meeting)
1254
+ ].join(" "));
1255
+ }
1217
1256
  return `${lines.join("\n").trimEnd()}\n`;
1218
1257
  }
1219
1258
  function renderMeetingView(record, format = "text") {
@@ -1223,6 +1262,7 @@ function renderMeetingView(record, format = "text") {
1223
1262
  case "text": break;
1224
1263
  }
1225
1264
  const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
1265
+ const folders = meetingFolders(record.meeting).length > 0 ? meetingFolders(record.meeting).map((folder) => folder.name).join(", ") : "(none)";
1226
1266
  const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1227
1267
  return `${[
1228
1268
  `# ${record.meeting.title || record.meeting.id}`,
@@ -1231,6 +1271,7 @@ function renderMeetingView(record, format = "text") {
1231
1271
  `Created: ${record.meeting.createdAt || "-"}`,
1232
1272
  `Updated: ${record.meeting.updatedAt || "-"}`,
1233
1273
  `Tags: ${tags}`,
1274
+ `Folders: ${folders}`,
1234
1275
  `Note source: ${record.meeting.noteContentSource}`,
1235
1276
  `Transcript: ${transcriptStatus}`,
1236
1277
  "",
@@ -2591,10 +2632,39 @@ function parseDocument(value) {
2591
2632
  updatedAt: stringValue(record.updated_at)
2592
2633
  };
2593
2634
  }
2635
+ function parseFolderDocumentIds(value) {
2636
+ if (!Array.isArray(value)) return [];
2637
+ return value.map((item) => {
2638
+ if (typeof item === "string") return item;
2639
+ const record = asRecord(item);
2640
+ if (!record) return "";
2641
+ return stringValue(record.id) || stringValue(record.document_id);
2642
+ }).filter(Boolean);
2643
+ }
2644
+ function parseFolder(value) {
2645
+ const record = asRecord(value);
2646
+ if (!record) throw new Error("folder payload is not an object");
2647
+ const createdAt = stringValue(record.created_at);
2648
+ const updatedAt = stringValue(record.updated_at) || createdAt;
2649
+ const name = stringValue(record.name) || stringValue(record.title);
2650
+ const documents = parseFolderDocumentIds(record.documents);
2651
+ const documentIds = documents.length > 0 ? documents : parseFolderDocumentIds(record.document_ids);
2652
+ return {
2653
+ createdAt,
2654
+ description: stringValue(record.description) || void 0,
2655
+ documentIds,
2656
+ id: stringValue(record.id),
2657
+ isFavourite: Boolean(record.is_favourite),
2658
+ name,
2659
+ updatedAt,
2660
+ workspaceId: stringValue(record.workspace_id) || void 0
2661
+ };
2662
+ }
2594
2663
  //#endregion
2595
2664
  //#region src/client/granola.ts
2596
2665
  const DEFAULT_CLIENT_VERSION = "5.354.0";
2597
2666
  const DOCUMENTS_URL = "https://api.granola.ai/v2/get-documents";
2667
+ const FOLDERS_URLS = ["https://api.granola.ai/v2/get-document-lists", "https://api.granola.ai/v1/get-document-lists"];
2598
2668
  function resolveClientVersion(value) {
2599
2669
  return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
2600
2670
  }
@@ -2640,6 +2710,29 @@ var GranolaApiClient = class {
2640
2710
  }
2641
2711
  return documents;
2642
2712
  }
2713
+ async listFolders(options) {
2714
+ let lastError;
2715
+ for (const foldersUrl of FOLDERS_URLS) {
2716
+ const response = await this.httpClient.postJson(foldersUrl, {}, {
2717
+ headers: {
2718
+ "User-Agent": `Granola/${this.clientVersion}`,
2719
+ "X-Client-Version": this.clientVersion
2720
+ },
2721
+ timeoutMs: options.timeoutMs
2722
+ });
2723
+ if (response.status === 404) {
2724
+ lastError = /* @__PURE__ */ new Error(`failed to get folders: ${response.status} ${response.statusText}`);
2725
+ continue;
2726
+ }
2727
+ if (!response.ok) {
2728
+ const body = (await response.text()).slice(0, 500);
2729
+ throw new Error(`failed to get folders: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
2730
+ }
2731
+ const payload = await response.json();
2732
+ return (Array.isArray(payload) ? payload : Array.isArray(payload.lists) ? payload.lists : Array.isArray(payload.document_lists) ? payload.document_lists : []).map(parseFolder);
2733
+ }
2734
+ throw lastError ?? /* @__PURE__ */ new Error("failed to get folders");
2735
+ }
2643
2736
  };
2644
2737
  //#endregion
2645
2738
  //#region src/client/http.ts
@@ -2826,8 +2919,121 @@ function createDefaultExportJobStore() {
2826
2919
  return new FileExportJobStore();
2827
2920
  }
2828
2921
  //#endregion
2922
+ //#region src/folders.ts
2923
+ function truncate(value, width) {
2924
+ if (value.length <= width) return value.padEnd(width);
2925
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
2926
+ }
2927
+ function compareFolders(left, right) {
2928
+ return right.updatedAt.localeCompare(left.updatedAt) || compareStrings(left.name || left.id, right.name || right.id) || compareStrings(left.id, right.id);
2929
+ }
2930
+ function matchesFolderSearch(folder, search) {
2931
+ const query = search.trim().toLowerCase();
2932
+ if (!query) return true;
2933
+ return [
2934
+ folder.id,
2935
+ folder.name,
2936
+ folder.description ?? "",
2937
+ folder.workspaceId ?? ""
2938
+ ].some((value) => value.toLowerCase().includes(query));
2939
+ }
2940
+ function formatFolderDate(value) {
2941
+ return value.trim().slice(0, 10) || "-";
2942
+ }
2943
+ function buildFolderSummary(folder) {
2944
+ return {
2945
+ createdAt: folder.createdAt,
2946
+ description: folder.description,
2947
+ documentCount: folder.documentIds.length,
2948
+ id: folder.id,
2949
+ isFavourite: folder.isFavourite,
2950
+ name: folder.name,
2951
+ updatedAt: folder.updatedAt,
2952
+ workspaceId: folder.workspaceId
2953
+ };
2954
+ }
2955
+ function buildFolderRecord(folder, meetings) {
2956
+ return {
2957
+ ...buildFolderSummary(folder),
2958
+ documentIds: [...folder.documentIds],
2959
+ meetings: meetings.map((meeting) => ({
2960
+ ...meeting,
2961
+ folders: meeting.folders.map((candidate) => ({ ...candidate })),
2962
+ tags: [...meeting.tags]
2963
+ }))
2964
+ };
2965
+ }
2966
+ function filterFolders(folders, options = {}) {
2967
+ const limit = options.limit ?? 20;
2968
+ return folders.filter((folder) => options.search ? matchesFolderSearch(folder, options.search) : true).sort(compareFolders).slice(0, limit).map((folder) => ({ ...folder }));
2969
+ }
2970
+ function resolveFolder(folders, id) {
2971
+ const exactMatch = folders.find((folder) => folder.id === id);
2972
+ if (exactMatch) return exactMatch;
2973
+ const matches = folders.filter((folder) => folder.id.startsWith(id));
2974
+ if (matches.length === 1) return matches[0];
2975
+ if (matches.length > 1) throw new Error(`ambiguous folder id: ${id}`);
2976
+ throw new Error(`folder not found: ${id}`);
2977
+ }
2978
+ function resolveFolderQuery(folders, query) {
2979
+ const trimmed = query.trim();
2980
+ if (!trimmed) throw new Error("folder query is required");
2981
+ const lower = trimmed.toLowerCase();
2982
+ const exactId = folders.find((folder) => folder.id === trimmed);
2983
+ if (exactId) return exactId;
2984
+ const exactNameMatches = folders.filter((folder) => folder.name.toLowerCase() === lower);
2985
+ if (exactNameMatches.length === 1) return exactNameMatches[0];
2986
+ const prefixMatches = folders.filter((folder) => folder.id.startsWith(trimmed));
2987
+ if (prefixMatches.length === 1) return prefixMatches[0];
2988
+ const nameMatches = folders.filter((folder) => folder.name.toLowerCase().includes(lower)).sort(compareFolders);
2989
+ if (nameMatches.length === 1) return nameMatches[0];
2990
+ if (exactNameMatches.length > 1 || prefixMatches.length > 1 || nameMatches.length > 1) throw new Error(`ambiguous folder query: ${trimmed}`);
2991
+ throw new Error(`folder not found: ${trimmed}`);
2992
+ }
2993
+ function renderFolderList(folders, format = "text") {
2994
+ switch (format) {
2995
+ case "json": return toJson(folders);
2996
+ case "yaml": return toYaml(folders);
2997
+ case "text": break;
2998
+ }
2999
+ if (folders.length === 0) return "No folders found\n";
3000
+ const lines = [`${"ID".padEnd(10)} ${"COUNT".padEnd(7)} ${"UPDATED".padEnd(10)} NAME`, `${"-".repeat(10)} ${"-".repeat(7)} ${"-".repeat(10)} ${"-".repeat(42)}`];
3001
+ for (const folder of folders) {
3002
+ const name = `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`;
3003
+ lines.push([
3004
+ folder.id.slice(0, 8).padEnd(10),
3005
+ String(folder.documentCount).padEnd(7),
3006
+ formatFolderDate(folder.updatedAt || folder.createdAt).padEnd(10),
3007
+ truncate(name, 42)
3008
+ ].join(" "));
3009
+ }
3010
+ return `${lines.join("\n").trimEnd()}\n`;
3011
+ }
3012
+ function renderFolderView(folder, format = "text") {
3013
+ switch (format) {
3014
+ case "json": return toJson(folder);
3015
+ case "yaml": return toYaml(folder);
3016
+ case "text": break;
3017
+ }
3018
+ const lines = [
3019
+ `# ${folder.name || folder.id}`,
3020
+ "",
3021
+ `ID: ${folder.id}`,
3022
+ `Created: ${folder.createdAt || "-"}`,
3023
+ `Updated: ${folder.updatedAt || "-"}`,
3024
+ `Documents: ${folder.documentCount}`,
3025
+ `Favourite: ${folder.isFavourite ? "yes" : "no"}`,
3026
+ `Workspace: ${folder.workspaceId || "-"}`
3027
+ ];
3028
+ if (folder.description) lines.push(`Description: ${folder.description}`);
3029
+ lines.push("", "## Meetings", "");
3030
+ lines.push(renderMeetingList(folder.meetings, "text").trimEnd());
3031
+ lines.push("");
3032
+ return `${lines.join("\n").trimEnd()}\n`;
3033
+ }
3034
+ //#endregion
2829
3035
  //#region src/meeting-index.ts
2830
- const MEETING_INDEX_VERSION = 1;
3036
+ const MEETING_INDEX_VERSION = 2;
2831
3037
  var FileMeetingIndexStore = class {
2832
3038
  constructor(filePath = defaultMeetingIndexFilePath()) {
2833
3039
  this.filePath = filePath;
@@ -2838,6 +3044,7 @@ var FileMeetingIndexStore = class {
2838
3044
  if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
2839
3045
  return parsed.meetings.map((meeting) => ({
2840
3046
  ...meeting,
3047
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2841
3048
  tags: [...meeting.tags]
2842
3049
  }));
2843
3050
  } catch {
@@ -2849,6 +3056,7 @@ var FileMeetingIndexStore = class {
2849
3056
  const payload = {
2850
3057
  meetings: meetings.map((meeting) => ({
2851
3058
  ...meeting,
3059
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2852
3060
  tags: [...meeting.tags]
2853
3061
  })),
2854
3062
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2877,6 +3085,16 @@ function cloneExportState(state) {
2877
3085
  function cloneExportJob(job) {
2878
3086
  return { ...job };
2879
3087
  }
3088
+ function cloneFolderSummary(folder) {
3089
+ return { ...folder };
3090
+ }
3091
+ function cloneMeetingSummary(meeting) {
3092
+ return {
3093
+ ...meeting,
3094
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => cloneFolderSummary(folder)) : [],
3095
+ tags: [...meeting.tags]
3096
+ };
3097
+ }
2880
3098
  function cloneState(state) {
2881
3099
  return {
2882
3100
  auth: { ...state.auth },
@@ -2887,6 +3105,7 @@ function cloneState(state) {
2887
3105
  transcripts: { ...state.config.transcripts }
2888
3106
  },
2889
3107
  documents: { ...state.documents },
3108
+ folders: { ...state.folders },
2890
3109
  exports: {
2891
3110
  jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
2892
3111
  notes: cloneExportState(state.exports.notes),
@@ -2915,6 +3134,10 @@ function defaultState(config, auth, surface) {
2915
3134
  count: 0,
2916
3135
  loaded: false
2917
3136
  },
3137
+ folders: {
3138
+ count: 0,
3139
+ loaded: false
3140
+ },
2918
3141
  exports: { jobs: [] },
2919
3142
  index: {
2920
3143
  available: false,
@@ -2931,6 +3154,7 @@ function defaultState(config, auth, surface) {
2931
3154
  var GranolaApp = class {
2932
3155
  #cacheData;
2933
3156
  #cacheResolved = false;
3157
+ #folders;
2934
3158
  #granolaClient;
2935
3159
  #documents;
2936
3160
  #meetingIndex;
@@ -2942,10 +3166,7 @@ var GranolaApp = class {
2942
3166
  this.deps = deps;
2943
3167
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
2944
3168
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
2945
- this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
2946
- ...meeting,
2947
- tags: [...meeting.tags]
2948
- }));
3169
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => cloneMeetingSummary(meeting));
2949
3170
  this.#state.index = {
2950
3171
  available: this.#meetingIndex.length > 0,
2951
3172
  filePath: defaultMeetingIndexFilePath(),
@@ -2971,16 +3192,21 @@ var GranolaApp = class {
2971
3192
  this.emitStateUpdate();
2972
3193
  return this.getState();
2973
3194
  }
2974
- resetDocumentsState() {
3195
+ resetRemoteState() {
2975
3196
  this.#granolaClient = void 0;
3197
+ this.#folders = void 0;
2976
3198
  this.#documents = void 0;
2977
3199
  this.#state.documents = {
2978
3200
  count: 0,
2979
3201
  loaded: false
2980
3202
  };
3203
+ this.#state.folders = {
3204
+ count: 0,
3205
+ loaded: false
3206
+ };
2981
3207
  }
2982
3208
  applyAuthState(auth, options = {}) {
2983
- if (options.resetDocuments) this.resetDocumentsState();
3209
+ if (options.resetDocuments) this.resetRemoteState();
2984
3210
  this.#state.auth = { ...auth };
2985
3211
  if (options.view) this.#state.ui = {
2986
3212
  ...this.#state.ui,
@@ -2990,10 +3216,7 @@ var GranolaApp = class {
2990
3216
  return { ...auth };
2991
3217
  }
2992
3218
  async persistMeetingIndex(meetings) {
2993
- this.#meetingIndex = meetings.map((meeting) => ({
2994
- ...meeting,
2995
- tags: [...meeting.tags]
2996
- }));
3219
+ this.#meetingIndex = meetings.map((meeting) => cloneMeetingSummary(meeting));
2997
3220
  this.#state.index = {
2998
3221
  available: this.#meetingIndex.length > 0,
2999
3222
  filePath: this.#state.index.filePath,
@@ -3007,8 +3230,10 @@ var GranolaApp = class {
3007
3230
  async refreshMeetingIndexFromLiveData() {
3008
3231
  const cacheData = await this.loadCache();
3009
3232
  const documents = await this.listDocuments();
3233
+ const folders = await this.loadFolders();
3010
3234
  const meetings = listMeetings(documents, {
3011
3235
  cacheData,
3236
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3012
3237
  limit: documents.length || 1,
3013
3238
  sort: "updated-desc"
3014
3239
  });
@@ -3047,6 +3272,54 @@ var GranolaApp = class {
3047
3272
  this.applyAuthState(runtime.auth);
3048
3273
  return this.#granolaClient;
3049
3274
  }
3275
+ buildFoldersByDocumentId(folders) {
3276
+ if (!folders || folders.length === 0) return;
3277
+ const byDocumentId = /* @__PURE__ */ new Map();
3278
+ for (const folder of folders) {
3279
+ const summary = buildFolderSummary(folder);
3280
+ for (const documentId of folder.documentIds) {
3281
+ const existing = byDocumentId.get(documentId) ?? [];
3282
+ existing.push(summary);
3283
+ byDocumentId.set(documentId, existing);
3284
+ }
3285
+ }
3286
+ for (const [documentId, summaries] of byDocumentId.entries()) byDocumentId.set(documentId, summaries.slice().sort((left, right) => left.name.localeCompare(right.name)).map((folder) => cloneFolderSummary(folder)));
3287
+ return byDocumentId;
3288
+ }
3289
+ async loadFolders(options = {}) {
3290
+ if (options.forceRefresh) {
3291
+ this.resetRemoteState();
3292
+ this.emitStateUpdate();
3293
+ }
3294
+ if (this.#folders) return this.#folders.map((folder) => ({
3295
+ ...folder,
3296
+ documentIds: [...folder.documentIds]
3297
+ }));
3298
+ const client = await this.getGranolaClient();
3299
+ if (!client.listFolders) {
3300
+ if (options.required) throw new Error("Granola folder API is not configured");
3301
+ return;
3302
+ }
3303
+ try {
3304
+ this.#folders = (await client.listFolders({ timeoutMs: this.config.notes.timeoutMs })).map((folder) => ({
3305
+ ...folder,
3306
+ documentIds: [...folder.documentIds]
3307
+ }));
3308
+ this.#state.folders = {
3309
+ count: this.#folders.length,
3310
+ loaded: true,
3311
+ loadedAt: this.nowIso()
3312
+ };
3313
+ this.emitStateUpdate();
3314
+ return this.#folders.map((folder) => ({
3315
+ ...folder,
3316
+ documentIds: [...folder.documentIds]
3317
+ }));
3318
+ } catch (error) {
3319
+ if (options.required) throw error;
3320
+ return;
3321
+ }
3322
+ }
3050
3323
  missingCacheError() {
3051
3324
  return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
3052
3325
  }
@@ -3159,12 +3432,7 @@ var GranolaApp = class {
3159
3432
  }
3160
3433
  async listDocuments(options = {}) {
3161
3434
  if (options.forceRefresh) {
3162
- this.#granolaClient = void 0;
3163
- this.#documents = void 0;
3164
- this.#state.documents = {
3165
- count: 0,
3166
- loaded: false
3167
- };
3435
+ this.resetRemoteState();
3168
3436
  this.emitStateUpdate();
3169
3437
  }
3170
3438
  if (this.#documents) return this.#documents;
@@ -3205,16 +3473,57 @@ var GranolaApp = class {
3205
3473
  if (options.required && !cacheData) throw this.missingCacheError();
3206
3474
  return cacheData;
3207
3475
  }
3476
+ async listFolders(options = {}) {
3477
+ const summaries = filterFolders((await this.loadFolders({
3478
+ forceRefresh: options.forceRefresh,
3479
+ required: true
3480
+ }) ?? []).map((folder) => buildFolderSummary(folder)), {
3481
+ limit: options.limit,
3482
+ search: options.search
3483
+ });
3484
+ this.setUiState({
3485
+ folderSearch: options.search,
3486
+ selectedFolderId: void 0,
3487
+ view: "folder-list"
3488
+ });
3489
+ return { folders: summaries };
3490
+ }
3491
+ async getFolder(id) {
3492
+ const folders = await this.loadFolders({ required: true });
3493
+ const cacheData = await this.loadCache();
3494
+ const documents = await this.listDocuments();
3495
+ const folder = resolveFolder((folders ?? []).map((folder) => buildFolderSummary(folder)), id);
3496
+ const rawFolder = (folders ?? []).find((candidate) => candidate.id === folder.id);
3497
+ if (!rawFolder) throw new Error(`folder not found: ${id}`);
3498
+ const record = buildFolderRecord(rawFolder, listMeetings(documents, {
3499
+ cacheData,
3500
+ folderId: folder.id,
3501
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3502
+ limit: Math.max(rawFolder.documentIds.length, 1),
3503
+ sort: "updated-desc"
3504
+ }));
3505
+ this.setUiState({
3506
+ selectedFolderId: folder.id,
3507
+ view: "folder-detail"
3508
+ });
3509
+ return record;
3510
+ }
3511
+ async findFolder(query) {
3512
+ const summary = resolveFolderQuery((await this.loadFolders({ required: true }) ?? []).map((folder) => buildFolderSummary(folder)), query);
3513
+ return await this.getFolder(summary.id);
3514
+ }
3208
3515
  async listMeetings(options = {}) {
3209
3516
  const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
3210
3517
  if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
3211
3518
  const meetings = filterMeetingSummaries(this.#meetingIndex, options);
3212
3519
  this.setUiState({
3520
+ folderSearch: void 0,
3213
3521
  meetingListSource: "index",
3214
3522
  meetingSearch: options.search,
3215
3523
  meetingSort: options.sort,
3216
3524
  meetingUpdatedFrom: options.updatedFrom,
3217
3525
  meetingUpdatedTo: options.updatedTo,
3526
+ selectedFolderId: options.folderId,
3218
3527
  selectedMeetingId: void 0,
3219
3528
  view: "meeting-list"
3220
3529
  });
@@ -3226,8 +3535,14 @@ var GranolaApp = class {
3226
3535
  }
3227
3536
  const cacheData = await this.loadCache();
3228
3537
  const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
3538
+ const folders = await this.loadFolders({
3539
+ forceRefresh: options.forceRefresh,
3540
+ required: Boolean(options.folderId)
3541
+ });
3229
3542
  const meetings = listMeetings(documents, {
3230
3543
  cacheData,
3544
+ folderId: options.folderId,
3545
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3231
3546
  limit: options.limit,
3232
3547
  search: options.search,
3233
3548
  sort: options.sort,
@@ -3236,15 +3551,18 @@ var GranolaApp = class {
3236
3551
  });
3237
3552
  await this.persistMeetingIndex(listMeetings(documents, {
3238
3553
  cacheData,
3554
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3239
3555
  limit: Math.max(documents.length, 1),
3240
3556
  sort: "updated-desc"
3241
3557
  }));
3242
3558
  this.setUiState({
3559
+ folderSearch: void 0,
3243
3560
  meetingListSource: "live",
3244
3561
  meetingSearch: options.search,
3245
3562
  meetingSort: options.sort,
3246
3563
  meetingUpdatedFrom: options.updatedFrom,
3247
3564
  meetingUpdatedTo: options.updatedTo,
3565
+ selectedFolderId: options.folderId,
3248
3566
  selectedMeetingId: void 0,
3249
3567
  view: "meeting-list"
3250
3568
  });
@@ -3256,9 +3574,11 @@ var GranolaApp = class {
3256
3574
  async getMeeting(id, options = {}) {
3257
3575
  const documents = await this.listDocuments();
3258
3576
  const cacheData = await this.loadCache({ required: options.requireCache });
3577
+ const folders = await this.loadFolders();
3259
3578
  const document = resolveMeeting(documents, id);
3260
- const meeting = buildMeetingRecord(document, cacheData);
3579
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3261
3580
  this.setUiState({
3581
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3262
3582
  selectedMeetingId: document.id,
3263
3583
  view: "meeting-detail"
3264
3584
  });
@@ -3271,9 +3591,11 @@ var GranolaApp = class {
3271
3591
  async findMeeting(query, options = {}) {
3272
3592
  const documents = await this.listDocuments();
3273
3593
  const cacheData = await this.loadCache({ required: options.requireCache });
3594
+ const folders = await this.loadFolders();
3274
3595
  const document = resolveMeetingQuery(documents, query);
3275
- const meeting = buildMeetingRecord(document, cacheData);
3596
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3276
3597
  this.setUiState({
3598
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3277
3599
  selectedMeetingId: document.id,
3278
3600
  view: "meeting-detail"
3279
3601
  });
@@ -3670,7 +3992,7 @@ function resolveListFormat$1(value) {
3670
3992
  default: throw new Error("invalid exports format: expected text, json, or yaml");
3671
3993
  }
3672
3994
  }
3673
- function parseLimit$1(value) {
3995
+ function parseLimit$2(value) {
3674
3996
  if (value === void 0) return 20;
3675
3997
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
3676
3998
  const limit = Number(value);
@@ -3699,7 +4021,7 @@ const exportsCommand = {
3699
4021
  async run({ commandArgs, commandFlags, globalFlags }) {
3700
4022
  const [action, id] = commandArgs;
3701
4023
  switch (action) {
3702
- case "list": return await list$1(commandFlags, globalFlags);
4024
+ case "list": return await list$2(commandFlags, globalFlags);
3703
4025
  case "rerun":
3704
4026
  if (!id) throw new Error("exports rerun requires a job id");
3705
4027
  return await rerun(id, commandFlags, globalFlags);
@@ -3710,9 +4032,9 @@ const exportsCommand = {
3710
4032
  }
3711
4033
  }
3712
4034
  };
3713
- async function list$1(commandFlags, globalFlags) {
4035
+ async function list$2(commandFlags, globalFlags) {
3714
4036
  const format = resolveListFormat$1(commandFlags.format);
3715
- const limit = parseLimit$1(commandFlags.limit);
4037
+ const limit = parseLimit$2(commandFlags.limit);
3716
4038
  const config = await loadConfig({
3717
4039
  globalFlags,
3718
4040
  subcommandFlags: commandFlags
@@ -3739,6 +4061,110 @@ async function rerun(id, commandFlags, globalFlags) {
3739
4061
  return 0;
3740
4062
  }
3741
4063
  //#endregion
4064
+ //#region src/commands/folder.ts
4065
+ function folderHelp() {
4066
+ return `Granola folder
4067
+
4068
+ Usage:
4069
+ granola folder <list|view> [options]
4070
+
4071
+ Subcommands:
4072
+ list List folders from the Granola API
4073
+ view <id|name> Show one folder and its meetings
4074
+
4075
+ Options:
4076
+ --format <value> text, json, yaml
4077
+ --limit <n> Number of folders for list (default: 20)
4078
+ --search <query> Filter folders by id, name, or description
4079
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4080
+ --supabase <path> Path to supabase.json
4081
+ --debug Enable debug logging
4082
+ --config <path> Path to .granola.toml
4083
+ -h, --help Show help
4084
+ `;
4085
+ }
4086
+ function resolveFolderListFormat(value) {
4087
+ switch (value) {
4088
+ case void 0: return "text";
4089
+ case "json":
4090
+ case "text":
4091
+ case "yaml": return value;
4092
+ default: throw new Error("invalid folder format: expected text, json, or yaml");
4093
+ }
4094
+ }
4095
+ function resolveFolderDetailFormat(value) {
4096
+ return resolveFolderListFormat(value);
4097
+ }
4098
+ function parseLimit$1(value) {
4099
+ if (value === void 0) return 20;
4100
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid folder limit: expected a positive integer");
4101
+ const limit = Number(value);
4102
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid folder limit: expected a positive integer");
4103
+ return limit;
4104
+ }
4105
+ const folderCommand = {
4106
+ description: "Inspect Granola folders and their meetings",
4107
+ flags: {
4108
+ format: { type: "string" },
4109
+ help: { type: "boolean" },
4110
+ limit: { type: "string" },
4111
+ search: { type: "string" },
4112
+ timeout: { type: "string" }
4113
+ },
4114
+ help: folderHelp,
4115
+ name: "folder",
4116
+ async run({ commandArgs, commandFlags, globalFlags }) {
4117
+ const [action, query] = commandArgs;
4118
+ switch (action) {
4119
+ case "list": return await list$1(commandFlags, globalFlags);
4120
+ case "view":
4121
+ if (!query) throw new Error("folder view requires an id or name");
4122
+ return await view$1(query, commandFlags, globalFlags);
4123
+ case void 0:
4124
+ console.log(folderHelp());
4125
+ return 1;
4126
+ default: throw new Error("invalid folder command: expected list or view");
4127
+ }
4128
+ }
4129
+ };
4130
+ async function list$1(commandFlags, globalFlags) {
4131
+ const format = resolveFolderListFormat(commandFlags.format);
4132
+ const limit = parseLimit$1(commandFlags.limit);
4133
+ const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
4134
+ const config = await loadConfig({
4135
+ globalFlags,
4136
+ subcommandFlags: commandFlags
4137
+ });
4138
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
4139
+ debug(config.debug, "supabase", config.supabase);
4140
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4141
+ const app = await createGranolaApp(config);
4142
+ debug(config.debug, "authMode", app.getState().auth.mode);
4143
+ console.log("Loading folders...");
4144
+ const result = await app.listFolders({
4145
+ limit,
4146
+ search
4147
+ });
4148
+ console.log(renderFolderList(result.folders, format).trimEnd());
4149
+ return 0;
4150
+ }
4151
+ async function view$1(query, commandFlags, globalFlags) {
4152
+ const format = resolveFolderDetailFormat(commandFlags.format);
4153
+ const config = await loadConfig({
4154
+ globalFlags,
4155
+ subcommandFlags: commandFlags
4156
+ });
4157
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
4158
+ debug(config.debug, "supabase", config.supabase);
4159
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4160
+ const app = await createGranolaApp(config);
4161
+ debug(config.debug, "authMode", app.getState().auth.mode);
4162
+ console.log("Fetching folder from Granola API...");
4163
+ const result = await app.findFolder(query);
4164
+ console.log(renderFolderView(result, format).trimEnd());
4165
+ return 0;
4166
+ }
4167
+ //#endregion
3742
4168
  //#region src/browser.ts
3743
4169
  const execFileAsync = promisify(execFile);
3744
4170
  function getBrowserOpenCommand(url, platform = process.platform) {
@@ -5430,6 +5856,7 @@ async function startGranolaServer(app, options = {}) {
5430
5856
  auth: true,
5431
5857
  events: true,
5432
5858
  exports: true,
5859
+ folders: true,
5433
5860
  meetingOpen: true,
5434
5861
  webClient: enableWebClient
5435
5862
  },
@@ -5439,7 +5866,7 @@ async function startGranolaServer(app, options = {}) {
5439
5866
  sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind
5440
5867
  },
5441
5868
  product: "granola-toolkit",
5442
- protocolVersion: 1,
5869
+ protocolVersion: 2,
5443
5870
  transport: "local-http"
5444
5871
  };
5445
5872
  const server = createServer(async (request, response) => {
@@ -5561,6 +5988,7 @@ async function startGranolaServer(app, options = {}) {
5561
5988
  return;
5562
5989
  }
5563
5990
  if (method === "GET" && path === granolaTransportPaths.meetings) {
5991
+ const folderId = url.searchParams.get("folderId")?.trim() || void 0;
5564
5992
  const limit = parseInteger(url.searchParams.get("limit"));
5565
5993
  const refresh = url.searchParams.get("refresh") === "true";
5566
5994
  const search = url.searchParams.get("search")?.trim() || void 0;
@@ -5568,6 +5996,7 @@ async function startGranolaServer(app, options = {}) {
5568
5996
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
5569
5997
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
5570
5998
  const result = await app.listMeetings({
5999
+ folderId,
5571
6000
  forceRefresh: refresh,
5572
6001
  limit,
5573
6002
  search,
@@ -5576,6 +6005,7 @@ async function startGranolaServer(app, options = {}) {
5576
6005
  updatedTo
5577
6006
  });
5578
6007
  sendJson(response, {
6008
+ folderId,
5579
6009
  meetings: result.meetings,
5580
6010
  refresh,
5581
6011
  search,
@@ -5586,12 +6016,39 @@ async function startGranolaServer(app, options = {}) {
5586
6016
  }, { headers: originHeaders });
5587
6017
  return;
5588
6018
  }
6019
+ if (method === "GET" && path === granolaTransportPaths.folders) {
6020
+ const limit = parseInteger(url.searchParams.get("limit"));
6021
+ const refresh = url.searchParams.get("refresh") === "true";
6022
+ const search = url.searchParams.get("search")?.trim() || void 0;
6023
+ sendJson(response, {
6024
+ folders: (await app.listFolders({
6025
+ forceRefresh: refresh,
6026
+ limit,
6027
+ search
6028
+ })).folders,
6029
+ refresh,
6030
+ search
6031
+ }, { headers: originHeaders });
6032
+ return;
6033
+ }
6034
+ if (method === "GET" && path === granolaTransportPaths.folderResolve) {
6035
+ const query = url.searchParams.get("q")?.trim();
6036
+ if (!query) throw new Error("folder query is required");
6037
+ sendJson(response, await app.findFolder(query), { headers: originHeaders });
6038
+ return;
6039
+ }
5589
6040
  if (method === "GET" && path === granolaTransportPaths.meetingResolve) {
5590
6041
  const query = url.searchParams.get("q")?.trim();
5591
6042
  if (!query) throw new Error("meeting query is required");
5592
6043
  sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5593
6044
  return;
5594
6045
  }
6046
+ if (method === "GET" && path.startsWith(`${granolaTransportPaths.folders}/`) && path !== granolaTransportPaths.folderResolve) {
6047
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.folders}/`.length));
6048
+ if (!id) throw new Error("folder id is required");
6049
+ sendJson(response, await app.getFolder(id), { headers: originHeaders });
6050
+ return;
6051
+ }
5595
6052
  if (method === "GET" && path.startsWith(`${granolaTransportPaths.meetings}/`) && path !== granolaTransportPaths.meetingResolve) {
5596
6053
  const id = decodeURIComponent(path.slice(`${granolaTransportPaths.meetings}/`.length));
5597
6054
  if (!id) throw new Error("meeting id is required");
@@ -5776,6 +6233,7 @@ Subcommands:
5776
6233
 
5777
6234
  Options:
5778
6235
  --cache <path> Path to Granola cache JSON for transcript data
6236
+ --folder <query> Filter list to one folder id or name
5779
6237
  --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
5780
6238
  --network <mode> open: local or lan (default: local)
5781
6239
  --hostname <value> open: hostname to bind (overrides network default)
@@ -5849,6 +6307,7 @@ const meetingCommand = {
5849
6307
  description: "Inspect and export individual Granola meetings",
5850
6308
  flags: {
5851
6309
  cache: { type: "string" },
6310
+ folder: { type: "string" },
5852
6311
  format: { type: "string" },
5853
6312
  help: { type: "boolean" },
5854
6313
  hostname: { type: "string" },
@@ -5892,6 +6351,7 @@ const meetingCommand = {
5892
6351
  async function list(commandFlags, globalFlags) {
5893
6352
  const format = resolveListFormat(commandFlags.format);
5894
6353
  const limit = parseLimit(commandFlags.limit);
6354
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
5895
6355
  const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
5896
6356
  const config = await loadConfig({
5897
6357
  globalFlags,
@@ -5904,11 +6364,15 @@ async function list(commandFlags, globalFlags) {
5904
6364
  const app = await createGranolaApp(config);
5905
6365
  debug(config.debug, "authMode", app.getState().auth.mode);
5906
6366
  console.log("Loading meetings...");
6367
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
6368
+ const folderId = folder?.id;
5907
6369
  const result = await app.listMeetings({
6370
+ folderId,
5908
6371
  limit,
5909
6372
  search
5910
6373
  });
5911
6374
  console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
6375
+ if (folder) console.log(`Folder: ${folder.name} (${folder.id})`);
5912
6376
  console.log(renderMeetingList(result.meetings, format).trimEnd());
5913
6377
  return 0;
5914
6378
  }
@@ -6271,6 +6735,7 @@ const commands = [
6271
6735
  attachCommand,
6272
6736
  authCommand,
6273
6737
  exportsCommand,
6738
+ folderCommand,
6274
6739
  meetingCommand,
6275
6740
  notesCommand,
6276
6741
  serveCommand,
@@ -6399,6 +6864,7 @@ Global options:
6399
6864
 
6400
6865
  Examples:
6401
6866
  granola attach http://127.0.0.1:4123
6867
+ granola folder list
6402
6868
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
6403
6869
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
6404
6870
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",