granola-toolkit 0.9.0 → 0.10.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 +36 -0
  2. package/dist/cli.js +466 -126
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,6 +32,7 @@ Installed CLI:
32
32
  ```bash
33
33
  granola --help
34
34
  granola auth login
35
+ granola meeting --help
35
36
  granola notes --help
36
37
  granola transcripts --help
37
38
  ```
@@ -41,6 +42,7 @@ Local build:
41
42
  ```bash
42
43
  vp pack
43
44
  node dist/cli.js --help
45
+ node dist/cli.js meeting --help
44
46
  node dist/cli.js notes --help
45
47
  node dist/cli.js transcripts --help
46
48
  ```
@@ -49,6 +51,7 @@ You can also use the package scripts:
49
51
 
50
52
  ```bash
51
53
  npm run build
54
+ npm run start -- meeting --help
52
55
  npm run notes -- --help
53
56
  npm run transcripts -- --help
54
57
  ```
@@ -72,6 +75,15 @@ node dist/cli.js transcripts --cache "$HOME/Library/Application Support/Granola/
72
75
  node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
73
76
  ```
74
77
 
78
+ Inspect individual meetings:
79
+
80
+ ```bash
81
+ granola meeting list --limit 10
82
+ granola meeting list --search planning
83
+ granola meeting view 1234abcd
84
+ granola meeting export 1234abcd --format yaml
85
+ ```
86
+
75
87
  ## How It Works
76
88
 
77
89
  ### Notes
@@ -121,6 +133,30 @@ Speaker labels are currently normalised to:
121
133
 
122
134
  Structured output formats are useful when you want to post-process exports in scripts instead of reading the default human-oriented Markdown or text files.
123
135
 
136
+ ### Meetings
137
+
138
+ `meeting` combines the API-backed notes path with the local transcript cache so you can inspect one meeting at a time.
139
+
140
+ The flow is:
141
+
142
+ 1. read a stored Granola session, or fall back to `supabase.json`
143
+ 2. fetch documents from Granola's API
144
+ 3. optionally load the local cache for transcript data
145
+ 4. resolve a meeting by full id or unique id prefix
146
+ 5. render either a list, a human-readable meeting view, or a machine-readable export bundle
147
+
148
+ The human-readable `view` command shows:
149
+
150
+ - meeting metadata
151
+ - the selected notes content
152
+ - transcript lines when the local cache is available
153
+
154
+ The machine-readable `export` command includes:
155
+
156
+ - a meeting summary
157
+ - structured note data plus rendered Markdown
158
+ - structured transcript data plus rendered transcript text when available
159
+
124
160
  ## Auth
125
161
 
126
162
  If you do not want to keep passing `--supabase`, import the desktop app session once:
package/dist/cli.js CHANGED
@@ -445,6 +445,60 @@ async function logout() {
445
445
  return 0;
446
446
  }
447
447
  //#endregion
448
+ //#region src/cache.ts
449
+ function parseCacheDocument(id, value) {
450
+ const record = asRecord(value);
451
+ if (!record) return;
452
+ return {
453
+ createdAt: stringValue(record.created_at),
454
+ id,
455
+ title: stringValue(record.title),
456
+ updatedAt: stringValue(record.updated_at)
457
+ };
458
+ }
459
+ function parseTranscriptSegments(value) {
460
+ if (!Array.isArray(value)) return;
461
+ return value.flatMap((segment) => {
462
+ const record = asRecord(segment);
463
+ if (!record) return [];
464
+ return [{
465
+ documentId: stringValue(record.document_id),
466
+ endTimestamp: stringValue(record.end_timestamp),
467
+ id: stringValue(record.id),
468
+ isFinal: Boolean(record.is_final),
469
+ source: stringValue(record.source),
470
+ startTimestamp: stringValue(record.start_timestamp),
471
+ text: stringValue(record.text)
472
+ }];
473
+ });
474
+ }
475
+ function parseCacheContents(contents) {
476
+ const outer = parseJsonString(contents);
477
+ if (!outer) throw new Error("failed to parse cache JSON");
478
+ const rawCache = outer.cache;
479
+ let cachePayload;
480
+ if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
481
+ else cachePayload = asRecord(rawCache);
482
+ const state = cachePayload ? asRecord(cachePayload.state) : void 0;
483
+ if (!state) throw new Error("failed to parse cache state");
484
+ const rawDocuments = asRecord(state.documents) ?? {};
485
+ const rawTranscripts = asRecord(state.transcripts) ?? {};
486
+ const documents = {};
487
+ for (const [id, rawDocument] of Object.entries(rawDocuments)) {
488
+ const document = parseCacheDocument(id, rawDocument);
489
+ if (document) documents[id] = document;
490
+ }
491
+ const transcripts = {};
492
+ for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
493
+ const segments = parseTranscriptSegments(rawTranscript);
494
+ if (segments) transcripts[id] = segments;
495
+ }
496
+ return {
497
+ documents,
498
+ transcripts
499
+ };
500
+ }
501
+ //#endregion
448
502
  //#region src/client/parsers.ts
449
503
  function parseProseMirrorDoc(value, options = {}) {
450
504
  if (value == null) return;
@@ -583,6 +637,22 @@ var AuthenticatedHttpClient = class {
583
637
  }
584
638
  };
585
639
  //#endregion
640
+ //#region src/client/default.ts
641
+ async function createDefaultGranolaApiClient(config, logger = console) {
642
+ const sessionStore = createDefaultSessionStore();
643
+ const storedSession = await sessionStore.readSession();
644
+ if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
645
+ if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
646
+ return new GranolaApiClient(new AuthenticatedHttpClient({
647
+ logger,
648
+ tokenProvider: storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
649
+ }));
650
+ }
651
+ async function loadOptionalGranolaCache(cacheFile) {
652
+ if (!cacheFile || !existsSync(cacheFile)) return;
653
+ return parseCacheContents(await readFile(cacheFile, "utf8"));
654
+ }
655
+ //#endregion
586
656
  //#region src/config.ts
587
657
  function pickString(value) {
588
658
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
@@ -1017,131 +1087,6 @@ async function writeNotes(documents, outputDir, format = "markdown") {
1017
1087
  });
1018
1088
  }
1019
1089
  //#endregion
1020
- //#region src/commands/shared.ts
1021
- function debug(enabled, ...values) {
1022
- if (enabled) console.error("[debug]", ...values);
1023
- }
1024
- //#endregion
1025
- //#region src/commands/notes.ts
1026
- function notesHelp() {
1027
- return `Granola notes
1028
-
1029
- Usage:
1030
- granola notes [options]
1031
-
1032
- Options:
1033
- --format <value> Output format: markdown, json, yaml, raw (default: markdown)
1034
- --output <path> Output directory for note files (default: ./notes)
1035
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
1036
- --supabase <path> Path to supabase.json
1037
- --debug Enable debug logging
1038
- --config <path> Path to .granola.toml
1039
- -h, --help Show help
1040
- `;
1041
- }
1042
- const notesCommand = {
1043
- description: "Export Granola notes",
1044
- flags: {
1045
- format: { type: "string" },
1046
- help: { type: "boolean" },
1047
- output: { type: "string" },
1048
- timeout: { type: "string" }
1049
- },
1050
- help: notesHelp,
1051
- name: "notes",
1052
- async run({ commandFlags, globalFlags }) {
1053
- const config = await loadConfig({
1054
- globalFlags,
1055
- subcommandFlags: commandFlags
1056
- });
1057
- const sessionStore = createDefaultSessionStore();
1058
- const storedSession = await sessionStore.readSession();
1059
- if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
1060
- if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
1061
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
1062
- debug(config.debug, "supabase", config.supabase);
1063
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
1064
- debug(config.debug, "output", config.notes.output);
1065
- const format = resolveNoteFormat(commandFlags.format);
1066
- debug(config.debug, "format", format);
1067
- console.log("Fetching documents from Granola API...");
1068
- const tokenProvider = storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
1069
- const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
1070
- logger: console,
1071
- tokenProvider
1072
- })).listDocuments({ timeoutMs: config.notes.timeoutMs });
1073
- console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
1074
- const written = await writeNotes(documents, config.notes.output, format);
1075
- console.log("✓ Export completed successfully");
1076
- debug(config.debug, "notes written", written);
1077
- return 0;
1078
- }
1079
- };
1080
- function resolveNoteFormat(value) {
1081
- switch (value) {
1082
- case void 0: return "markdown";
1083
- case "json":
1084
- case "markdown":
1085
- case "raw":
1086
- case "yaml": return value;
1087
- default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
1088
- }
1089
- }
1090
- //#endregion
1091
- //#region src/cache.ts
1092
- function parseCacheDocument(id, value) {
1093
- const record = asRecord(value);
1094
- if (!record) return;
1095
- return {
1096
- createdAt: stringValue(record.created_at),
1097
- id,
1098
- title: stringValue(record.title),
1099
- updatedAt: stringValue(record.updated_at)
1100
- };
1101
- }
1102
- function parseTranscriptSegments(value) {
1103
- if (!Array.isArray(value)) return;
1104
- return value.flatMap((segment) => {
1105
- const record = asRecord(segment);
1106
- if (!record) return [];
1107
- return [{
1108
- documentId: stringValue(record.document_id),
1109
- endTimestamp: stringValue(record.end_timestamp),
1110
- id: stringValue(record.id),
1111
- isFinal: Boolean(record.is_final),
1112
- source: stringValue(record.source),
1113
- startTimestamp: stringValue(record.start_timestamp),
1114
- text: stringValue(record.text)
1115
- }];
1116
- });
1117
- }
1118
- function parseCacheContents(contents) {
1119
- const outer = parseJsonString(contents);
1120
- if (!outer) throw new Error("failed to parse cache JSON");
1121
- const rawCache = outer.cache;
1122
- let cachePayload;
1123
- if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
1124
- else cachePayload = asRecord(rawCache);
1125
- const state = cachePayload ? asRecord(cachePayload.state) : void 0;
1126
- if (!state) throw new Error("failed to parse cache state");
1127
- const rawDocuments = asRecord(state.documents) ?? {};
1128
- const rawTranscripts = asRecord(state.transcripts) ?? {};
1129
- const documents = {};
1130
- for (const [id, rawDocument] of Object.entries(rawDocuments)) {
1131
- const document = parseCacheDocument(id, rawDocument);
1132
- if (document) documents[id] = document;
1133
- }
1134
- const transcripts = {};
1135
- for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
1136
- const segments = parseTranscriptSegments(rawTranscript);
1137
- if (segments) transcripts[id] = segments;
1138
- }
1139
- return {
1140
- documents,
1141
- transcripts
1142
- };
1143
- }
1144
- //#endregion
1145
1090
  //#region src/transcripts.ts
1146
1091
  function transcriptSegmentKey(segment) {
1147
1092
  if (segment.id) return `id:${segment.id}`;
@@ -1174,7 +1119,9 @@ function normaliseTranscriptSegments(segments) {
1174
1119
  const current = selected.get(key);
1175
1120
  selected.set(key, preferredTranscriptSegment(current, segment));
1176
1121
  }
1177
- return [...selected.values()].sort(compareTranscriptSegments);
1122
+ const resolved = [...selected.values()].sort(compareTranscriptSegments);
1123
+ if (resolved.some((segment) => segment.isFinal)) return resolved.filter((segment) => segment.isFinal);
1124
+ return resolved;
1178
1125
  }
1179
1126
  function buildTranscriptExport(document, segments, rawSegments = segments) {
1180
1127
  const renderedSegments = segments.map((segment) => ({
@@ -1275,6 +1222,398 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
1275
1222
  });
1276
1223
  }
1277
1224
  //#endregion
1225
+ //#region src/meetings.ts
1226
+ function parseTimestamp(value) {
1227
+ if (!value.trim()) return;
1228
+ const timestamp = Date.parse(value);
1229
+ return Number.isNaN(timestamp) ? void 0 : timestamp;
1230
+ }
1231
+ function compareTimestampsDescending(left, right) {
1232
+ const leftTimestamp = parseTimestamp(left);
1233
+ const rightTimestamp = parseTimestamp(right);
1234
+ if (leftTimestamp != null && rightTimestamp != null) return rightTimestamp - leftTimestamp;
1235
+ if (leftTimestamp != null) return -1;
1236
+ if (rightTimestamp != null) return 1;
1237
+ return compareStrings(right, left);
1238
+ }
1239
+ function compareMeetingDocuments(left, right) {
1240
+ return compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1241
+ }
1242
+ function serialiseNote(note) {
1243
+ return {
1244
+ content: note.content,
1245
+ contentSource: note.contentSource,
1246
+ createdAt: note.createdAt,
1247
+ id: note.id,
1248
+ tags: [...note.tags],
1249
+ title: note.title,
1250
+ updatedAt: note.updatedAt
1251
+ };
1252
+ }
1253
+ function serialiseTranscript(transcript) {
1254
+ return {
1255
+ createdAt: transcript.createdAt,
1256
+ id: transcript.id,
1257
+ segments: transcript.segments.map((segment) => ({ ...segment })),
1258
+ title: transcript.title,
1259
+ updatedAt: transcript.updatedAt
1260
+ };
1261
+ }
1262
+ function cacheDocumentForMeeting(document, cacheData) {
1263
+ return cacheData?.documents[document.id] ?? {
1264
+ createdAt: document.createdAt,
1265
+ id: document.id,
1266
+ title: document.title,
1267
+ updatedAt: latestDocumentTimestamp(document)
1268
+ };
1269
+ }
1270
+ function buildMeetingTranscript(document, cacheData) {
1271
+ if (!cacheData) return {
1272
+ loaded: false,
1273
+ segmentCount: 0,
1274
+ transcript: null,
1275
+ transcriptText: null
1276
+ };
1277
+ const rawSegments = cacheData.transcripts[document.id] ?? [];
1278
+ const normalisedSegments = normaliseTranscriptSegments(rawSegments);
1279
+ if (normalisedSegments.length === 0) return {
1280
+ loaded: true,
1281
+ segmentCount: 0,
1282
+ transcript: null,
1283
+ transcriptText: null
1284
+ };
1285
+ const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
1286
+ return {
1287
+ loaded: true,
1288
+ segmentCount: transcript.segments.length,
1289
+ transcript: serialiseTranscript(transcript),
1290
+ transcriptText: renderTranscriptExport(transcript, "text")
1291
+ };
1292
+ }
1293
+ function matchesMeetingSearch(document, search) {
1294
+ const query = search.trim().toLowerCase();
1295
+ if (!query) return true;
1296
+ return [
1297
+ document.id,
1298
+ document.title,
1299
+ ...document.tags
1300
+ ].some((value) => value.toLowerCase().includes(query));
1301
+ }
1302
+ function truncate(value, width) {
1303
+ if (value.length <= width) return value.padEnd(width);
1304
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
1305
+ }
1306
+ function formatMeetingDate(value) {
1307
+ return value.trim().slice(0, 10) || "-";
1308
+ }
1309
+ function formatTranscriptStatus(meeting) {
1310
+ if (!meeting.transcriptLoaded) return "n/a";
1311
+ if (meeting.transcriptSegmentCount === 0) return "none";
1312
+ return String(meeting.transcriptSegmentCount);
1313
+ }
1314
+ function formatTranscriptLines(transcript) {
1315
+ if (!transcript || transcript.segments.length === 0) return "";
1316
+ return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
1317
+ }
1318
+ function buildMeetingSummary(document, cacheData) {
1319
+ const note = buildNoteExport(document);
1320
+ const transcript = buildMeetingTranscript(document, cacheData);
1321
+ return {
1322
+ createdAt: document.createdAt,
1323
+ id: document.id,
1324
+ noteContentSource: note.contentSource,
1325
+ tags: [...document.tags],
1326
+ title: document.title,
1327
+ transcriptLoaded: transcript.loaded,
1328
+ transcriptSegmentCount: transcript.segmentCount,
1329
+ updatedAt: latestDocumentTimestamp(document)
1330
+ };
1331
+ }
1332
+ function buildMeetingRecord(document, cacheData) {
1333
+ const note = buildNoteExport(document);
1334
+ const transcript = buildMeetingTranscript(document, cacheData);
1335
+ return {
1336
+ meeting: {
1337
+ createdAt: document.createdAt,
1338
+ id: document.id,
1339
+ noteContentSource: note.contentSource,
1340
+ tags: [...document.tags],
1341
+ title: document.title,
1342
+ transcriptLoaded: transcript.loaded,
1343
+ transcriptSegmentCount: transcript.segmentCount,
1344
+ updatedAt: latestDocumentTimestamp(document)
1345
+ },
1346
+ note: serialiseNote(note),
1347
+ noteMarkdown: renderNoteExport(note, "markdown"),
1348
+ transcript: transcript.transcript,
1349
+ transcriptText: transcript.transcriptText
1350
+ };
1351
+ }
1352
+ function listMeetings(documents, options = {}) {
1353
+ const limit = options.limit ?? 20;
1354
+ return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).sort(compareMeetingDocuments).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1355
+ }
1356
+ function resolveMeeting(documents, id) {
1357
+ const exactMatch = documents.find((document) => document.id === id);
1358
+ if (exactMatch) return exactMatch;
1359
+ const matches = documents.filter((document) => document.id.startsWith(id));
1360
+ if (matches.length === 1) return matches[0];
1361
+ if (matches.length > 1) {
1362
+ const sample = matches.slice(0, 5).map((document) => document.id.slice(0, 8)).join(", ");
1363
+ throw new Error(`ambiguous meeting id: ${id} matches ${matches.length} meetings (${sample})`);
1364
+ }
1365
+ throw new Error(`meeting not found: ${id}`);
1366
+ }
1367
+ function renderMeetingList(meetings, format = "text") {
1368
+ switch (format) {
1369
+ case "json": return toJson(meetings);
1370
+ case "yaml": return toYaml(meetings);
1371
+ case "text": break;
1372
+ }
1373
+ if (meetings.length === 0) return "No meetings found\n";
1374
+ 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)}`];
1375
+ for (const meeting of meetings) lines.push([
1376
+ meeting.id.slice(0, 8).padEnd(10),
1377
+ formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1378
+ truncate(meeting.title || meeting.id, 42),
1379
+ truncate(meeting.noteContentSource, 18),
1380
+ formatTranscriptStatus(meeting)
1381
+ ].join(" "));
1382
+ return `${lines.join("\n").trimEnd()}\n`;
1383
+ }
1384
+ function renderMeetingView(record, format = "text") {
1385
+ switch (format) {
1386
+ case "json": return toJson(record);
1387
+ case "yaml": return toYaml(record);
1388
+ case "text": break;
1389
+ }
1390
+ const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
1391
+ const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1392
+ return `${[
1393
+ `# ${record.meeting.title || record.meeting.id}`,
1394
+ "",
1395
+ `ID: ${record.meeting.id}`,
1396
+ `Created: ${record.meeting.createdAt || "-"}`,
1397
+ `Updated: ${record.meeting.updatedAt || "-"}`,
1398
+ `Tags: ${tags}`,
1399
+ `Note source: ${record.meeting.noteContentSource}`,
1400
+ `Transcript: ${transcriptStatus}`,
1401
+ "",
1402
+ "## Notes",
1403
+ "",
1404
+ record.note.content.trim() || "(no notes)",
1405
+ "",
1406
+ "## Transcript",
1407
+ "",
1408
+ formatTranscriptLines(record.transcript) || (record.meeting.transcriptLoaded ? "(no transcript segments)" : "(Granola cache not loaded)"),
1409
+ ""
1410
+ ].join("\n").trimEnd()}\n`;
1411
+ }
1412
+ function renderMeetingExport(record, format = "json") {
1413
+ switch (format) {
1414
+ case "json": return toJson(record);
1415
+ case "yaml": return toYaml(record);
1416
+ }
1417
+ }
1418
+ //#endregion
1419
+ //#region src/commands/shared.ts
1420
+ function debug(enabled, ...values) {
1421
+ if (enabled) console.error("[debug]", ...values);
1422
+ }
1423
+ //#endregion
1424
+ //#region src/commands/meeting.ts
1425
+ function meetingHelp() {
1426
+ return `Granola meeting
1427
+
1428
+ Usage:
1429
+ granola meeting <list|view|export> [options]
1430
+
1431
+ Subcommands:
1432
+ list List meetings from the Granola API
1433
+ view <id> Show a single meeting with notes and transcript text
1434
+ export <id> Export a single meeting as JSON or YAML
1435
+
1436
+ Options:
1437
+ --cache <path> Path to Granola cache JSON for transcript data
1438
+ --format <value> list/view: text, json, yaml; export: json, yaml
1439
+ --limit <n> Number of meetings for list (default: 20)
1440
+ --search <query> Filter list by title, id, or tag
1441
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
1442
+ --supabase <path> Path to supabase.json
1443
+ --debug Enable debug logging
1444
+ --config <path> Path to .granola.toml
1445
+ -h, --help Show help
1446
+ `;
1447
+ }
1448
+ function resolveListFormat(value) {
1449
+ switch (value) {
1450
+ case void 0: return "text";
1451
+ case "json":
1452
+ case "text":
1453
+ case "yaml": return value;
1454
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
1455
+ }
1456
+ }
1457
+ function resolveViewFormat(value) {
1458
+ switch (value) {
1459
+ case void 0: return "text";
1460
+ case "json":
1461
+ case "text":
1462
+ case "yaml": return value;
1463
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
1464
+ }
1465
+ }
1466
+ function resolveExportFormat(value) {
1467
+ switch (value) {
1468
+ case void 0: return "json";
1469
+ case "json":
1470
+ case "yaml": return value;
1471
+ default: throw new Error("invalid meeting export format: expected json or yaml");
1472
+ }
1473
+ }
1474
+ function parseLimit(value) {
1475
+ if (value === void 0) return 20;
1476
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
1477
+ const limit = Number(value);
1478
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
1479
+ return limit;
1480
+ }
1481
+ const meetingCommand = {
1482
+ description: "Inspect and export individual Granola meetings",
1483
+ flags: {
1484
+ cache: { type: "string" },
1485
+ format: { type: "string" },
1486
+ help: { type: "boolean" },
1487
+ limit: { type: "string" },
1488
+ search: { type: "string" },
1489
+ timeout: { type: "string" }
1490
+ },
1491
+ help: meetingHelp,
1492
+ name: "meeting",
1493
+ async run({ commandArgs, commandFlags, globalFlags }) {
1494
+ const [action, id] = commandArgs;
1495
+ switch (action) {
1496
+ case "list": return await list(commandFlags, globalFlags);
1497
+ case "view":
1498
+ if (!id) throw new Error("meeting view requires an id");
1499
+ return await view(id, commandFlags, globalFlags);
1500
+ case "export":
1501
+ if (!id) throw new Error("meeting export requires an id");
1502
+ return await exportMeeting(id, commandFlags, globalFlags);
1503
+ case void 0:
1504
+ console.log(meetingHelp());
1505
+ return 1;
1506
+ default: throw new Error("invalid meeting command: expected list, view, or export");
1507
+ }
1508
+ }
1509
+ };
1510
+ async function loadMeetingData(commandFlags, globalFlags) {
1511
+ const config = await loadConfig({
1512
+ globalFlags,
1513
+ subcommandFlags: commandFlags
1514
+ });
1515
+ if (config.transcripts.cacheFile && !existsSync(config.transcripts.cacheFile)) throw new Error(`Granola cache file not found: ${config.transcripts.cacheFile}`);
1516
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
1517
+ debug(config.debug, "supabase", config.supabase);
1518
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
1519
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
1520
+ const granolaClient = await createDefaultGranolaApiClient(config);
1521
+ return {
1522
+ cacheData: await loadOptionalGranolaCache(config.transcripts.cacheFile),
1523
+ config,
1524
+ granolaClient
1525
+ };
1526
+ }
1527
+ async function list(commandFlags, globalFlags) {
1528
+ const format = resolveListFormat(commandFlags.format);
1529
+ const limit = parseLimit(commandFlags.limit);
1530
+ const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
1531
+ const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
1532
+ console.log("Fetching meetings from Granola API...");
1533
+ const meetings = listMeetings(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), {
1534
+ cacheData,
1535
+ limit,
1536
+ search
1537
+ });
1538
+ console.log(renderMeetingList(meetings, format).trimEnd());
1539
+ return 0;
1540
+ }
1541
+ async function view(id, commandFlags, globalFlags) {
1542
+ const format = resolveViewFormat(commandFlags.format);
1543
+ const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
1544
+ console.log("Fetching meeting from Granola API...");
1545
+ const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
1546
+ console.log(renderMeetingView(meeting, format).trimEnd());
1547
+ return 0;
1548
+ }
1549
+ async function exportMeeting(id, commandFlags, globalFlags) {
1550
+ const format = resolveExportFormat(commandFlags.format);
1551
+ const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
1552
+ console.log("Fetching meeting from Granola API...");
1553
+ const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
1554
+ console.log(renderMeetingExport(meeting, format).trimEnd());
1555
+ return 0;
1556
+ }
1557
+ //#endregion
1558
+ //#region src/commands/notes.ts
1559
+ function notesHelp() {
1560
+ return `Granola notes
1561
+
1562
+ Usage:
1563
+ granola notes [options]
1564
+
1565
+ Options:
1566
+ --format <value> Output format: markdown, json, yaml, raw (default: markdown)
1567
+ --output <path> Output directory for note files (default: ./notes)
1568
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
1569
+ --supabase <path> Path to supabase.json
1570
+ --debug Enable debug logging
1571
+ --config <path> Path to .granola.toml
1572
+ -h, --help Show help
1573
+ `;
1574
+ }
1575
+ const notesCommand = {
1576
+ description: "Export Granola notes",
1577
+ flags: {
1578
+ format: { type: "string" },
1579
+ help: { type: "boolean" },
1580
+ output: { type: "string" },
1581
+ timeout: { type: "string" }
1582
+ },
1583
+ help: notesHelp,
1584
+ name: "notes",
1585
+ async run({ commandFlags, globalFlags }) {
1586
+ const config = await loadConfig({
1587
+ globalFlags,
1588
+ subcommandFlags: commandFlags
1589
+ });
1590
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
1591
+ debug(config.debug, "supabase", config.supabase);
1592
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
1593
+ debug(config.debug, "output", config.notes.output);
1594
+ const format = resolveNoteFormat(commandFlags.format);
1595
+ debug(config.debug, "format", format);
1596
+ const granolaClient = await createDefaultGranolaApiClient(config);
1597
+ console.log("Fetching documents from Granola API...");
1598
+ const documents = await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs });
1599
+ console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
1600
+ const written = await writeNotes(documents, config.notes.output, format);
1601
+ console.log("✓ Export completed successfully");
1602
+ debug(config.debug, "notes written", written);
1603
+ return 0;
1604
+ }
1605
+ };
1606
+ function resolveNoteFormat(value) {
1607
+ switch (value) {
1608
+ case void 0: return "markdown";
1609
+ case "json":
1610
+ case "markdown":
1611
+ case "raw":
1612
+ case "yaml": return value;
1613
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
1614
+ }
1615
+ }
1616
+ //#endregion
1278
1617
  //#region src/commands/transcripts.ts
1279
1618
  function transcriptsHelp() {
1280
1619
  return `Granola transcripts
@@ -1337,6 +1676,7 @@ function resolveTranscriptFormat(value) {
1337
1676
  //#region src/commands/index.ts
1338
1677
  const commands = [
1339
1678
  authCommand,
1679
+ meetingCommand,
1340
1680
  notesCommand,
1341
1681
  transcriptsCommand
1342
1682
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",