granola-toolkit 0.29.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 +38 -0
  2. package/dist/cli.js +647 -98
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9,6 +9,76 @@ import { NodeHtmlMarkdown } from "node-html-markdown";
9
9
  import { execFile } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
11
  import { createServer } from "node:http";
12
+ //#region src/transport.ts
13
+ const granolaTransportPaths = {
14
+ authLock: "/auth/lock",
15
+ authLogin: "/auth/login",
16
+ authLogout: "/auth/logout",
17
+ authMode: "/auth/mode",
18
+ authRefresh: "/auth/refresh",
19
+ authStatus: "/auth/status",
20
+ authUnlock: "/auth/unlock",
21
+ events: "/events",
22
+ exportJobs: "/exports/jobs",
23
+ exportNotes: "/exports/notes",
24
+ exportTranscripts: "/exports/transcripts",
25
+ folderResolve: "/folders/resolve",
26
+ folders: "/folders",
27
+ health: "/health",
28
+ meetingResolve: "/meetings/resolve",
29
+ meetings: "/meetings",
30
+ root: "/",
31
+ serverInfo: "/server/info",
32
+ state: "/state"
33
+ };
34
+ function appendSearchParams(path, params) {
35
+ const url = new URL(path, "http://localhost");
36
+ for (const [key, value] of Object.entries(params)) {
37
+ if (value === void 0 || value === false || value === "") continue;
38
+ url.searchParams.set(key, String(value));
39
+ }
40
+ return `${url.pathname}${url.search}`;
41
+ }
42
+ function granolaMeetingPath(id) {
43
+ return `${granolaTransportPaths.meetings}/${encodeURIComponent(id)}`;
44
+ }
45
+ function granolaMeetingResolvePath(query, options = {}) {
46
+ return appendSearchParams(granolaTransportPaths.meetingResolve, {
47
+ includeTranscript: options.includeTranscript ? "true" : void 0,
48
+ q: query
49
+ });
50
+ }
51
+ function granolaMeetingsPath(options = {}) {
52
+ return appendSearchParams(granolaTransportPaths.meetings, {
53
+ folderId: options.folderId,
54
+ limit: options.limit,
55
+ refresh: options.forceRefresh ? "true" : void 0,
56
+ search: options.search,
57
+ sort: options.sort,
58
+ updatedFrom: options.updatedFrom,
59
+ updatedTo: options.updatedTo
60
+ });
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
+ }
75
+ function granolaExportJobsPath(options = {}) {
76
+ return appendSearchParams(granolaTransportPaths.exportJobs, { limit: options.limit });
77
+ }
78
+ function granolaExportJobRerunPath(id) {
79
+ return `${granolaTransportPaths.exportJobs}/${encodeURIComponent(id)}/rerun`;
80
+ }
81
+ //#endregion
12
82
  //#region src/server/client.ts
13
83
  function cloneValue(value) {
14
84
  return structuredClone(value);
@@ -24,14 +94,6 @@ function normaliseServerUrl(serverUrl) {
24
94
  parsed.hash = "";
25
95
  return parsed;
26
96
  }
27
- function appendSearchParams(path, params) {
28
- const url = new URL(path, "http://localhost");
29
- for (const [key, value] of Object.entries(params)) {
30
- if (value === void 0 || value === false || value === "") continue;
31
- url.searchParams.set(key, String(value));
32
- }
33
- return `${url.pathname}${url.search}`;
34
- }
35
97
  function mergeHeaders(...values) {
36
98
  const headers = new Headers();
37
99
  for (const value of values) {
@@ -66,22 +128,32 @@ var GranolaServerClient = class GranolaServerClient {
66
128
  #fetchImpl;
67
129
  #password;
68
130
  #reconnectDelayMs;
131
+ info;
69
132
  #streamAbortController;
70
- constructor(url, initialState, options = {}) {
133
+ constructor(info, url, initialState, options = {}) {
71
134
  this.url = url;
72
135
  this.#fetchImpl = options.fetchImpl ?? fetch;
136
+ this.info = cloneValue(info);
73
137
  this.#password = options.password?.trim() || void 0;
74
138
  this.#reconnectDelayMs = options.reconnectDelayMs ?? 1e3;
75
139
  this.#state = cloneValue(initialState);
76
140
  }
77
141
  static async connect(serverUrl, options = {}) {
78
142
  const url = normaliseServerUrl(serverUrl);
79
- const response = await (options.fetchImpl ?? fetch)(new URL("/state", url), { headers: mergeHeaders({
143
+ const fetchImpl = options.fetchImpl ?? fetch;
144
+ const infoResponse = await fetchImpl(new URL(granolaTransportPaths.serverInfo, url), { headers: mergeHeaders({
145
+ ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
146
+ accept: "application/json"
147
+ }) });
148
+ if (!infoResponse.ok) throw await responseError(infoResponse);
149
+ const info = await infoResponse.json();
150
+ if (info.protocolVersion !== 2) throw new Error(`unsupported Granola transport protocol: expected 2, got ${info.protocolVersion}`);
151
+ const response = await fetchImpl(new URL(granolaTransportPaths.state, url), { headers: mergeHeaders({
80
152
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
81
153
  accept: "application/json"
82
154
  }) });
83
155
  if (!response.ok) throw await responseError(response);
84
- const client = new GranolaServerClient(url, await response.json(), options);
156
+ const client = new GranolaServerClient(info, url, await response.json(), options);
85
157
  client.startEvents();
86
158
  return client;
87
159
  }
@@ -103,66 +175,65 @@ var GranolaServerClient = class GranolaServerClient {
103
175
  };
104
176
  }
105
177
  async inspectAuth() {
106
- return await this.requestJson("/auth/status");
178
+ return await this.requestJson(granolaTransportPaths.authStatus);
107
179
  }
108
180
  async loginAuth(options = {}) {
109
- return await this.requestJson("/auth/login", {
181
+ return await this.requestJson(granolaTransportPaths.authLogin, {
110
182
  body: JSON.stringify(options),
111
183
  headers: { "content-type": "application/json" },
112
184
  method: "POST"
113
185
  });
114
186
  }
115
187
  async logoutAuth() {
116
- return await this.requestJson("/auth/logout", { method: "POST" });
188
+ return await this.requestJson(granolaTransportPaths.authLogout, { method: "POST" });
117
189
  }
118
190
  async refreshAuth() {
119
- return await this.requestJson("/auth/refresh", { method: "POST" });
191
+ return await this.requestJson(granolaTransportPaths.authRefresh, { method: "POST" });
120
192
  }
121
193
  async switchAuthMode(mode) {
122
- return await this.requestJson("/auth/mode", {
194
+ return await this.requestJson(granolaTransportPaths.authMode, {
123
195
  body: JSON.stringify({ mode }),
124
196
  headers: { "content-type": "application/json" },
125
197
  method: "POST"
126
198
  });
127
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
+ }
128
209
  async listMeetings(options = {}) {
129
- return await this.requestJson(appendSearchParams("/meetings", {
130
- limit: options.limit,
131
- refresh: options.forceRefresh ? "true" : void 0,
132
- search: options.search,
133
- sort: options.sort,
134
- updatedFrom: options.updatedFrom,
135
- updatedTo: options.updatedTo
136
- }));
210
+ return await this.requestJson(granolaMeetingsPath(options));
137
211
  }
138
212
  async getMeeting(id, options = {}) {
139
- return await this.requestJson(appendSearchParams(`/meetings/${encodeURIComponent(id)}`, { includeTranscript: options.requireCache ? "true" : void 0 }));
213
+ return await this.requestJson(`${granolaMeetingPath(id)}${options.requireCache ? "?includeTranscript=true" : ""}`);
140
214
  }
141
215
  async findMeeting(query, options = {}) {
142
- return await this.requestJson(appendSearchParams("/meetings/resolve", {
143
- includeTranscript: options.requireCache ? "true" : void 0,
144
- q: query
145
- }));
216
+ return await this.requestJson(granolaMeetingResolvePath(query, { includeTranscript: options.requireCache }));
146
217
  }
147
218
  async listExportJobs(options = {}) {
148
- return await this.requestJson(appendSearchParams("/exports/jobs", { limit: options.limit }));
219
+ return await this.requestJson(granolaExportJobsPath(options));
149
220
  }
150
221
  async exportNotes(format = "markdown") {
151
- return await this.requestJson("/exports/notes", {
222
+ return await this.requestJson(granolaTransportPaths.exportNotes, {
152
223
  body: JSON.stringify({ format }),
153
224
  headers: { "content-type": "application/json" },
154
225
  method: "POST"
155
226
  });
156
227
  }
157
228
  async exportTranscripts(format = "text") {
158
- return await this.requestJson("/exports/transcripts", {
229
+ return await this.requestJson(granolaTransportPaths.exportTranscripts, {
159
230
  body: JSON.stringify({ format }),
160
231
  headers: { "content-type": "application/json" },
161
232
  method: "POST"
162
233
  });
163
234
  }
164
235
  async rerunExportJob(id) {
165
- return await this.requestJson(`/exports/jobs/${encodeURIComponent(id)}/rerun`, { method: "POST" });
236
+ return await this.requestJson(granolaExportJobRerunPath(id), { method: "POST" });
166
237
  }
167
238
  async request(path, init = {}) {
168
239
  const response = await this.#fetchImpl(new URL(path, this.url), {
@@ -192,7 +263,7 @@ var GranolaServerClient = class GranolaServerClient {
192
263
  const controller = new AbortController();
193
264
  this.#streamAbortController = controller;
194
265
  try {
195
- const response = await this.request("/events", {
266
+ const response = await this.request(granolaTransportPaths.events, {
196
267
  headers: { accept: "text/event-stream" },
197
268
  signal: controller.signal
198
269
  });
@@ -1029,6 +1100,10 @@ function matchesMeetingSearch(document, search) {
1029
1100
  ...document.tags
1030
1101
  ].some((value) => value.toLowerCase().includes(query));
1031
1102
  }
1103
+ function matchesMeetingFolders(documentId, folderId, foldersByDocumentId) {
1104
+ if (!folderId) return true;
1105
+ return (foldersByDocumentId?.get(documentId) ?? []).some((folder) => folder.id === folderId);
1106
+ }
1032
1107
  function matchesMeetingSummarySearch(meeting, search) {
1033
1108
  const query = search.trim().toLowerCase();
1034
1109
  if (!query) return true;
@@ -1038,6 +1113,9 @@ function matchesMeetingSummarySearch(meeting, search) {
1038
1113
  ...meeting.tags
1039
1114
  ].some((value) => value.toLowerCase().includes(query));
1040
1115
  }
1116
+ function meetingFolders(meeting) {
1117
+ return Array.isArray(meeting.folders) ? meeting.folders : [];
1118
+ }
1041
1119
  function parseDateFilter(value, label) {
1042
1120
  const trimmed = value?.trim();
1043
1121
  if (!trimmed) return;
@@ -1064,7 +1142,7 @@ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1064
1142
  if (to != null && updatedAt > to) return false;
1065
1143
  return true;
1066
1144
  }
1067
- function truncate(value, width) {
1145
+ function truncate$1(value, width) {
1068
1146
  if (value.length <= width) return value.padEnd(width);
1069
1147
  return `${value.slice(0, Math.max(0, width - 1))}…`;
1070
1148
  }
@@ -1080,11 +1158,12 @@ function formatTranscriptLines(transcript) {
1080
1158
  if (!transcript || transcript.segments.length === 0) return "";
1081
1159
  return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
1082
1160
  }
1083
- function buildMeetingSummary(document, cacheData) {
1161
+ function buildMeetingSummary(document, cacheData, folders = []) {
1084
1162
  const note = buildNoteExport(document);
1085
1163
  const transcript = buildMeetingTranscript(document, cacheData);
1086
1164
  return {
1087
1165
  createdAt: document.createdAt,
1166
+ folders: folders.map((folder) => ({ ...folder })),
1088
1167
  id: document.id,
1089
1168
  noteContentSource: note.contentSource,
1090
1169
  tags: [...document.tags],
@@ -1094,12 +1173,13 @@ function buildMeetingSummary(document, cacheData) {
1094
1173
  updatedAt: latestDocumentTimestamp(document)
1095
1174
  };
1096
1175
  }
1097
- function buildMeetingRecord(document, cacheData) {
1176
+ function buildMeetingRecord(document, cacheData, folders = []) {
1098
1177
  const note = buildNoteExport(document);
1099
1178
  const transcript = buildMeetingTranscript(document, cacheData);
1100
1179
  return {
1101
1180
  meeting: {
1102
1181
  createdAt: document.createdAt,
1182
+ folders: folders.map((folder) => ({ ...folder })),
1103
1183
  id: document.id,
1104
1184
  noteContentSource: note.contentSource,
1105
1185
  tags: [...document.tags],
@@ -1117,13 +1197,14 @@ function buildMeetingRecord(document, cacheData) {
1117
1197
  function listMeetings(documents, options = {}) {
1118
1198
  const limit = options.limit ?? 20;
1119
1199
  const sort = options.sort ?? "updated-desc";
1120
- 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)));
1121
1201
  }
1122
1202
  function filterMeetingSummaries(meetings, options = {}) {
1123
1203
  const limit = options.limit ?? 20;
1124
1204
  const sort = options.sort ?? "updated-desc";
1125
- 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) => ({
1126
1206
  ...meeting,
1207
+ folders: meetingFolders(meeting).map((folder) => ({ ...folder })),
1127
1208
  tags: [...meeting.tags]
1128
1209
  }));
1129
1210
  }
@@ -1160,14 +1241,18 @@ function renderMeetingList(meetings, format = "text") {
1160
1241
  case "text": break;
1161
1242
  }
1162
1243
  if (meetings.length === 0) return "No meetings found\n";
1163
- 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)}`];
1164
- for (const meeting of meetings) lines.push([
1165
- meeting.id.slice(0, 8).padEnd(10),
1166
- formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1167
- truncate(meeting.title || meeting.id, 42),
1168
- truncate(meeting.noteContentSource, 18),
1169
- formatTranscriptStatus(meeting)
1170
- ].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
+ }
1171
1256
  return `${lines.join("\n").trimEnd()}\n`;
1172
1257
  }
1173
1258
  function renderMeetingView(record, format = "text") {
@@ -1177,6 +1262,7 @@ function renderMeetingView(record, format = "text") {
1177
1262
  case "text": break;
1178
1263
  }
1179
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)";
1180
1266
  const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1181
1267
  return `${[
1182
1268
  `# ${record.meeting.title || record.meeting.id}`,
@@ -1185,6 +1271,7 @@ function renderMeetingView(record, format = "text") {
1185
1271
  `Created: ${record.meeting.createdAt || "-"}`,
1186
1272
  `Updated: ${record.meeting.updatedAt || "-"}`,
1187
1273
  `Tags: ${tags}`,
1274
+ `Folders: ${folders}`,
1188
1275
  `Note source: ${record.meeting.noteContentSource}`,
1189
1276
  `Transcript: ${transcriptStatus}`,
1190
1277
  "",
@@ -2131,6 +2218,22 @@ function parseCacheContents(contents) {
2131
2218
  };
2132
2219
  }
2133
2220
  //#endregion
2221
+ //#region src/persistence/layout.ts
2222
+ function defaultGranolaToolkitDataDirectory(targetPlatform = platform(), homeDirectory = homedir()) {
2223
+ return targetPlatform === "darwin" ? join(homeDirectory, "Library", "Application Support", "granola-toolkit") : join(homeDirectory, ".config", "granola-toolkit");
2224
+ }
2225
+ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2226
+ const targetPlatform = options.platform ?? platform();
2227
+ const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2228
+ return {
2229
+ dataDirectory,
2230
+ exportJobsFile: join(dataDirectory, "export-jobs.json"),
2231
+ meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2232
+ sessionFile: join(dataDirectory, "session.json"),
2233
+ sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file"
2234
+ };
2235
+ }
2236
+ //#endregion
2134
2237
  //#region src/client/auth.ts
2135
2238
  const execFileAsync$1 = promisify(execFile);
2136
2239
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
@@ -2351,11 +2454,10 @@ async function refreshGranolaSession(session, fetchImpl = fetch) {
2351
2454
  };
2352
2455
  }
2353
2456
  function defaultSessionFilePath() {
2354
- const home = homedir();
2355
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
2457
+ return defaultGranolaToolkitPersistenceLayout().sessionFile;
2356
2458
  }
2357
2459
  function createDefaultSessionStore() {
2358
- return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
2460
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
2359
2461
  }
2360
2462
  //#endregion
2361
2463
  //#region src/client/default-auth.ts
@@ -2530,10 +2632,39 @@ function parseDocument(value) {
2530
2632
  updatedAt: stringValue(record.updated_at)
2531
2633
  };
2532
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
+ }
2533
2663
  //#endregion
2534
2664
  //#region src/client/granola.ts
2535
2665
  const DEFAULT_CLIENT_VERSION = "5.354.0";
2536
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"];
2537
2668
  function resolveClientVersion(value) {
2538
2669
  return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
2539
2670
  }
@@ -2579,6 +2710,29 @@ var GranolaApiClient = class {
2579
2710
  }
2580
2711
  return documents;
2581
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
+ }
2582
2736
  };
2583
2737
  //#endregion
2584
2738
  //#region src/client/http.ts
@@ -2736,8 +2890,7 @@ function createExportJobId(kind) {
2736
2890
  return `${kind}-${randomUUID()}`;
2737
2891
  }
2738
2892
  function defaultExportJobsFilePath() {
2739
- const home = homedir();
2740
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
2893
+ return defaultGranolaToolkitPersistenceLayout().exportJobsFile;
2741
2894
  }
2742
2895
  var FileExportJobStore = class {
2743
2896
  constructor(filePath = defaultExportJobsFilePath()) {
@@ -2766,8 +2919,121 @@ function createDefaultExportJobStore() {
2766
2919
  return new FileExportJobStore();
2767
2920
  }
2768
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
2769
3035
  //#region src/meeting-index.ts
2770
- const MEETING_INDEX_VERSION = 1;
3036
+ const MEETING_INDEX_VERSION = 2;
2771
3037
  var FileMeetingIndexStore = class {
2772
3038
  constructor(filePath = defaultMeetingIndexFilePath()) {
2773
3039
  this.filePath = filePath;
@@ -2778,6 +3044,7 @@ var FileMeetingIndexStore = class {
2778
3044
  if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
2779
3045
  return parsed.meetings.map((meeting) => ({
2780
3046
  ...meeting,
3047
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2781
3048
  tags: [...meeting.tags]
2782
3049
  }));
2783
3050
  } catch {
@@ -2789,6 +3056,7 @@ var FileMeetingIndexStore = class {
2789
3056
  const payload = {
2790
3057
  meetings: meetings.map((meeting) => ({
2791
3058
  ...meeting,
3059
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2792
3060
  tags: [...meeting.tags]
2793
3061
  })),
2794
3062
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2801,8 +3069,7 @@ var FileMeetingIndexStore = class {
2801
3069
  }
2802
3070
  };
2803
3071
  function defaultMeetingIndexFilePath() {
2804
- const home = homedir();
2805
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "meeting-index.json") : join(home, ".config", "granola-toolkit", "meeting-index.json");
3072
+ return defaultGranolaToolkitPersistenceLayout().meetingIndexFile;
2806
3073
  }
2807
3074
  function createDefaultMeetingIndexStore() {
2808
3075
  return new FileMeetingIndexStore();
@@ -2818,6 +3085,16 @@ function cloneExportState(state) {
2818
3085
  function cloneExportJob(job) {
2819
3086
  return { ...job };
2820
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
+ }
2821
3098
  function cloneState(state) {
2822
3099
  return {
2823
3100
  auth: { ...state.auth },
@@ -2828,6 +3105,7 @@ function cloneState(state) {
2828
3105
  transcripts: { ...state.config.transcripts }
2829
3106
  },
2830
3107
  documents: { ...state.documents },
3108
+ folders: { ...state.folders },
2831
3109
  exports: {
2832
3110
  jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
2833
3111
  notes: cloneExportState(state.exports.notes),
@@ -2856,6 +3134,10 @@ function defaultState(config, auth, surface) {
2856
3134
  count: 0,
2857
3135
  loaded: false
2858
3136
  },
3137
+ folders: {
3138
+ count: 0,
3139
+ loaded: false
3140
+ },
2859
3141
  exports: { jobs: [] },
2860
3142
  index: {
2861
3143
  available: false,
@@ -2872,6 +3154,7 @@ function defaultState(config, auth, surface) {
2872
3154
  var GranolaApp = class {
2873
3155
  #cacheData;
2874
3156
  #cacheResolved = false;
3157
+ #folders;
2875
3158
  #granolaClient;
2876
3159
  #documents;
2877
3160
  #meetingIndex;
@@ -2883,10 +3166,7 @@ var GranolaApp = class {
2883
3166
  this.deps = deps;
2884
3167
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
2885
3168
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
2886
- this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
2887
- ...meeting,
2888
- tags: [...meeting.tags]
2889
- }));
3169
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => cloneMeetingSummary(meeting));
2890
3170
  this.#state.index = {
2891
3171
  available: this.#meetingIndex.length > 0,
2892
3172
  filePath: defaultMeetingIndexFilePath(),
@@ -2912,16 +3192,21 @@ var GranolaApp = class {
2912
3192
  this.emitStateUpdate();
2913
3193
  return this.getState();
2914
3194
  }
2915
- resetDocumentsState() {
3195
+ resetRemoteState() {
2916
3196
  this.#granolaClient = void 0;
3197
+ this.#folders = void 0;
2917
3198
  this.#documents = void 0;
2918
3199
  this.#state.documents = {
2919
3200
  count: 0,
2920
3201
  loaded: false
2921
3202
  };
3203
+ this.#state.folders = {
3204
+ count: 0,
3205
+ loaded: false
3206
+ };
2922
3207
  }
2923
3208
  applyAuthState(auth, options = {}) {
2924
- if (options.resetDocuments) this.resetDocumentsState();
3209
+ if (options.resetDocuments) this.resetRemoteState();
2925
3210
  this.#state.auth = { ...auth };
2926
3211
  if (options.view) this.#state.ui = {
2927
3212
  ...this.#state.ui,
@@ -2931,10 +3216,7 @@ var GranolaApp = class {
2931
3216
  return { ...auth };
2932
3217
  }
2933
3218
  async persistMeetingIndex(meetings) {
2934
- this.#meetingIndex = meetings.map((meeting) => ({
2935
- ...meeting,
2936
- tags: [...meeting.tags]
2937
- }));
3219
+ this.#meetingIndex = meetings.map((meeting) => cloneMeetingSummary(meeting));
2938
3220
  this.#state.index = {
2939
3221
  available: this.#meetingIndex.length > 0,
2940
3222
  filePath: this.#state.index.filePath,
@@ -2948,8 +3230,10 @@ var GranolaApp = class {
2948
3230
  async refreshMeetingIndexFromLiveData() {
2949
3231
  const cacheData = await this.loadCache();
2950
3232
  const documents = await this.listDocuments();
3233
+ const folders = await this.loadFolders();
2951
3234
  const meetings = listMeetings(documents, {
2952
3235
  cacheData,
3236
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
2953
3237
  limit: documents.length || 1,
2954
3238
  sort: "updated-desc"
2955
3239
  });
@@ -2988,6 +3272,54 @@ var GranolaApp = class {
2988
3272
  this.applyAuthState(runtime.auth);
2989
3273
  return this.#granolaClient;
2990
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
+ }
2991
3323
  missingCacheError() {
2992
3324
  return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
2993
3325
  }
@@ -3100,12 +3432,7 @@ var GranolaApp = class {
3100
3432
  }
3101
3433
  async listDocuments(options = {}) {
3102
3434
  if (options.forceRefresh) {
3103
- this.#granolaClient = void 0;
3104
- this.#documents = void 0;
3105
- this.#state.documents = {
3106
- count: 0,
3107
- loaded: false
3108
- };
3435
+ this.resetRemoteState();
3109
3436
  this.emitStateUpdate();
3110
3437
  }
3111
3438
  if (this.#documents) return this.#documents;
@@ -3146,16 +3473,57 @@ var GranolaApp = class {
3146
3473
  if (options.required && !cacheData) throw this.missingCacheError();
3147
3474
  return cacheData;
3148
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
+ }
3149
3515
  async listMeetings(options = {}) {
3150
3516
  const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
3151
3517
  if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
3152
3518
  const meetings = filterMeetingSummaries(this.#meetingIndex, options);
3153
3519
  this.setUiState({
3520
+ folderSearch: void 0,
3154
3521
  meetingListSource: "index",
3155
3522
  meetingSearch: options.search,
3156
3523
  meetingSort: options.sort,
3157
3524
  meetingUpdatedFrom: options.updatedFrom,
3158
3525
  meetingUpdatedTo: options.updatedTo,
3526
+ selectedFolderId: options.folderId,
3159
3527
  selectedMeetingId: void 0,
3160
3528
  view: "meeting-list"
3161
3529
  });
@@ -3167,8 +3535,14 @@ var GranolaApp = class {
3167
3535
  }
3168
3536
  const cacheData = await this.loadCache();
3169
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
+ });
3170
3542
  const meetings = listMeetings(documents, {
3171
3543
  cacheData,
3544
+ folderId: options.folderId,
3545
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3172
3546
  limit: options.limit,
3173
3547
  search: options.search,
3174
3548
  sort: options.sort,
@@ -3177,15 +3551,18 @@ var GranolaApp = class {
3177
3551
  });
3178
3552
  await this.persistMeetingIndex(listMeetings(documents, {
3179
3553
  cacheData,
3554
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3180
3555
  limit: Math.max(documents.length, 1),
3181
3556
  sort: "updated-desc"
3182
3557
  }));
3183
3558
  this.setUiState({
3559
+ folderSearch: void 0,
3184
3560
  meetingListSource: "live",
3185
3561
  meetingSearch: options.search,
3186
3562
  meetingSort: options.sort,
3187
3563
  meetingUpdatedFrom: options.updatedFrom,
3188
3564
  meetingUpdatedTo: options.updatedTo,
3565
+ selectedFolderId: options.folderId,
3189
3566
  selectedMeetingId: void 0,
3190
3567
  view: "meeting-list"
3191
3568
  });
@@ -3197,9 +3574,11 @@ var GranolaApp = class {
3197
3574
  async getMeeting(id, options = {}) {
3198
3575
  const documents = await this.listDocuments();
3199
3576
  const cacheData = await this.loadCache({ required: options.requireCache });
3577
+ const folders = await this.loadFolders();
3200
3578
  const document = resolveMeeting(documents, id);
3201
- const meeting = buildMeetingRecord(document, cacheData);
3579
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3202
3580
  this.setUiState({
3581
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3203
3582
  selectedMeetingId: document.id,
3204
3583
  view: "meeting-detail"
3205
3584
  });
@@ -3212,9 +3591,11 @@ var GranolaApp = class {
3212
3591
  async findMeeting(query, options = {}) {
3213
3592
  const documents = await this.listDocuments();
3214
3593
  const cacheData = await this.loadCache({ required: options.requireCache });
3594
+ const folders = await this.loadFolders();
3215
3595
  const document = resolveMeetingQuery(documents, query);
3216
- const meeting = buildMeetingRecord(document, cacheData);
3596
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3217
3597
  this.setUiState({
3598
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3218
3599
  selectedMeetingId: document.id,
3219
3600
  view: "meeting-detail"
3220
3601
  });
@@ -3611,7 +3992,7 @@ function resolveListFormat$1(value) {
3611
3992
  default: throw new Error("invalid exports format: expected text, json, or yaml");
3612
3993
  }
3613
3994
  }
3614
- function parseLimit$1(value) {
3995
+ function parseLimit$2(value) {
3615
3996
  if (value === void 0) return 20;
3616
3997
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
3617
3998
  const limit = Number(value);
@@ -3640,7 +4021,7 @@ const exportsCommand = {
3640
4021
  async run({ commandArgs, commandFlags, globalFlags }) {
3641
4022
  const [action, id] = commandArgs;
3642
4023
  switch (action) {
3643
- case "list": return await list$1(commandFlags, globalFlags);
4024
+ case "list": return await list$2(commandFlags, globalFlags);
3644
4025
  case "rerun":
3645
4026
  if (!id) throw new Error("exports rerun requires a job id");
3646
4027
  return await rerun(id, commandFlags, globalFlags);
@@ -3651,9 +4032,9 @@ const exportsCommand = {
3651
4032
  }
3652
4033
  }
3653
4034
  };
3654
- async function list$1(commandFlags, globalFlags) {
4035
+ async function list$2(commandFlags, globalFlags) {
3655
4036
  const format = resolveListFormat$1(commandFlags.format);
3656
- const limit = parseLimit$1(commandFlags.limit);
4037
+ const limit = parseLimit$2(commandFlags.limit);
3657
4038
  const config = await loadConfig({
3658
4039
  globalFlags,
3659
4040
  subcommandFlags: commandFlags
@@ -3680,6 +4061,110 @@ async function rerun(id, commandFlags, globalFlags) {
3680
4061
  return 0;
3681
4062
  }
3682
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
3683
4168
  //#region src/browser.ts
3684
4169
  const execFileAsync = promisify(execFile);
3685
4170
  function getBrowserOpenCommand(url, platform = process.platform) {
@@ -5355,7 +5840,7 @@ function isPasswordAuthenticated(request, password) {
5355
5840
  return parseCookies(request)[PASSWORD_COOKIE_NAME] === password;
5356
5841
  }
5357
5842
  function publicRoute(path, enableWebClient) {
5358
- return path === "/health" || path === "/auth/unlock" || enableWebClient && path === "/";
5843
+ return path === granolaTransportPaths.health || path === granolaTransportPaths.serverInfo || path === granolaTransportPaths.authUnlock || enableWebClient && path === granolaTransportPaths.root;
5359
5844
  }
5360
5845
  async function startGranolaServer(app, options = {}) {
5361
5846
  const enableWebClient = options.enableWebClient ?? false;
@@ -5365,6 +5850,25 @@ async function startGranolaServer(app, options = {}) {
5365
5850
  password: options.security?.password?.trim() || void 0,
5366
5851
  trustedOrigins: (options.security?.trustedOrigins ?? []).map((origin) => origin.trim()).filter(Boolean)
5367
5852
  };
5853
+ const serverInfo = {
5854
+ capabilities: {
5855
+ attach: true,
5856
+ auth: true,
5857
+ events: true,
5858
+ exports: true,
5859
+ folders: true,
5860
+ meetingOpen: true,
5861
+ webClient: enableWebClient
5862
+ },
5863
+ persistence: {
5864
+ exportJobs: true,
5865
+ meetingIndex: true,
5866
+ sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind
5867
+ },
5868
+ product: "granola-toolkit",
5869
+ protocolVersion: 2,
5870
+ transport: "local-http"
5871
+ };
5368
5872
  const server = createServer(async (request, response) => {
5369
5873
  const method = request.method ?? "GET";
5370
5874
  const url = new URL(request.url ?? "/", `http://${hostname}`);
@@ -5392,11 +5896,11 @@ async function startGranolaServer(app, options = {}) {
5392
5896
  sendNoContent(response, 204, originHeaders);
5393
5897
  return;
5394
5898
  }
5395
- if (method === "GET" && path === "/" && enableWebClient) {
5899
+ if (method === "GET" && path === granolaTransportPaths.root && enableWebClient) {
5396
5900
  sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
5397
5901
  return;
5398
5902
  }
5399
- if (method === "GET" && path === "/health") {
5903
+ if (method === "GET" && path === granolaTransportPaths.health) {
5400
5904
  sendJson(response, {
5401
5905
  ok: true,
5402
5906
  service: "granola-toolkit",
@@ -5404,7 +5908,11 @@ async function startGranolaServer(app, options = {}) {
5404
5908
  }, { headers: originHeaders });
5405
5909
  return;
5406
5910
  }
5407
- if (method === "POST" && path === "/auth/unlock") {
5911
+ if (method === "GET" && path === granolaTransportPaths.serverInfo) {
5912
+ sendJson(response, serverInfo, { headers: originHeaders });
5913
+ return;
5914
+ }
5915
+ if (method === "POST" && path === granolaTransportPaths.authUnlock) {
5408
5916
  if (!security.password) {
5409
5917
  sendJson(response, {
5410
5918
  ok: true,
@@ -5443,22 +5951,22 @@ async function startGranolaServer(app, options = {}) {
5443
5951
  });
5444
5952
  return;
5445
5953
  }
5446
- if (method === "GET" && path === "/state") {
5954
+ if (method === "GET" && path === granolaTransportPaths.state) {
5447
5955
  sendJson(response, app.getState(), { headers: originHeaders });
5448
5956
  return;
5449
5957
  }
5450
- if (method === "GET" && path === "/auth/status") {
5958
+ if (method === "GET" && path === granolaTransportPaths.authStatus) {
5451
5959
  sendJson(response, await app.inspectAuth(), { headers: originHeaders });
5452
5960
  return;
5453
5961
  }
5454
- if (method === "POST" && path === "/auth/lock") {
5962
+ if (method === "POST" && path === granolaTransportPaths.authLock) {
5455
5963
  sendJson(response, { ok: true }, { headers: {
5456
5964
  ...originHeaders,
5457
5965
  "set-cookie": clearPasswordCookieHeader()
5458
5966
  } });
5459
5967
  return;
5460
5968
  }
5461
- if (method === "GET" && path === "/events") {
5969
+ if (method === "GET" && path === granolaTransportPaths.events) {
5462
5970
  response.writeHead(200, {
5463
5971
  "cache-control": "no-cache, no-transform",
5464
5972
  connection: "keep-alive",
@@ -5479,7 +5987,8 @@ async function startGranolaServer(app, options = {}) {
5479
5987
  });
5480
5988
  return;
5481
5989
  }
5482
- if (method === "GET" && path === "/meetings") {
5990
+ if (method === "GET" && path === granolaTransportPaths.meetings) {
5991
+ const folderId = url.searchParams.get("folderId")?.trim() || void 0;
5483
5992
  const limit = parseInteger(url.searchParams.get("limit"));
5484
5993
  const refresh = url.searchParams.get("refresh") === "true";
5485
5994
  const search = url.searchParams.get("search")?.trim() || void 0;
@@ -5487,6 +5996,7 @@ async function startGranolaServer(app, options = {}) {
5487
5996
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
5488
5997
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
5489
5998
  const result = await app.listMeetings({
5999
+ folderId,
5490
6000
  forceRefresh: refresh,
5491
6001
  limit,
5492
6002
  search,
@@ -5495,6 +6005,7 @@ async function startGranolaServer(app, options = {}) {
5495
6005
  updatedTo
5496
6006
  });
5497
6007
  sendJson(response, {
6008
+ folderId,
5498
6009
  meetings: result.meetings,
5499
6010
  refresh,
5500
6011
  search,
@@ -5505,38 +6016,65 @@ async function startGranolaServer(app, options = {}) {
5505
6016
  }, { headers: originHeaders });
5506
6017
  return;
5507
6018
  }
5508
- if (method === "GET" && path === "/meetings/resolve") {
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
+ }
6040
+ if (method === "GET" && path === granolaTransportPaths.meetingResolve) {
5509
6041
  const query = url.searchParams.get("q")?.trim();
5510
6042
  if (!query) throw new Error("meeting query is required");
5511
6043
  sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5512
6044
  return;
5513
6045
  }
5514
- if (method === "GET" && path.startsWith("/meetings/")) {
5515
- const id = decodeURIComponent(path.slice(10));
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
+ }
6052
+ if (method === "GET" && path.startsWith(`${granolaTransportPaths.meetings}/`) && path !== granolaTransportPaths.meetingResolve) {
6053
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.meetings}/`.length));
5516
6054
  if (!id) throw new Error("meeting id is required");
5517
6055
  sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5518
6056
  return;
5519
6057
  }
5520
- if (method === "POST" && path === "/auth/login") {
6058
+ if (method === "POST" && path === granolaTransportPaths.authLogin) {
5521
6059
  const body = await readJsonBody(request);
5522
6060
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
5523
6061
  sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
5524
6062
  return;
5525
6063
  }
5526
- if (method === "POST" && path === "/auth/logout") {
6064
+ if (method === "POST" && path === granolaTransportPaths.authLogout) {
5527
6065
  sendJson(response, await app.logoutAuth(), { headers: originHeaders });
5528
6066
  return;
5529
6067
  }
5530
- if (method === "POST" && path === "/auth/refresh") {
6068
+ if (method === "POST" && path === granolaTransportPaths.authRefresh) {
5531
6069
  sendJson(response, await app.refreshAuth(), { headers: originHeaders });
5532
6070
  return;
5533
6071
  }
5534
- if (method === "POST" && path === "/auth/mode") {
6072
+ if (method === "POST" && path === granolaTransportPaths.authMode) {
5535
6073
  const body = await readJsonBody(request);
5536
6074
  sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
5537
6075
  return;
5538
6076
  }
5539
- if (method === "POST" && path === "/exports/notes") {
6077
+ if (method === "POST" && path === granolaTransportPaths.exportNotes) {
5540
6078
  const body = await readJsonBody(request);
5541
6079
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
5542
6080
  headers: originHeaders,
@@ -5544,13 +6082,13 @@ async function startGranolaServer(app, options = {}) {
5544
6082
  });
5545
6083
  return;
5546
6084
  }
5547
- if (method === "GET" && path === "/exports/jobs") {
6085
+ if (method === "GET" && path === granolaTransportPaths.exportJobs) {
5548
6086
  const limit = parseInteger(url.searchParams.get("limit"));
5549
6087
  sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
5550
6088
  return;
5551
6089
  }
5552
- if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
5553
- const id = decodeURIComponent(path.slice(14, -6));
6090
+ if (method === "POST" && path.startsWith(`${granolaTransportPaths.exportJobs}/`) && path.endsWith("/rerun")) {
6091
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.exportJobs}/`.length, -6));
5554
6092
  if (!id) throw new Error("export job id is required");
5555
6093
  sendJson(response, await app.rerunExportJob(id), {
5556
6094
  headers: originHeaders,
@@ -5558,7 +6096,7 @@ async function startGranolaServer(app, options = {}) {
5558
6096
  });
5559
6097
  return;
5560
6098
  }
5561
- if (method === "POST" && path === "/exports/transcripts") {
6099
+ if (method === "POST" && path === granolaTransportPaths.exportTranscripts) {
5562
6100
  const body = await readJsonBody(request);
5563
6101
  sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
5564
6102
  headers: originHeaders,
@@ -5630,6 +6168,7 @@ function printWebRoutes() {
5630
6168
  console.log("Routes:");
5631
6169
  console.log(" GET /");
5632
6170
  console.log(" GET /health");
6171
+ console.log(" GET /server/info");
5633
6172
  console.log(" POST /auth/unlock");
5634
6173
  console.log(" POST /auth/lock");
5635
6174
  console.log(" GET /auth/status");
@@ -5694,6 +6233,7 @@ Subcommands:
5694
6233
 
5695
6234
  Options:
5696
6235
  --cache <path> Path to Granola cache JSON for transcript data
6236
+ --folder <query> Filter list to one folder id or name
5697
6237
  --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
5698
6238
  --network <mode> open: local or lan (default: local)
5699
6239
  --hostname <value> open: hostname to bind (overrides network default)
@@ -5767,6 +6307,7 @@ const meetingCommand = {
5767
6307
  description: "Inspect and export individual Granola meetings",
5768
6308
  flags: {
5769
6309
  cache: { type: "string" },
6310
+ folder: { type: "string" },
5770
6311
  format: { type: "string" },
5771
6312
  help: { type: "boolean" },
5772
6313
  hostname: { type: "string" },
@@ -5810,6 +6351,7 @@ const meetingCommand = {
5810
6351
  async function list(commandFlags, globalFlags) {
5811
6352
  const format = resolveListFormat(commandFlags.format);
5812
6353
  const limit = parseLimit(commandFlags.limit);
6354
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
5813
6355
  const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
5814
6356
  const config = await loadConfig({
5815
6357
  globalFlags,
@@ -5822,11 +6364,15 @@ async function list(commandFlags, globalFlags) {
5822
6364
  const app = await createGranolaApp(config);
5823
6365
  debug(config.debug, "authMode", app.getState().auth.mode);
5824
6366
  console.log("Loading meetings...");
6367
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
6368
+ const folderId = folder?.id;
5825
6369
  const result = await app.listMeetings({
6370
+ folderId,
5826
6371
  limit,
5827
6372
  search
5828
6373
  });
5829
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})`);
5830
6376
  console.log(renderMeetingList(result.meetings, format).trimEnd());
5831
6377
  return 0;
5832
6378
  }
@@ -6042,6 +6588,7 @@ const serveCommand = {
6042
6588
  if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
6043
6589
  console.log("Endpoints:");
6044
6590
  console.log(" GET /health");
6591
+ console.log(" GET /server/info");
6045
6592
  console.log(" POST /auth/unlock");
6046
6593
  console.log(" POST /auth/lock");
6047
6594
  console.log(" GET /auth/status");
@@ -6188,6 +6735,7 @@ const commands = [
6188
6735
  attachCommand,
6189
6736
  authCommand,
6190
6737
  exportsCommand,
6738
+ folderCommand,
6191
6739
  meetingCommand,
6192
6740
  notesCommand,
6193
6741
  serveCommand,
@@ -6316,6 +6864,7 @@ Global options:
6316
6864
 
6317
6865
  Examples:
6318
6866
  granola attach http://127.0.0.1:4123
6867
+ granola folder list
6319
6868
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
6320
6869
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
6321
6870
  `;