granola-toolkit 0.37.0 → 0.38.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 +4 -1
  2. package/dist/cli.js +407 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,7 +20,7 @@ The published package exposes both `granola` and `granola-toolkit` as executable
20
20
  ## Quick Start
21
21
 
22
22
  ```bash
23
- granola auth login
23
+ granola auth login --api-key grn_...
24
24
  granola sync
25
25
  granola sync --watch
26
26
  granola folder list
@@ -30,6 +30,9 @@ granola web
30
30
  granola tui
31
31
  ```
32
32
 
33
+ If you prefer to reuse the desktop app session instead, `granola auth login` still imports it from
34
+ `supabase.json`.
35
+
33
36
  ## Documentation
34
37
 
35
38
  The detailed documentation now lives at
package/dist/cli.js CHANGED
@@ -1088,14 +1088,14 @@ function cacheDocumentForMeeting(document, cacheData) {
1088
1088
  };
1089
1089
  }
1090
1090
  function buildMeetingTranscript(document, cacheData) {
1091
- if (!cacheData) return {
1091
+ const rawSegments = cacheData?.transcripts[document.id] ?? document.transcriptSegments ?? [];
1092
+ if (!(Boolean(cacheData) || Array.isArray(document.transcriptSegments))) return {
1092
1093
  loaded: false,
1093
1094
  segmentCount: 0,
1094
1095
  transcript: null,
1095
1096
  transcriptRecord: null,
1096
1097
  transcriptText: null
1097
1098
  };
1098
- const rawSegments = cacheData.transcripts[document.id] ?? [];
1099
1099
  const normalisedSegments = normaliseTranscriptSegments(rawSegments);
1100
1100
  if (normalisedSegments.length === 0) return {
1101
1101
  loaded: true,
@@ -1383,7 +1383,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1383
1383
  }
1384
1384
  }
1385
1385
  function buildGranolaTuiSummary(state, meetingSource) {
1386
- return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | ${state.sync.running ? "sync running" : state.sync.lastError ? "sync error" : state.sync.lastCompletedAt ? `sync ${state.sync.lastCompletedAt.slice(11, 16)}` : "sync idle"} | list ${meetingSource}`;
1386
+ return `auth ${state.auth.mode === "api-key" ? "key" : state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | ${state.sync.running ? "sync running" : state.sync.lastError ? "sync error" : state.sync.lastCompletedAt ? `sync ${state.sync.lastCompletedAt.slice(11, 16)}` : "sync idle"} | list ${meetingSource}`;
1387
1387
  }
1388
1388
  //#endregion
1389
1389
  //#region src/tui/theme.ts
@@ -1429,13 +1429,16 @@ function actionDisabledReason(auth, actionId) {
1429
1429
  case "refresh":
1430
1430
  if (!auth.storedSessionAvailable) return "stored session missing";
1431
1431
  return auth.refreshAvailable ? "" : "refresh unavailable";
1432
+ case "use-api-key":
1433
+ if (!auth.apiKeyAvailable) return "API key missing";
1434
+ return auth.mode === "api-key" ? "already active" : "";
1432
1435
  case "use-stored":
1433
1436
  if (!auth.storedSessionAvailable) return "stored session missing";
1434
1437
  return auth.mode === "stored-session" ? "already active" : "";
1435
1438
  case "use-supabase":
1436
1439
  if (!auth.supabaseAvailable) return "supabase.json unavailable";
1437
1440
  return auth.mode === "supabase-file" ? "already active" : "";
1438
- case "logout": return auth.storedSessionAvailable ? "" : "stored session missing";
1441
+ case "logout": return auth.apiKeyAvailable || auth.storedSessionAvailable ? "" : "no stored credentials";
1439
1442
  }
1440
1443
  }
1441
1444
  function buildGranolaTuiAuthActions(auth) {
@@ -1452,22 +1455,28 @@ function buildGranolaTuiAuthActions(auth) {
1452
1455
  key: "2",
1453
1456
  label: "Refresh stored session"
1454
1457
  },
1458
+ {
1459
+ description: "Switch the active auth source to the stored API key",
1460
+ id: "use-api-key",
1461
+ key: "3",
1462
+ label: "Use API key"
1463
+ },
1455
1464
  {
1456
1465
  description: "Switch the active auth source to the stored session",
1457
1466
  id: "use-stored",
1458
- key: "3",
1467
+ key: "4",
1459
1468
  label: "Use stored session"
1460
1469
  },
1461
1470
  {
1462
1471
  description: "Switch the active auth source to supabase.json",
1463
1472
  id: "use-supabase",
1464
- key: "4",
1473
+ key: "5",
1465
1474
  label: "Use supabase.json"
1466
1475
  },
1467
1476
  {
1468
- description: "Delete the stored session and fall back to supabase.json",
1477
+ description: "Delete stored credentials and fall back to configured sources",
1469
1478
  id: "logout",
1470
- key: "5",
1479
+ key: "6",
1471
1480
  label: "Sign out"
1472
1481
  }
1473
1482
  ].map((action) => {
@@ -1481,7 +1490,8 @@ function buildGranolaTuiAuthActions(auth) {
1481
1490
  }
1482
1491
  function renderGranolaTuiAuthState(auth) {
1483
1492
  const lines = [
1484
- `Active source: ${auth.mode === "stored-session" ? "Stored session" : "supabase.json"}`,
1493
+ `Active source: ${auth.mode === "api-key" ? "API key" : auth.mode === "stored-session" ? "Stored session" : "supabase.json"}`,
1494
+ `API key: ${auth.apiKeyAvailable ? "available" : "missing"}`,
1485
1495
  `Stored session: ${auth.storedSessionAvailable ? "available" : "missing"}`,
1486
1496
  `supabase.json: ${auth.supabaseAvailable ? "available" : "missing"}`,
1487
1497
  `Refresh: ${auth.refreshAvailable ? "available" : "missing"}`
@@ -1977,6 +1987,11 @@ var GranolaTuiWorkspace = class {
1977
1987
  await this.app.refreshAuth();
1978
1988
  successMessage = "Stored session refreshed";
1979
1989
  break;
1990
+ case "use-api-key":
1991
+ this.setStatus("Switching to stored API key…");
1992
+ await this.app.switchAuthMode("api-key");
1993
+ successMessage = "Using stored API key";
1994
+ break;
1980
1995
  case "use-stored":
1981
1996
  this.setStatus("Switching to stored session…");
1982
1997
  await this.app.switchAuthMode("stored-session");
@@ -1990,7 +2005,7 @@ var GranolaTuiWorkspace = class {
1990
2005
  case "logout":
1991
2006
  this.setStatus("Signing out…");
1992
2007
  await this.app.logoutAuth();
1993
- successMessage = "Stored session removed";
2008
+ successMessage = "Stored credentials removed";
1994
2009
  break;
1995
2010
  }
1996
2011
  await this.reloadAfterAuthChange();
@@ -2388,6 +2403,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2388
2403
  const targetPlatform = options.platform ?? platform();
2389
2404
  const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2390
2405
  return {
2406
+ apiKeyFile: join(dataDirectory, "api-key.txt"),
2391
2407
  dataDirectory,
2392
2408
  exportJobsFile: join(dataDirectory, "export-jobs.json"),
2393
2409
  meetingIndexFile: join(dataDirectory, "meeting-index.json"),
@@ -2402,6 +2418,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2402
2418
  const execFileAsync$1 = promisify(execFile);
2403
2419
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
2404
2420
  const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
2421
+ const KEYCHAIN_ACCOUNT_NAME_API_KEY = "api-key";
2405
2422
  const KEYCHAIN_ACCOUNT_NAME = "session";
2406
2423
  const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
2407
2424
  function numberValue(value) {
@@ -2486,6 +2503,32 @@ var FileSessionStore = class {
2486
2503
  });
2487
2504
  }
2488
2505
  };
2506
+ var FileApiKeyStore = class {
2507
+ constructor(filePath = defaultApiKeyFilePath()) {
2508
+ this.filePath = filePath;
2509
+ }
2510
+ async clearApiKey() {
2511
+ try {
2512
+ await unlink(this.filePath);
2513
+ } catch {}
2514
+ }
2515
+ async readApiKey() {
2516
+ try {
2517
+ return (await readFile(this.filePath, "utf8")).trim() || void 0;
2518
+ } catch {
2519
+ return;
2520
+ }
2521
+ }
2522
+ async writeApiKey(apiKey) {
2523
+ const trimmed = apiKey.trim();
2524
+ if (!trimmed) throw new Error("Granola API key is required");
2525
+ await mkdir(dirname(this.filePath), { recursive: true });
2526
+ await writeFile(this.filePath, `${trimmed}\n`, {
2527
+ encoding: "utf8",
2528
+ mode: 384
2529
+ });
2530
+ }
2531
+ };
2489
2532
  var KeychainSessionStore = class {
2490
2533
  async clearSession() {
2491
2534
  try {
@@ -2527,6 +2570,48 @@ var KeychainSessionStore = class {
2527
2570
  ]);
2528
2571
  }
2529
2572
  };
2573
+ var KeychainApiKeyStore = class {
2574
+ async clearApiKey() {
2575
+ try {
2576
+ await execFileAsync$1("security", [
2577
+ "delete-generic-password",
2578
+ "-s",
2579
+ KEYCHAIN_SERVICE_NAME,
2580
+ "-a",
2581
+ KEYCHAIN_ACCOUNT_NAME_API_KEY
2582
+ ]);
2583
+ } catch {}
2584
+ }
2585
+ async readApiKey() {
2586
+ try {
2587
+ const { stdout } = await execFileAsync$1("security", [
2588
+ "find-generic-password",
2589
+ "-s",
2590
+ KEYCHAIN_SERVICE_NAME,
2591
+ "-a",
2592
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2593
+ "-w"
2594
+ ]);
2595
+ return stdout.trim() || void 0;
2596
+ } catch {
2597
+ return;
2598
+ }
2599
+ }
2600
+ async writeApiKey(apiKey) {
2601
+ const trimmed = apiKey.trim();
2602
+ if (!trimmed) throw new Error("Granola API key is required");
2603
+ await execFileAsync$1("security", [
2604
+ "add-generic-password",
2605
+ "-U",
2606
+ "-s",
2607
+ KEYCHAIN_SERVICE_NAME,
2608
+ "-a",
2609
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2610
+ "-w",
2611
+ trimmed
2612
+ ]);
2613
+ }
2614
+ };
2530
2615
  var CachedTokenProvider = class {
2531
2616
  #token;
2532
2617
  constructor(source, store = new NoopTokenStore()) {
@@ -2620,33 +2705,45 @@ async function refreshGranolaSession(session, fetchImpl = fetch) {
2620
2705
  function defaultSessionFilePath() {
2621
2706
  return defaultGranolaToolkitPersistenceLayout().sessionFile;
2622
2707
  }
2708
+ function defaultApiKeyFilePath() {
2709
+ return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
2710
+ }
2623
2711
  function createDefaultSessionStore() {
2624
2712
  return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
2625
2713
  }
2714
+ function createDefaultApiKeyStore() {
2715
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
2716
+ }
2626
2717
  //#endregion
2627
2718
  //#region src/client/default-auth.ts
2628
2719
  function hasStoredSession(session) {
2629
2720
  return Boolean(session?.accessToken.trim());
2630
2721
  }
2631
2722
  function resolveActiveMode(options) {
2723
+ if (options.preferredMode === "api-key" && options.apiKeyAvailable) return "api-key";
2632
2724
  if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
2633
2725
  if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
2726
+ if (options.apiKeyAvailable) return "api-key";
2634
2727
  if (options.storedSessionAvailable) return "stored-session";
2635
- return "supabase-file";
2728
+ if (options.supabaseAvailable) return "supabase-file";
2729
+ return options.preferredMode ?? "api-key";
2636
2730
  }
2637
2731
  function missingSupabaseError() {
2638
2732
  return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
2639
2733
  }
2640
2734
  function buildDefaultGranolaAuthInfo(config, options = {}) {
2641
2735
  const existsSyncImpl = options.existsSyncImpl ?? existsSync;
2736
+ const apiKeyAvailable = Boolean(options.apiKey?.trim());
2642
2737
  const session = options.session;
2643
2738
  const storedSessionAvailable = hasStoredSession(session);
2644
2739
  const supabasePath = config.supabase || void 0;
2645
2740
  const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
2646
2741
  return {
2742
+ apiKeyAvailable,
2647
2743
  clientId: session?.clientId,
2648
2744
  lastError: options.lastError,
2649
2745
  mode: resolveActiveMode({
2746
+ apiKeyAvailable,
2650
2747
  preferredMode: options.preferredMode,
2651
2748
  storedSessionAvailable,
2652
2749
  supabaseAvailable
@@ -2659,9 +2756,12 @@ function buildDefaultGranolaAuthInfo(config, options = {}) {
2659
2756
  };
2660
2757
  }
2661
2758
  async function inspectDefaultGranolaAuth(config, options = {}) {
2759
+ const apiKeyStore = options.apiKeyStore ?? createDefaultApiKeyStore();
2662
2760
  const sessionStore = options.sessionStore ?? createDefaultSessionStore();
2761
+ const apiKey = options.apiKey ?? config.apiKey ?? await apiKeyStore.readApiKey();
2663
2762
  const session = options.session ?? await sessionStore.readSession();
2664
2763
  return buildDefaultGranolaAuthInfo(config, {
2764
+ apiKey,
2665
2765
  existsSyncImpl: options.existsSyncImpl,
2666
2766
  lastError: options.lastError,
2667
2767
  preferredMode: options.preferredMode,
@@ -2678,6 +2778,12 @@ var DefaultAuthController = class {
2678
2778
  sessionStore() {
2679
2779
  return this.options.sessionStore ?? createDefaultSessionStore();
2680
2780
  }
2781
+ apiKeyStore() {
2782
+ return this.options.apiKeyStore ?? createDefaultApiKeyStore();
2783
+ }
2784
+ async readApiKey() {
2785
+ return this.config.apiKey?.trim() || await this.apiKeyStore().readApiKey();
2786
+ }
2681
2787
  readSession() {
2682
2788
  return this.sessionStore().readSession();
2683
2789
  }
@@ -2691,8 +2797,10 @@ var DefaultAuthController = class {
2691
2797
  return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
2692
2798
  }
2693
2799
  async inspect() {
2800
+ const apiKey = await this.readApiKey();
2694
2801
  const session = await this.readSession();
2695
2802
  return buildDefaultGranolaAuthInfo(this.config, {
2803
+ apiKey,
2696
2804
  existsSyncImpl: this.options.existsSyncImpl,
2697
2805
  lastError: this.#lastError,
2698
2806
  preferredMode: this.#preferredMode,
@@ -2700,6 +2808,13 @@ var DefaultAuthController = class {
2700
2808
  });
2701
2809
  }
2702
2810
  async login(options = {}) {
2811
+ const apiKey = options.apiKey?.trim();
2812
+ if (apiKey) {
2813
+ await this.apiKeyStore().writeApiKey(apiKey);
2814
+ this.#lastError = void 0;
2815
+ this.#preferredMode = "api-key";
2816
+ return await this.inspect();
2817
+ }
2703
2818
  const supabasePath = this.resolveSupabasePath(options.supabasePath);
2704
2819
  const session = await this.sessionSource(supabasePath).loadSession();
2705
2820
  await this.sessionStore().writeSession(session);
@@ -2708,6 +2823,7 @@ var DefaultAuthController = class {
2708
2823
  return await this.inspect();
2709
2824
  }
2710
2825
  async logout() {
2826
+ await this.apiKeyStore().clearApiKey();
2711
2827
  await this.sessionStore().clearSession();
2712
2828
  this.#lastError = void 0;
2713
2829
  this.#preferredMode = void 0;
@@ -2732,6 +2848,10 @@ var DefaultAuthController = class {
2732
2848
  }
2733
2849
  async switchMode(mode) {
2734
2850
  const state = await this.inspect();
2851
+ if (mode === "api-key" && !state.apiKeyAvailable) {
2852
+ this.#lastError = "no Granola API key found";
2853
+ throw new Error(this.#lastError);
2854
+ }
2735
2855
  if (mode === "stored-session" && !state.storedSessionAvailable) {
2736
2856
  this.#lastError = "no stored Granola session found";
2737
2857
  throw new Error(this.#lastError);
@@ -2796,6 +2916,63 @@ function parseDocument(value) {
2796
2916
  updatedAt: stringValue(record.updated_at)
2797
2917
  };
2798
2918
  }
2919
+ function parseFolderMembership(value) {
2920
+ const record = asRecord(value);
2921
+ if (!record) return;
2922
+ const id = stringValue(record.id);
2923
+ const name = stringValue(record.name);
2924
+ if (!id || !name) return;
2925
+ return {
2926
+ id,
2927
+ name
2928
+ };
2929
+ }
2930
+ function parsePublicTranscriptSegment(documentId, value, index) {
2931
+ const record = asRecord(value);
2932
+ if (!record) return;
2933
+ const text = stringValue(record.text);
2934
+ const startTimestamp = stringValue(record.start_time);
2935
+ const endTimestamp = stringValue(record.end_time) || startTimestamp;
2936
+ if (!text || !startTimestamp) return;
2937
+ const speaker = asRecord(record.speaker);
2938
+ return {
2939
+ documentId,
2940
+ endTimestamp,
2941
+ id: stringValue(record.id) || `${documentId}:transcript:${index + 1}`,
2942
+ isFinal: true,
2943
+ source: stringValue(speaker?.name) || stringValue(speaker?.source) || "unknown",
2944
+ startTimestamp,
2945
+ text
2946
+ };
2947
+ }
2948
+ function parsePublicNoteSummary(value) {
2949
+ const record = asRecord(value);
2950
+ if (!record) throw new Error("public note payload is not an object");
2951
+ return {
2952
+ createdAt: stringValue(record.created_at),
2953
+ id: stringValue(record.id),
2954
+ title: stringValue(record.title),
2955
+ updatedAt: stringValue(record.updated_at)
2956
+ };
2957
+ }
2958
+ function parsePublicNote(value) {
2959
+ const record = asRecord(value);
2960
+ if (!record) throw new Error("public note payload is not an object");
2961
+ const id = stringValue(record.id);
2962
+ const summaryMarkdown = stringValue(record.summary_markdown);
2963
+ const summaryText = stringValue(record.summary_text);
2964
+ return {
2965
+ content: summaryMarkdown || summaryText,
2966
+ createdAt: stringValue(record.created_at),
2967
+ folderMemberships: Array.isArray(record.folder_membership) ? record.folder_membership.map(parseFolderMembership).filter((membership) => Boolean(membership)) : [],
2968
+ id,
2969
+ notesPlain: summaryText,
2970
+ tags: [],
2971
+ title: stringValue(record.title),
2972
+ transcriptSegments: Array.isArray(record.transcript) ? record.transcript.map((segment, index) => parsePublicTranscriptSegment(id, segment, index)).filter((segment) => Boolean(segment)) : [],
2973
+ updatedAt: stringValue(record.updated_at)
2974
+ };
2975
+ }
2799
2976
  function parseFolderDocumentIds(value) {
2800
2977
  if (!Array.isArray(value)) return [];
2801
2978
  return value.map((item) => {
@@ -2899,6 +3076,98 @@ var GranolaApiClient = class {
2899
3076
  }
2900
3077
  };
2901
3078
  //#endregion
3079
+ //#region src/client/granola-public.ts
3080
+ const PUBLIC_NOTES_URL = "https://public-api.granola.ai/v1/notes";
3081
+ const MAX_PAGE_SIZE = 30;
3082
+ const DETAIL_BATCH_SIZE = 5;
3083
+ function cloneDocument(document) {
3084
+ return {
3085
+ ...document,
3086
+ folderMemberships: document.folderMemberships?.map((membership) => ({ ...membership })),
3087
+ tags: [...document.tags],
3088
+ transcriptSegments: document.transcriptSegments?.map((segment) => ({ ...segment }))
3089
+ };
3090
+ }
3091
+ async function mapInBatches(items, batchSize, mapper) {
3092
+ const results = [];
3093
+ for (let index = 0; index < items.length; index += batchSize) {
3094
+ const batch = items.slice(index, index + batchSize);
3095
+ results.push(...await Promise.all(batch.map((item) => mapper(item))));
3096
+ }
3097
+ return results;
3098
+ }
3099
+ var GranolaPublicApiClient = class {
3100
+ #documentsById = /* @__PURE__ */ new Map();
3101
+ constructor(httpClient) {
3102
+ this.httpClient = httpClient;
3103
+ }
3104
+ async listNoteSummaries(options) {
3105
+ const notes = [];
3106
+ const pageSize = Math.max(1, Math.min(options.limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE));
3107
+ let cursor;
3108
+ for (;;) {
3109
+ const url = new URL(PUBLIC_NOTES_URL);
3110
+ url.searchParams.set("page_size", String(pageSize));
3111
+ if (cursor) url.searchParams.set("cursor", cursor);
3112
+ const response = await this.httpClient.request({
3113
+ headers: { Accept: "application/json" },
3114
+ timeoutMs: options.timeoutMs,
3115
+ url: url.toString()
3116
+ });
3117
+ if (!response.ok) {
3118
+ const body = (await response.text()).slice(0, 500);
3119
+ throw new Error(`failed to list notes: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
3120
+ }
3121
+ const payload = await response.json();
3122
+ if (!Array.isArray(payload.notes)) throw new Error("failed to parse public notes response");
3123
+ notes.push(...payload.notes.map(parsePublicNoteSummary));
3124
+ cursor = payload.cursor ?? void 0;
3125
+ if (!payload.hasMore || !cursor) break;
3126
+ }
3127
+ return notes;
3128
+ }
3129
+ async fetchNoteDetail(noteId, timeoutMs) {
3130
+ const url = new URL(`${PUBLIC_NOTES_URL}/${noteId}`);
3131
+ url.searchParams.append("include", "transcript");
3132
+ const response = await this.httpClient.request({
3133
+ headers: { Accept: "application/json" },
3134
+ timeoutMs,
3135
+ url: url.toString()
3136
+ });
3137
+ if (!response.ok) {
3138
+ const body = (await response.text()).slice(0, 500);
3139
+ throw new Error(`failed to get note ${noteId}: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
3140
+ }
3141
+ const document = parsePublicNote(await response.json());
3142
+ this.#documentsById.set(document.id, cloneDocument(document));
3143
+ return document;
3144
+ }
3145
+ async listDocuments(options) {
3146
+ const summaries = await this.listNoteSummaries(options);
3147
+ const nextDocuments = [];
3148
+ const toFetch = [];
3149
+ for (const summary of summaries) {
3150
+ const cached = this.#documentsById.get(summary.id);
3151
+ if (cached && cached.updatedAt === summary.updatedAt) {
3152
+ nextDocuments.push(cloneDocument(cached));
3153
+ continue;
3154
+ }
3155
+ toFetch.push(summary);
3156
+ }
3157
+ const fetchedDocuments = await mapInBatches(toFetch, DETAIL_BATCH_SIZE, async (summary) => await this.fetchNoteDetail(summary.id, options.timeoutMs));
3158
+ const fetchedById = new Map(fetchedDocuments.map((document) => [document.id, document]));
3159
+ const ids = /* @__PURE__ */ new Set();
3160
+ for (const summary of summaries) {
3161
+ const document = fetchedById.get(summary.id) ?? this.#documentsById.get(summary.id);
3162
+ if (!document) throw new Error(`failed to load note detail: ${summary.id}`);
3163
+ ids.add(summary.id);
3164
+ nextDocuments.push(cloneDocument(document));
3165
+ }
3166
+ for (const cachedId of this.#documentsById.keys()) if (!ids.has(cachedId)) this.#documentsById.delete(cachedId);
3167
+ return nextDocuments;
3168
+ }
3169
+ };
3170
+ //#endregion
2902
3171
  //#region src/client/http.ts
2903
3172
  const RETRYABLE_STATUS_CODES = new Set([
2904
3173
  429,
@@ -2993,8 +3262,23 @@ var AuthenticatedHttpClient = class {
2993
3262
  //#region src/client/default.ts
2994
3263
  async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
2995
3264
  const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
2996
- if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
2997
- if (!auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
3265
+ if (!auth.apiKeyAvailable && !auth.storedSessionAvailable && !config.supabase) throw new Error(`Granola credentials not found. Set --api-key or GRANOLA_API_KEY, use granola auth login --api-key, or fall back to --supabase. Expected supabase locations include: ${granolaSupabaseCandidates().join(", ")}`);
3266
+ if (config.supabase && !existsSync(config.supabase) && !auth.apiKeyAvailable && !auth.storedSessionAvailable) throw new Error(`supabase.json not found: ${config.supabase}`);
3267
+ if (auth.mode !== "api-key" && !auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
3268
+ if (auth.mode === "api-key") {
3269
+ const apiKeyStore = createDefaultApiKeyStore();
3270
+ const apiKey = config.apiKey?.trim() || await apiKeyStore.readApiKey();
3271
+ if (!apiKey) throw new Error("Granola API key not found. Set --api-key or GRANOLA_API_KEY, or run granola auth login --api-key <token>.");
3272
+ return {
3273
+ auth,
3274
+ client: new GranolaPublicApiClient(new AuthenticatedHttpClient({
3275
+ logger,
3276
+ tokenProvider: new CachedTokenProvider({ async loadAccessToken() {
3277
+ return apiKey;
3278
+ } })
3279
+ }))
3280
+ };
3281
+ }
2998
3282
  const sessionStore = createDefaultSessionStore();
2999
3283
  return {
3000
3284
  auth,
@@ -3763,6 +4047,28 @@ var GranolaApp = class {
3763
4047
  for (const [documentId, summaries] of byDocumentId.entries()) byDocumentId.set(documentId, summaries.slice().sort((left, right) => left.name.localeCompare(right.name)).map((folder) => cloneFolderSummary(folder)));
3764
4048
  return byDocumentId;
3765
4049
  }
4050
+ deriveFoldersFromDocuments(documents) {
4051
+ const byFolderId = /* @__PURE__ */ new Map();
4052
+ for (const document of documents) for (const membership of document.folderMemberships ?? []) {
4053
+ const existing = byFolderId.get(membership.id);
4054
+ if (existing) {
4055
+ existing.documentIds = [...new Set([...existing.documentIds, document.id])];
4056
+ existing.updatedAt = existing.updatedAt.localeCompare(document.updatedAt) >= 0 ? existing.updatedAt : document.updatedAt;
4057
+ if (!existing.createdAt || existing.createdAt.localeCompare(document.createdAt) > 0) existing.createdAt = document.createdAt;
4058
+ continue;
4059
+ }
4060
+ byFolderId.set(membership.id, {
4061
+ createdAt: document.createdAt,
4062
+ documentIds: [document.id],
4063
+ id: membership.id,
4064
+ isFavourite: false,
4065
+ name: membership.name,
4066
+ updatedAt: document.updatedAt
4067
+ });
4068
+ }
4069
+ if (byFolderId.size === 0) return;
4070
+ return [...byFolderId.values()].sort((left, right) => left.name.localeCompare(right.name));
4071
+ }
3766
4072
  async loadFolders(options = {}) {
3767
4073
  if (options.forceRefresh) {
3768
4074
  this.resetFoldersState();
@@ -3774,6 +4080,24 @@ var GranolaApp = class {
3774
4080
  }));
3775
4081
  const client = await this.getGranolaClient();
3776
4082
  if (!client.listFolders) {
4083
+ const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
4084
+ const folders = this.deriveFoldersFromDocuments(documents);
4085
+ if (folders) {
4086
+ this.#folders = folders.map((folder) => ({
4087
+ ...folder,
4088
+ documentIds: [...folder.documentIds]
4089
+ }));
4090
+ this.#state.folders = {
4091
+ count: folders.length,
4092
+ loaded: true,
4093
+ loadedAt: this.nowIso()
4094
+ };
4095
+ this.emitStateUpdate();
4096
+ return this.#folders.map((folder) => ({
4097
+ ...folder,
4098
+ documentIds: [...folder.documentIds]
4099
+ }));
4100
+ }
3777
4101
  if (options.required) throw new Error("Granola folder API is not configured");
3778
4102
  return;
3779
4103
  }
@@ -4368,6 +4692,7 @@ async function loadConfig(options) {
4368
4692
  const defaultCache = firstExistingPath(granolaCacheCandidates());
4369
4693
  const timeoutValue = pickString(options.subcommandFlags.timeout) ?? pickString(env.TIMEOUT) ?? pickString(configValues.timeout) ?? "2m";
4370
4694
  return {
4695
+ apiKey: pickString(options.globalFlags["api-key"]) ?? pickString(env.GRANOLA_API_KEY) ?? pickString(configValues["api-key"]) ?? pickString(configValues.apiKey),
4371
4696
  configFileUsed: config.path,
4372
4697
  debug: pickBoolean(options.globalFlags.debug) ?? envFlag(env.DEBUG_MODE) ?? pickBoolean(configValues.debug) ?? false,
4373
4698
  notes: {
@@ -4455,13 +4780,15 @@ Usage:
4455
4780
 
4456
4781
  Subcommands:
4457
4782
  login Import credentials from the Granola desktop app
4783
+ Or store a Granola API key with --api-key
4458
4784
  status Show the current Granola auth state
4459
- logout Delete the stored Granola session
4785
+ logout Delete stored Granola credentials
4460
4786
  refresh Refresh the stored Granola session
4461
- use <stored|supabase>
4787
+ use <api-key|stored|supabase>
4462
4788
  Switch the active auth source for this toolkit instance
4463
4789
 
4464
4790
  Options:
4791
+ --api-key <token> Store a Granola Personal API key
4465
4792
  --supabase <path> Path to supabase.json for auth login
4466
4793
  --config <path> Path to .granola.toml
4467
4794
  --debug Enable debug logging
@@ -4469,10 +4796,15 @@ Options:
4469
4796
  `;
4470
4797
  }
4471
4798
  function formatAuthSource(mode) {
4472
- return mode === "stored-session" ? "stored session" : "supabase.json";
4799
+ switch (mode) {
4800
+ case "api-key": return "API key";
4801
+ case "stored-session": return "stored session";
4802
+ default: return "supabase.json";
4803
+ }
4473
4804
  }
4474
4805
  function printAuthState(state) {
4475
4806
  console.log(`Active source: ${formatAuthSource(state.mode)}`);
4807
+ console.log(`API key: ${state.apiKeyAvailable ? "available" : "missing"}`);
4476
4808
  console.log(`Stored session: ${state.storedSessionAvailable ? "available" : "missing"}`);
4477
4809
  console.log(`supabase.json: ${state.supabaseAvailable ? "available" : "missing"}`);
4478
4810
  if (state.supabasePath) console.log(`supabase path: ${state.supabasePath}`);
@@ -4483,10 +4815,13 @@ function printAuthState(state) {
4483
4815
  }
4484
4816
  const authCommand = {
4485
4817
  description: "Manage stored Granola sessions",
4486
- flags: { help: { type: "boolean" } },
4818
+ flags: {
4819
+ "api-key": { type: "string" },
4820
+ help: { type: "boolean" }
4821
+ },
4487
4822
  help: authHelp,
4488
4823
  name: "auth",
4489
- async run({ commandArgs, globalFlags }) {
4824
+ async run({ commandArgs, commandFlags, globalFlags }) {
4490
4825
  const [action, value] = commandArgs;
4491
4826
  const config = await loadConfig({
4492
4827
  globalFlags,
@@ -4497,14 +4832,14 @@ const authCommand = {
4497
4832
  const app = await createGranolaApp(config);
4498
4833
  switch (action) {
4499
4834
  case "login": {
4500
- const state = await app.loginAuth();
4501
- console.log(`Imported Granola session from ${state.supabasePath ?? "desktop app defaults"}`);
4835
+ const state = await app.loginAuth({ apiKey: typeof commandFlags["api-key"] === "string" ? commandFlags["api-key"] : void 0 });
4836
+ console.log(typeof commandFlags["api-key"] === "string" ? "Stored Granola API key" : `Imported Granola session from ${state.supabasePath ?? "desktop app defaults"}`);
4502
4837
  printAuthState(state);
4503
4838
  return 0;
4504
4839
  }
4505
4840
  case "logout": {
4506
4841
  const state = await app.logoutAuth();
4507
- console.log("Stored Granola session deleted");
4842
+ console.log("Stored Granola credentials deleted");
4508
4843
  printAuthState(state);
4509
4844
  return 0;
4510
4845
  }
@@ -4517,7 +4852,7 @@ const authCommand = {
4517
4852
  case "status": {
4518
4853
  const state = await app.inspectAuth();
4519
4854
  printAuthState(state);
4520
- return state.storedSessionAvailable ? 0 : 1;
4855
+ return state.apiKeyAvailable || state.storedSessionAvailable || state.supabaseAvailable ? 0 : 1;
4521
4856
  }
4522
4857
  case "use": {
4523
4858
  const mode = resolveAuthMode(value);
@@ -4535,11 +4870,13 @@ const authCommand = {
4535
4870
  };
4536
4871
  function resolveAuthMode(value) {
4537
4872
  switch (value) {
4873
+ case "api":
4874
+ case "api-key": return "api-key";
4538
4875
  case "stored":
4539
4876
  case "stored-session": return "stored-session";
4540
4877
  case "supabase":
4541
4878
  case "supabase-file": return "supabase-file";
4542
- default: throw new Error("invalid auth mode: expected stored or supabase");
4879
+ default: throw new Error("invalid auth mode: expected api-key, stored, or supabase");
4543
4880
  }
4544
4881
  }
4545
4882
  //#endregion
@@ -4983,7 +5320,12 @@ function renderAppState() {
4983
5320
  }
4984
5321
 
4985
5322
  const appState = state.appState;
4986
- const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
5323
+ const authMode =
5324
+ appState.auth.mode === "api-key"
5325
+ ? "API key"
5326
+ : appState.auth.mode === "stored-session"
5327
+ ? "Stored session"
5328
+ : "supabase.json";
4987
5329
  const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
4988
5330
  const cache = appState.cache.loaded
4989
5331
  ? appState.cache.transcriptCount + " transcript sets"
@@ -5103,7 +5445,12 @@ function renderAuthPanel() {
5103
5445
  return;
5104
5446
  }
5105
5447
 
5106
- const activeSource = auth.mode === "stored-session" ? "Stored session" : "supabase.json";
5448
+ const activeSource =
5449
+ auth.mode === "api-key"
5450
+ ? "API key"
5451
+ : auth.mode === "stored-session"
5452
+ ? "Stored session"
5453
+ : "supabase.json";
5107
5454
  const lastError = auth.lastError
5108
5455
  ? '<div class="auth-card__meta auth-card__error">' + escapeHtml(auth.lastError) + "</div>"
5109
5456
  : "";
@@ -5112,6 +5459,7 @@ function renderAuthPanel() {
5112
5459
  '<div class="auth-card">',
5113
5460
  '<div class="status-grid">',
5114
5461
  '<div><span class="status-label">Active</span><strong>' + escapeHtml(activeSource) + "</strong></div>",
5462
+ '<div><span class="status-label">API key</span><strong>' + escapeHtml(auth.apiKeyAvailable ? "available" : "missing") + "</strong></div>",
5115
5463
  '<div><span class="status-label">Stored</span><strong>' + escapeHtml(auth.storedSessionAvailable ? "available" : "missing") + "</strong></div>",
5116
5464
  '<div><span class="status-label">supabase.json</span><strong>' + escapeHtml(auth.supabaseAvailable ? "available" : "missing") + "</strong></div>",
5117
5465
  '<div><span class="status-label">Refresh</span><strong>' + escapeHtml(auth.refreshAvailable ? "available" : "missing") + "</strong></div>",
@@ -5126,12 +5474,16 @@ function renderAuthPanel() {
5126
5474
  ? '<div class="auth-card__meta">supabase path: ' + escapeHtml(auth.supabasePath) + "</div>"
5127
5475
  : "",
5128
5476
  lastError,
5477
+ '<div class="auth-card__meta">Store a Granola Personal API key here or use <code>granola auth login --api-key &lt;token&gt;</code>.</div>',
5129
5478
  '<div class="auth-card__actions">',
5479
+ '<input class="input" type="password" placeholder="grn_..." data-auth-api-key />',
5480
+ authActionButton("Save API key", "login-api-key", false),
5130
5481
  authActionButton("Import desktop session", "login", !auth.supabaseAvailable),
5131
5482
  authActionButton("Refresh stored session", "refresh", !auth.storedSessionAvailable || !auth.refreshAvailable),
5483
+ authModeButton("Use API key", "api-key", !auth.apiKeyAvailable || auth.mode === "api-key"),
5132
5484
  authModeButton("Use stored session", "stored-session", !auth.storedSessionAvailable || auth.mode === "stored-session"),
5133
5485
  authModeButton("Use supabase.json", "supabase-file", !auth.supabaseAvailable || auth.mode === "supabase-file"),
5134
- authActionButton("Sign out", "logout", !auth.storedSessionAvailable),
5486
+ authActionButton("Sign out", "logout", !auth.apiKeyAvailable && !auth.storedSessionAvailable),
5135
5487
  "</div>",
5136
5488
  "</div>",
5137
5489
  ].join("");
@@ -5521,14 +5873,19 @@ async function rerunJob(id) {
5521
5873
  }
5522
5874
  }
5523
5875
 
5524
- async function loginAuth() {
5525
- setStatus("Importing desktop session…", "busy");
5876
+ async function loginAuth(apiKey) {
5877
+ setStatus(apiKey ? "Saving API key…" : "Importing desktop session…", "busy");
5526
5878
  try {
5527
- await fetchJson("/auth/login", { method: "POST" });
5879
+ const body = apiKey ? { apiKey } : undefined;
5880
+ await fetchJson("/auth/login", {
5881
+ body: body ? JSON.stringify(body) : undefined,
5882
+ headers: body ? { "content-type": "application/json" } : undefined,
5883
+ method: "POST",
5884
+ });
5528
5885
  await refreshAll();
5529
5886
  } catch (error) {
5530
5887
  await syncAuthState();
5531
- setStatus("Auth import failed", "error");
5888
+ setStatus(apiKey ? "API key save failed" : "Auth import failed", "error");
5532
5889
  state.detailError = error instanceof Error ? error.message : String(error);
5533
5890
  renderMeetingDetail();
5534
5891
  }
@@ -5675,6 +6032,17 @@ els.authPanel.addEventListener("click", (event) => {
5675
6032
  const actionButton = event.target.closest("[data-auth-action]");
5676
6033
  if (actionButton) {
5677
6034
  switch (actionButton.dataset.authAction) {
6035
+ case "login-api-key": {
6036
+ const apiKeyInput = els.authPanel.querySelector("[data-auth-api-key]");
6037
+ const apiKey =
6038
+ apiKeyInput && "value" in apiKeyInput ? String(apiKeyInput.value || "").trim() : "";
6039
+ if (!apiKey) {
6040
+ setStatus("Enter a Granola API key", "error");
6041
+ return;
6042
+ }
6043
+ void loginAuth(apiKey);
6044
+ return;
6045
+ }
5678
6046
  case "login":
5679
6047
  void loginAuth();
5680
6048
  return;
@@ -6542,9 +6910,10 @@ function parseMeetingSort(value) {
6542
6910
  }
6543
6911
  function parseAuthMode(value) {
6544
6912
  switch (value) {
6913
+ case "api-key":
6545
6914
  case "stored-session":
6546
6915
  case "supabase-file": return value;
6547
- default: throw new Error("invalid auth mode: expected stored-session or supabase-file");
6916
+ default: throw new Error("invalid auth mode: expected api-key, stored-session, or supabase-file");
6548
6917
  }
6549
6918
  }
6550
6919
  function folderIdFromBody(value) {
@@ -6891,8 +7260,12 @@ async function startGranolaServer(app, options = {}) {
6891
7260
  }
6892
7261
  if (method === "POST" && path === granolaTransportPaths.authLogin) {
6893
7262
  const body = await readJsonBody(request);
7263
+ const apiKey = typeof body.apiKey === "string" && body.apiKey.trim() ? body.apiKey.trim() : void 0;
6894
7264
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
6895
- sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
7265
+ sendJson(response, await app.loginAuth({
7266
+ apiKey,
7267
+ supabasePath
7268
+ }), { headers: originHeaders });
6896
7269
  return;
6897
7270
  }
6898
7271
  if (method === "POST" && path === granolaTransportPaths.authLogout) {
@@ -7911,6 +8284,7 @@ Commands:
7911
8284
  ${commands.map((command) => ` ${command.name.padEnd(commandWidth)} ${command.description}`).join("\n")}
7912
8285
 
7913
8286
  Global options:
8287
+ --api-key <token> Granola Personal API key
7914
8288
  --config <path> Path to .granola.toml
7915
8289
  --debug Enable debug logging
7916
8290
  --supabase <path> Path to supabase.json
@@ -7928,6 +8302,7 @@ async function runCli(argv) {
7928
8302
  try {
7929
8303
  const { command, rest } = splitCommand(argv);
7930
8304
  const global = parseFlags(rest, {
8305
+ "api-key": { type: "string" },
7931
8306
  config: { type: "string" },
7932
8307
  debug: { type: "boolean" },
7933
8308
  help: { type: "boolean" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",