granola-toolkit 0.36.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 +516 -40
  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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { existsSync } from "node:fs";
7
7
  import { homedir, platform } from "node:os";
@@ -30,6 +30,7 @@ const granolaTransportPaths = {
30
30
  root: "/",
31
31
  serverInfo: "/server/info",
32
32
  syncRun: "/sync",
33
+ syncEvents: "/sync/events",
33
34
  state: "/state"
34
35
  };
35
36
  function appendSearchParams(path, params) {
@@ -181,6 +182,10 @@ var GranolaServerClient = class GranolaServerClient {
181
182
  async inspectSync() {
182
183
  return cloneValue(this.#state.sync);
183
184
  }
185
+ async listSyncEvents(options = {}) {
186
+ const path = options.limit ? `${granolaTransportPaths.syncEvents}?limit=${encodeURIComponent(String(options.limit))}` : granolaTransportPaths.syncEvents;
187
+ return await this.requestJson(path);
188
+ }
184
189
  async loginAuth(options = {}) {
185
190
  return await this.requestJson(granolaTransportPaths.authLogin, {
186
191
  body: JSON.stringify(options),
@@ -1083,14 +1088,14 @@ function cacheDocumentForMeeting(document, cacheData) {
1083
1088
  };
1084
1089
  }
1085
1090
  function buildMeetingTranscript(document, cacheData) {
1086
- if (!cacheData) return {
1091
+ const rawSegments = cacheData?.transcripts[document.id] ?? document.transcriptSegments ?? [];
1092
+ if (!(Boolean(cacheData) || Array.isArray(document.transcriptSegments))) return {
1087
1093
  loaded: false,
1088
1094
  segmentCount: 0,
1089
1095
  transcript: null,
1090
1096
  transcriptRecord: null,
1091
1097
  transcriptText: null
1092
1098
  };
1093
- const rawSegments = cacheData.transcripts[document.id] ?? [];
1094
1099
  const normalisedSegments = normaliseTranscriptSegments(rawSegments);
1095
1100
  if (normalisedSegments.length === 0) return {
1096
1101
  loaded: true,
@@ -1378,7 +1383,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1378
1383
  }
1379
1384
  }
1380
1385
  function buildGranolaTuiSummary(state, meetingSource) {
1381
- 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}`;
1382
1387
  }
1383
1388
  //#endregion
1384
1389
  //#region src/tui/theme.ts
@@ -1424,13 +1429,16 @@ function actionDisabledReason(auth, actionId) {
1424
1429
  case "refresh":
1425
1430
  if (!auth.storedSessionAvailable) return "stored session missing";
1426
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" : "";
1427
1435
  case "use-stored":
1428
1436
  if (!auth.storedSessionAvailable) return "stored session missing";
1429
1437
  return auth.mode === "stored-session" ? "already active" : "";
1430
1438
  case "use-supabase":
1431
1439
  if (!auth.supabaseAvailable) return "supabase.json unavailable";
1432
1440
  return auth.mode === "supabase-file" ? "already active" : "";
1433
- case "logout": return auth.storedSessionAvailable ? "" : "stored session missing";
1441
+ case "logout": return auth.apiKeyAvailable || auth.storedSessionAvailable ? "" : "no stored credentials";
1434
1442
  }
1435
1443
  }
1436
1444
  function buildGranolaTuiAuthActions(auth) {
@@ -1447,22 +1455,28 @@ function buildGranolaTuiAuthActions(auth) {
1447
1455
  key: "2",
1448
1456
  label: "Refresh stored session"
1449
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
+ },
1450
1464
  {
1451
1465
  description: "Switch the active auth source to the stored session",
1452
1466
  id: "use-stored",
1453
- key: "3",
1467
+ key: "4",
1454
1468
  label: "Use stored session"
1455
1469
  },
1456
1470
  {
1457
1471
  description: "Switch the active auth source to supabase.json",
1458
1472
  id: "use-supabase",
1459
- key: "4",
1473
+ key: "5",
1460
1474
  label: "Use supabase.json"
1461
1475
  },
1462
1476
  {
1463
- description: "Delete the stored session and fall back to supabase.json",
1477
+ description: "Delete stored credentials and fall back to configured sources",
1464
1478
  id: "logout",
1465
- key: "5",
1479
+ key: "6",
1466
1480
  label: "Sign out"
1467
1481
  }
1468
1482
  ].map((action) => {
@@ -1476,7 +1490,8 @@ function buildGranolaTuiAuthActions(auth) {
1476
1490
  }
1477
1491
  function renderGranolaTuiAuthState(auth) {
1478
1492
  const lines = [
1479
- `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"}`,
1480
1495
  `Stored session: ${auth.storedSessionAvailable ? "available" : "missing"}`,
1481
1496
  `supabase.json: ${auth.supabaseAvailable ? "available" : "missing"}`,
1482
1497
  `Refresh: ${auth.refreshAvailable ? "available" : "missing"}`
@@ -1972,6 +1987,11 @@ var GranolaTuiWorkspace = class {
1972
1987
  await this.app.refreshAuth();
1973
1988
  successMessage = "Stored session refreshed";
1974
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;
1975
1995
  case "use-stored":
1976
1996
  this.setStatus("Switching to stored session…");
1977
1997
  await this.app.switchAuthMode("stored-session");
@@ -1985,7 +2005,7 @@ var GranolaTuiWorkspace = class {
1985
2005
  case "logout":
1986
2006
  this.setStatus("Signing out…");
1987
2007
  await this.app.logoutAuth();
1988
- successMessage = "Stored session removed";
2008
+ successMessage = "Stored credentials removed";
1989
2009
  break;
1990
2010
  }
1991
2011
  await this.reloadAfterAuthChange();
@@ -2383,11 +2403,13 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2383
2403
  const targetPlatform = options.platform ?? platform();
2384
2404
  const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2385
2405
  return {
2406
+ apiKeyFile: join(dataDirectory, "api-key.txt"),
2386
2407
  dataDirectory,
2387
2408
  exportJobsFile: join(dataDirectory, "export-jobs.json"),
2388
2409
  meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2389
2410
  sessionFile: join(dataDirectory, "session.json"),
2390
2411
  sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
2412
+ syncEventsFile: join(dataDirectory, "sync-events.jsonl"),
2391
2413
  syncStateFile: join(dataDirectory, "sync-state.json")
2392
2414
  };
2393
2415
  }
@@ -2396,6 +2418,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2396
2418
  const execFileAsync$1 = promisify(execFile);
2397
2419
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
2398
2420
  const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
2421
+ const KEYCHAIN_ACCOUNT_NAME_API_KEY = "api-key";
2399
2422
  const KEYCHAIN_ACCOUNT_NAME = "session";
2400
2423
  const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
2401
2424
  function numberValue(value) {
@@ -2480,6 +2503,32 @@ var FileSessionStore = class {
2480
2503
  });
2481
2504
  }
2482
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
+ };
2483
2532
  var KeychainSessionStore = class {
2484
2533
  async clearSession() {
2485
2534
  try {
@@ -2521,6 +2570,48 @@ var KeychainSessionStore = class {
2521
2570
  ]);
2522
2571
  }
2523
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
+ };
2524
2615
  var CachedTokenProvider = class {
2525
2616
  #token;
2526
2617
  constructor(source, store = new NoopTokenStore()) {
@@ -2614,33 +2705,45 @@ async function refreshGranolaSession(session, fetchImpl = fetch) {
2614
2705
  function defaultSessionFilePath() {
2615
2706
  return defaultGranolaToolkitPersistenceLayout().sessionFile;
2616
2707
  }
2708
+ function defaultApiKeyFilePath() {
2709
+ return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
2710
+ }
2617
2711
  function createDefaultSessionStore() {
2618
2712
  return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
2619
2713
  }
2714
+ function createDefaultApiKeyStore() {
2715
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
2716
+ }
2620
2717
  //#endregion
2621
2718
  //#region src/client/default-auth.ts
2622
2719
  function hasStoredSession(session) {
2623
2720
  return Boolean(session?.accessToken.trim());
2624
2721
  }
2625
2722
  function resolveActiveMode(options) {
2723
+ if (options.preferredMode === "api-key" && options.apiKeyAvailable) return "api-key";
2626
2724
  if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
2627
2725
  if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
2726
+ if (options.apiKeyAvailable) return "api-key";
2628
2727
  if (options.storedSessionAvailable) return "stored-session";
2629
- return "supabase-file";
2728
+ if (options.supabaseAvailable) return "supabase-file";
2729
+ return options.preferredMode ?? "api-key";
2630
2730
  }
2631
2731
  function missingSupabaseError() {
2632
2732
  return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
2633
2733
  }
2634
2734
  function buildDefaultGranolaAuthInfo(config, options = {}) {
2635
2735
  const existsSyncImpl = options.existsSyncImpl ?? existsSync;
2736
+ const apiKeyAvailable = Boolean(options.apiKey?.trim());
2636
2737
  const session = options.session;
2637
2738
  const storedSessionAvailable = hasStoredSession(session);
2638
2739
  const supabasePath = config.supabase || void 0;
2639
2740
  const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
2640
2741
  return {
2742
+ apiKeyAvailable,
2641
2743
  clientId: session?.clientId,
2642
2744
  lastError: options.lastError,
2643
2745
  mode: resolveActiveMode({
2746
+ apiKeyAvailable,
2644
2747
  preferredMode: options.preferredMode,
2645
2748
  storedSessionAvailable,
2646
2749
  supabaseAvailable
@@ -2653,9 +2756,12 @@ function buildDefaultGranolaAuthInfo(config, options = {}) {
2653
2756
  };
2654
2757
  }
2655
2758
  async function inspectDefaultGranolaAuth(config, options = {}) {
2759
+ const apiKeyStore = options.apiKeyStore ?? createDefaultApiKeyStore();
2656
2760
  const sessionStore = options.sessionStore ?? createDefaultSessionStore();
2761
+ const apiKey = options.apiKey ?? config.apiKey ?? await apiKeyStore.readApiKey();
2657
2762
  const session = options.session ?? await sessionStore.readSession();
2658
2763
  return buildDefaultGranolaAuthInfo(config, {
2764
+ apiKey,
2659
2765
  existsSyncImpl: options.existsSyncImpl,
2660
2766
  lastError: options.lastError,
2661
2767
  preferredMode: options.preferredMode,
@@ -2672,6 +2778,12 @@ var DefaultAuthController = class {
2672
2778
  sessionStore() {
2673
2779
  return this.options.sessionStore ?? createDefaultSessionStore();
2674
2780
  }
2781
+ apiKeyStore() {
2782
+ return this.options.apiKeyStore ?? createDefaultApiKeyStore();
2783
+ }
2784
+ async readApiKey() {
2785
+ return this.config.apiKey?.trim() || await this.apiKeyStore().readApiKey();
2786
+ }
2675
2787
  readSession() {
2676
2788
  return this.sessionStore().readSession();
2677
2789
  }
@@ -2685,8 +2797,10 @@ var DefaultAuthController = class {
2685
2797
  return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
2686
2798
  }
2687
2799
  async inspect() {
2800
+ const apiKey = await this.readApiKey();
2688
2801
  const session = await this.readSession();
2689
2802
  return buildDefaultGranolaAuthInfo(this.config, {
2803
+ apiKey,
2690
2804
  existsSyncImpl: this.options.existsSyncImpl,
2691
2805
  lastError: this.#lastError,
2692
2806
  preferredMode: this.#preferredMode,
@@ -2694,6 +2808,13 @@ var DefaultAuthController = class {
2694
2808
  });
2695
2809
  }
2696
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
+ }
2697
2818
  const supabasePath = this.resolveSupabasePath(options.supabasePath);
2698
2819
  const session = await this.sessionSource(supabasePath).loadSession();
2699
2820
  await this.sessionStore().writeSession(session);
@@ -2702,6 +2823,7 @@ var DefaultAuthController = class {
2702
2823
  return await this.inspect();
2703
2824
  }
2704
2825
  async logout() {
2826
+ await this.apiKeyStore().clearApiKey();
2705
2827
  await this.sessionStore().clearSession();
2706
2828
  this.#lastError = void 0;
2707
2829
  this.#preferredMode = void 0;
@@ -2726,6 +2848,10 @@ var DefaultAuthController = class {
2726
2848
  }
2727
2849
  async switchMode(mode) {
2728
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
+ }
2729
2855
  if (mode === "stored-session" && !state.storedSessionAvailable) {
2730
2856
  this.#lastError = "no stored Granola session found";
2731
2857
  throw new Error(this.#lastError);
@@ -2790,6 +2916,63 @@ function parseDocument(value) {
2790
2916
  updatedAt: stringValue(record.updated_at)
2791
2917
  };
2792
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
+ }
2793
2976
  function parseFolderDocumentIds(value) {
2794
2977
  if (!Array.isArray(value)) return [];
2795
2978
  return value.map((item) => {
@@ -2893,6 +3076,98 @@ var GranolaApiClient = class {
2893
3076
  }
2894
3077
  };
2895
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
2896
3171
  //#region src/client/http.ts
2897
3172
  const RETRYABLE_STATUS_CODES = new Set([
2898
3173
  429,
@@ -2987,8 +3262,23 @@ var AuthenticatedHttpClient = class {
2987
3262
  //#region src/client/default.ts
2988
3263
  async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
2989
3264
  const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
2990
- if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
2991
- 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
+ }
2992
3282
  const sessionStore = createDefaultSessionStore();
2993
3283
  return {
2994
3284
  auth,
@@ -3281,14 +3571,17 @@ function cloneSyncSummary(summary) {
3281
3571
  }
3282
3572
  function normaliseSyncState(filePath, file) {
3283
3573
  return {
3574
+ eventCount: file?.eventCount ?? 0,
3575
+ eventsFile: file?.eventsFile ?? defaultSyncEventsFilePath$1(),
3284
3576
  filePath,
3285
3577
  lastChanges: (file?.lastChanges ?? []).slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3286
- lastCompletedAt: file?.lastCompletedAt,
3287
- lastError: file?.lastError,
3288
- lastFailedAt: file?.lastFailedAt,
3289
- lastStartedAt: file?.lastStartedAt,
3290
3578
  running: false,
3291
- summary: cloneSyncSummary(file?.summary)
3579
+ ...file?.lastCompletedAt ? { lastCompletedAt: file.lastCompletedAt } : {},
3580
+ ...file?.lastError ? { lastError: file.lastError } : {},
3581
+ ...file?.lastFailedAt ? { lastFailedAt: file.lastFailedAt } : {},
3582
+ ...file?.lastRunId ? { lastRunId: file.lastRunId } : {},
3583
+ ...file?.lastStartedAt ? { lastStartedAt: file.lastStartedAt } : {},
3584
+ ...file?.summary ? { summary: cloneSyncSummary(file.summary) } : {}
3292
3585
  };
3293
3586
  }
3294
3587
  var FileSyncStateStore = class {
@@ -3307,10 +3600,13 @@ var FileSyncStateStore = class {
3307
3600
  async writeState(state) {
3308
3601
  await mkdir(dirname(this.filePath), { recursive: true });
3309
3602
  const payload = {
3603
+ eventCount: state.eventCount,
3604
+ eventsFile: state.eventsFile,
3310
3605
  lastChanges: state.lastChanges.slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3311
3606
  lastCompletedAt: state.lastCompletedAt,
3312
3607
  lastError: state.lastError,
3313
3608
  lastFailedAt: state.lastFailedAt,
3609
+ lastRunId: state.lastRunId,
3314
3610
  lastStartedAt: state.lastStartedAt,
3315
3611
  summary: cloneSyncSummary(state.summary),
3316
3612
  version: SYNC_STATE_VERSION
@@ -3324,10 +3620,45 @@ var FileSyncStateStore = class {
3324
3620
  function defaultSyncStateFilePath() {
3325
3621
  return defaultGranolaToolkitPersistenceLayout().syncStateFile;
3326
3622
  }
3623
+ function defaultSyncEventsFilePath$1() {
3624
+ return defaultGranolaToolkitPersistenceLayout().syncEventsFile;
3625
+ }
3327
3626
  function createDefaultSyncStateStore() {
3328
3627
  return new FileSyncStateStore();
3329
3628
  }
3330
3629
  //#endregion
3630
+ //#region src/sync-events.ts
3631
+ function cloneSyncEvent$1(event) {
3632
+ return { ...event };
3633
+ }
3634
+ var FileSyncEventStore = class {
3635
+ constructor(filePath = defaultSyncEventsFilePath()) {
3636
+ this.filePath = filePath;
3637
+ }
3638
+ async appendEvents(events) {
3639
+ if (events.length === 0) return;
3640
+ await mkdir(dirname(this.filePath), { recursive: true });
3641
+ const payload = events.map((event) => JSON.stringify(event)).join("\n");
3642
+ await appendFile(this.filePath, `${payload}\n`, {
3643
+ encoding: "utf8",
3644
+ mode: 384
3645
+ });
3646
+ }
3647
+ async readEvents(limit = 50) {
3648
+ try {
3649
+ return (await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((event) => Boolean(event)).map(cloneSyncEvent$1).slice(-limit).reverse();
3650
+ } catch {
3651
+ return [];
3652
+ }
3653
+ }
3654
+ };
3655
+ function defaultSyncEventsFilePath() {
3656
+ return defaultGranolaToolkitPersistenceLayout().syncEventsFile;
3657
+ }
3658
+ function createDefaultSyncEventStore() {
3659
+ return new FileSyncEventStore();
3660
+ }
3661
+ //#endregion
3331
3662
  //#region src/sync.ts
3332
3663
  function normaliseMeeting(meeting) {
3333
3664
  return {
@@ -3425,6 +3756,18 @@ function diffMeetingSummaries(previous, next, folderCount) {
3425
3756
  }
3426
3757
  };
3427
3758
  }
3759
+ function buildSyncEvents(runId, occurredAt, changes) {
3760
+ return changes.map((change, index) => ({
3761
+ id: `${runId}:${index + 1}`,
3762
+ kind: change.kind === "created" ? "meeting.created" : change.kind === "changed" ? "meeting.changed" : change.kind === "removed" ? "meeting.removed" : "transcript.ready",
3763
+ meetingId: change.meetingId,
3764
+ occurredAt,
3765
+ previousUpdatedAt: change.previousUpdatedAt,
3766
+ runId,
3767
+ title: change.title,
3768
+ updatedAt: change.updatedAt
3769
+ }));
3770
+ }
3428
3771
  //#endregion
3429
3772
  //#region src/app/core.ts
3430
3773
  function transcriptCount(cacheData) {
@@ -3455,6 +3798,9 @@ function cloneSyncState(state) {
3455
3798
  summary: state.summary ? { ...state.summary } : void 0
3456
3799
  };
3457
3800
  }
3801
+ function cloneSyncEvent(event) {
3802
+ return { ...event };
3803
+ }
3458
3804
  function cloneMeetingSummary(meeting) {
3459
3805
  return {
3460
3806
  ...meeting,
@@ -3514,6 +3860,8 @@ function defaultState(config, auth, surface) {
3514
3860
  meetingCount: 0
3515
3861
  },
3516
3862
  sync: {
3863
+ eventCount: 0,
3864
+ eventsFile: defaultSyncEventsFilePath$1(),
3517
3865
  filePath: defaultSyncStateFilePath(),
3518
3866
  lastChanges: [],
3519
3867
  running: false
@@ -3550,6 +3898,8 @@ var GranolaApp = class {
3550
3898
  this.#state.sync = {
3551
3899
  ...this.#state.sync,
3552
3900
  ...cloneSyncState(deps.syncState ?? {
3901
+ eventCount: 0,
3902
+ eventsFile: defaultSyncEventsFilePath$1(),
3553
3903
  filePath: defaultSyncStateFilePath(),
3554
3904
  lastChanges: [],
3555
3905
  running: false
@@ -3609,6 +3959,9 @@ var GranolaApp = class {
3609
3959
  if (!this.deps.syncStateStore) return;
3610
3960
  await this.deps.syncStateStore.writeState(this.#state.sync);
3611
3961
  }
3962
+ createSyncRunId() {
3963
+ return `sync-${this.nowIso().replaceAll(/[-:.]/g, "").replace("T", "").replace("Z", "")}`;
3964
+ }
3612
3965
  applyAuthState(auth, options = {}) {
3613
3966
  if (options.resetDocuments) this.resetRemoteState();
3614
3967
  this.#state.auth = { ...auth };
@@ -3694,6 +4047,28 @@ var GranolaApp = class {
3694
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)));
3695
4048
  return byDocumentId;
3696
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
+ }
3697
4072
  async loadFolders(options = {}) {
3698
4073
  if (options.forceRefresh) {
3699
4074
  this.resetFoldersState();
@@ -3705,6 +4080,24 @@ var GranolaApp = class {
3705
4080
  }));
3706
4081
  const client = await this.getGranolaClient();
3707
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
+ }
3708
4101
  if (options.required) throw new Error("Granola folder API is not configured");
3709
4102
  return;
3710
4103
  }
@@ -3793,6 +4186,10 @@ var GranolaApp = class {
3793
4186
  async inspectSync() {
3794
4187
  return cloneSyncState(this.#state.sync);
3795
4188
  }
4189
+ async listSyncEvents(options = {}) {
4190
+ if (!this.deps.syncEventStore) return { events: [] };
4191
+ return { events: (await this.deps.syncEventStore.readEvents(options.limit)).map(cloneSyncEvent) };
4192
+ }
3796
4193
  async loginAuth(options = {}) {
3797
4194
  const controller = this.requireAuthController();
3798
4195
  try {
@@ -3856,11 +4253,17 @@ var GranolaApp = class {
3856
4253
  const snapshot = await this.liveMeetingSnapshot({ forceRefresh: options.forceRefresh ?? true });
3857
4254
  await this.persistMeetingIndex(snapshot.meetings);
3858
4255
  const { changes, summary } = diffMeetingSummaries(previousMeetings, snapshot.meetings, snapshot.folders?.length ?? 0);
4256
+ const completedAt = this.nowIso();
4257
+ const runId = this.createSyncRunId();
4258
+ const events = buildSyncEvents(runId, completedAt, changes);
4259
+ if (events.length > 0 && this.deps.syncEventStore) await this.deps.syncEventStore.appendEvents(events);
3859
4260
  this.#state.sync = {
3860
4261
  ...this.#state.sync,
4262
+ eventCount: this.#state.sync.eventCount + events.length,
3861
4263
  lastChanges: changes.slice(0, 50).map(cloneSyncChange),
3862
- lastCompletedAt: this.nowIso(),
4264
+ lastCompletedAt: completedAt,
3863
4265
  lastError: void 0,
4266
+ lastRunId: runId,
3864
4267
  running: false,
3865
4268
  summary: { ...summary }
3866
4269
  };
@@ -4209,6 +4612,7 @@ async function createGranolaApp(config, options = {}) {
4209
4612
  const exportJobs = await exportJobStore.readJobs();
4210
4613
  const meetingIndexStore = createDefaultMeetingIndexStore();
4211
4614
  const meetingIndex = await meetingIndexStore.readIndex();
4615
+ const syncEventStore = createDefaultSyncEventStore();
4212
4616
  const syncStateStore = createDefaultSyncStateStore();
4213
4617
  const syncState = await syncStateStore.readState();
4214
4618
  return new GranolaApp(config, {
@@ -4221,6 +4625,7 @@ async function createGranolaApp(config, options = {}) {
4221
4625
  meetingIndex,
4222
4626
  meetingIndexStore,
4223
4627
  now: options.now,
4628
+ syncEventStore,
4224
4629
  syncState,
4225
4630
  syncStateStore
4226
4631
  }, { surface: options.surface });
@@ -4287,6 +4692,7 @@ async function loadConfig(options) {
4287
4692
  const defaultCache = firstExistingPath(granolaCacheCandidates());
4288
4693
  const timeoutValue = pickString(options.subcommandFlags.timeout) ?? pickString(env.TIMEOUT) ?? pickString(configValues.timeout) ?? "2m";
4289
4694
  return {
4695
+ apiKey: pickString(options.globalFlags["api-key"]) ?? pickString(env.GRANOLA_API_KEY) ?? pickString(configValues["api-key"]) ?? pickString(configValues.apiKey),
4290
4696
  configFileUsed: config.path,
4291
4697
  debug: pickBoolean(options.globalFlags.debug) ?? envFlag(env.DEBUG_MODE) ?? pickBoolean(configValues.debug) ?? false,
4292
4698
  notes: {
@@ -4374,13 +4780,15 @@ Usage:
4374
4780
 
4375
4781
  Subcommands:
4376
4782
  login Import credentials from the Granola desktop app
4783
+ Or store a Granola API key with --api-key
4377
4784
  status Show the current Granola auth state
4378
- logout Delete the stored Granola session
4785
+ logout Delete stored Granola credentials
4379
4786
  refresh Refresh the stored Granola session
4380
- use <stored|supabase>
4787
+ use <api-key|stored|supabase>
4381
4788
  Switch the active auth source for this toolkit instance
4382
4789
 
4383
4790
  Options:
4791
+ --api-key <token> Store a Granola Personal API key
4384
4792
  --supabase <path> Path to supabase.json for auth login
4385
4793
  --config <path> Path to .granola.toml
4386
4794
  --debug Enable debug logging
@@ -4388,10 +4796,15 @@ Options:
4388
4796
  `;
4389
4797
  }
4390
4798
  function formatAuthSource(mode) {
4391
- 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
+ }
4392
4804
  }
4393
4805
  function printAuthState(state) {
4394
4806
  console.log(`Active source: ${formatAuthSource(state.mode)}`);
4807
+ console.log(`API key: ${state.apiKeyAvailable ? "available" : "missing"}`);
4395
4808
  console.log(`Stored session: ${state.storedSessionAvailable ? "available" : "missing"}`);
4396
4809
  console.log(`supabase.json: ${state.supabaseAvailable ? "available" : "missing"}`);
4397
4810
  if (state.supabasePath) console.log(`supabase path: ${state.supabasePath}`);
@@ -4402,10 +4815,13 @@ function printAuthState(state) {
4402
4815
  }
4403
4816
  const authCommand = {
4404
4817
  description: "Manage stored Granola sessions",
4405
- flags: { help: { type: "boolean" } },
4818
+ flags: {
4819
+ "api-key": { type: "string" },
4820
+ help: { type: "boolean" }
4821
+ },
4406
4822
  help: authHelp,
4407
4823
  name: "auth",
4408
- async run({ commandArgs, globalFlags }) {
4824
+ async run({ commandArgs, commandFlags, globalFlags }) {
4409
4825
  const [action, value] = commandArgs;
4410
4826
  const config = await loadConfig({
4411
4827
  globalFlags,
@@ -4416,14 +4832,14 @@ const authCommand = {
4416
4832
  const app = await createGranolaApp(config);
4417
4833
  switch (action) {
4418
4834
  case "login": {
4419
- const state = await app.loginAuth();
4420
- 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"}`);
4421
4837
  printAuthState(state);
4422
4838
  return 0;
4423
4839
  }
4424
4840
  case "logout": {
4425
4841
  const state = await app.logoutAuth();
4426
- console.log("Stored Granola session deleted");
4842
+ console.log("Stored Granola credentials deleted");
4427
4843
  printAuthState(state);
4428
4844
  return 0;
4429
4845
  }
@@ -4436,7 +4852,7 @@ const authCommand = {
4436
4852
  case "status": {
4437
4853
  const state = await app.inspectAuth();
4438
4854
  printAuthState(state);
4439
- return state.storedSessionAvailable ? 0 : 1;
4855
+ return state.apiKeyAvailable || state.storedSessionAvailable || state.supabaseAvailable ? 0 : 1;
4440
4856
  }
4441
4857
  case "use": {
4442
4858
  const mode = resolveAuthMode(value);
@@ -4454,11 +4870,13 @@ const authCommand = {
4454
4870
  };
4455
4871
  function resolveAuthMode(value) {
4456
4872
  switch (value) {
4873
+ case "api":
4874
+ case "api-key": return "api-key";
4457
4875
  case "stored":
4458
4876
  case "stored-session": return "stored-session";
4459
4877
  case "supabase":
4460
4878
  case "supabase-file": return "supabase-file";
4461
- 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");
4462
4880
  }
4463
4881
  }
4464
4882
  //#endregion
@@ -4902,7 +5320,12 @@ function renderAppState() {
4902
5320
  }
4903
5321
 
4904
5322
  const appState = state.appState;
4905
- 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";
4906
5329
  const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
4907
5330
  const cache = appState.cache.loaded
4908
5331
  ? appState.cache.transcriptCount + " transcript sets"
@@ -5022,7 +5445,12 @@ function renderAuthPanel() {
5022
5445
  return;
5023
5446
  }
5024
5447
 
5025
- 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";
5026
5454
  const lastError = auth.lastError
5027
5455
  ? '<div class="auth-card__meta auth-card__error">' + escapeHtml(auth.lastError) + "</div>"
5028
5456
  : "";
@@ -5031,6 +5459,7 @@ function renderAuthPanel() {
5031
5459
  '<div class="auth-card">',
5032
5460
  '<div class="status-grid">',
5033
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>",
5034
5463
  '<div><span class="status-label">Stored</span><strong>' + escapeHtml(auth.storedSessionAvailable ? "available" : "missing") + "</strong></div>",
5035
5464
  '<div><span class="status-label">supabase.json</span><strong>' + escapeHtml(auth.supabaseAvailable ? "available" : "missing") + "</strong></div>",
5036
5465
  '<div><span class="status-label">Refresh</span><strong>' + escapeHtml(auth.refreshAvailable ? "available" : "missing") + "</strong></div>",
@@ -5045,12 +5474,16 @@ function renderAuthPanel() {
5045
5474
  ? '<div class="auth-card__meta">supabase path: ' + escapeHtml(auth.supabasePath) + "</div>"
5046
5475
  : "",
5047
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>',
5048
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),
5049
5481
  authActionButton("Import desktop session", "login", !auth.supabaseAvailable),
5050
5482
  authActionButton("Refresh stored session", "refresh", !auth.storedSessionAvailable || !auth.refreshAvailable),
5483
+ authModeButton("Use API key", "api-key", !auth.apiKeyAvailable || auth.mode === "api-key"),
5051
5484
  authModeButton("Use stored session", "stored-session", !auth.storedSessionAvailable || auth.mode === "stored-session"),
5052
5485
  authModeButton("Use supabase.json", "supabase-file", !auth.supabaseAvailable || auth.mode === "supabase-file"),
5053
- authActionButton("Sign out", "logout", !auth.storedSessionAvailable),
5486
+ authActionButton("Sign out", "logout", !auth.apiKeyAvailable && !auth.storedSessionAvailable),
5054
5487
  "</div>",
5055
5488
  "</div>",
5056
5489
  ].join("");
@@ -5440,14 +5873,19 @@ async function rerunJob(id) {
5440
5873
  }
5441
5874
  }
5442
5875
 
5443
- async function loginAuth() {
5444
- setStatus("Importing desktop session…", "busy");
5876
+ async function loginAuth(apiKey) {
5877
+ setStatus(apiKey ? "Saving API key…" : "Importing desktop session…", "busy");
5445
5878
  try {
5446
- 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
+ });
5447
5885
  await refreshAll();
5448
5886
  } catch (error) {
5449
5887
  await syncAuthState();
5450
- setStatus("Auth import failed", "error");
5888
+ setStatus(apiKey ? "API key save failed" : "Auth import failed", "error");
5451
5889
  state.detailError = error instanceof Error ? error.message : String(error);
5452
5890
  renderMeetingDetail();
5453
5891
  }
@@ -5594,6 +6032,17 @@ els.authPanel.addEventListener("click", (event) => {
5594
6032
  const actionButton = event.target.closest("[data-auth-action]");
5595
6033
  if (actionButton) {
5596
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
+ }
5597
6046
  case "login":
5598
6047
  void loginAuth();
5599
6048
  return;
@@ -6461,9 +6910,10 @@ function parseMeetingSort(value) {
6461
6910
  }
6462
6911
  function parseAuthMode(value) {
6463
6912
  switch (value) {
6913
+ case "api-key":
6464
6914
  case "stored-session":
6465
6915
  case "supabase-file": return value;
6466
- 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");
6467
6917
  }
6468
6918
  }
6469
6919
  function folderIdFromBody(value) {
@@ -6603,6 +7053,7 @@ async function startGranolaServer(app, options = {}) {
6603
7053
  exportJobs: true,
6604
7054
  meetingIndex: true,
6605
7055
  sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind,
7056
+ syncEvents: true,
6606
7057
  syncState: true
6607
7058
  },
6608
7059
  product: "granola-toolkit",
@@ -6707,6 +7158,10 @@ async function startGranolaServer(app, options = {}) {
6707
7158
  }), { headers: originHeaders });
6708
7159
  return;
6709
7160
  }
7161
+ if (method === "GET" && path === granolaTransportPaths.syncEvents) {
7162
+ sendJson(response, await app.listSyncEvents({ limit: parseInteger(url.searchParams.get("limit")) ?? 20 }), { headers: originHeaders });
7163
+ return;
7164
+ }
6710
7165
  if (method === "POST" && path === granolaTransportPaths.authLock) {
6711
7166
  sendJson(response, { ok: true }, { headers: {
6712
7167
  ...originHeaders,
@@ -6805,8 +7260,12 @@ async function startGranolaServer(app, options = {}) {
6805
7260
  }
6806
7261
  if (method === "POST" && path === granolaTransportPaths.authLogin) {
6807
7262
  const body = await readJsonBody(request);
7263
+ const apiKey = typeof body.apiKey === "string" && body.apiKey.trim() ? body.apiKey.trim() : void 0;
6808
7264
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
6809
- sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
7265
+ sendJson(response, await app.loginAuth({
7266
+ apiKey,
7267
+ supabasePath
7268
+ }), { headers: originHeaders });
6810
7269
  return;
6811
7270
  }
6812
7271
  if (method === "POST" && path === granolaTransportPaths.authLogout) {
@@ -6991,6 +7450,7 @@ function printWebRoutes() {
6991
7450
  console.log(" POST /exports/notes");
6992
7451
  console.log(" POST /exports/jobs/:id/rerun");
6993
7452
  console.log(" POST /exports/transcripts");
7453
+ console.log(" GET /sync/events");
6994
7454
  console.log(" POST /sync");
6995
7455
  }
6996
7456
  async function runGranolaWebWorkspace(app, options) {
@@ -7443,6 +7903,7 @@ const serveCommand = {
7443
7903
  console.log(" POST /exports/notes");
7444
7904
  console.log(" POST /exports/jobs/:id/rerun");
7445
7905
  console.log(" POST /exports/transcripts");
7906
+ console.log(" GET /sync/events");
7446
7907
  console.log(" POST /sync");
7447
7908
  console.log(`Attach: granola attach ${server.url.href}`);
7448
7909
  if (password) console.log("Attach password: add --password <value>");
@@ -7460,10 +7921,12 @@ function syncHelp() {
7460
7921
 
7461
7922
  Usage:
7462
7923
  granola sync [options]
7924
+ granola sync events [options]
7463
7925
 
7464
7926
  Options:
7465
7927
  --watch Keep syncing in the background until interrupted
7466
7928
  --interval <value> Poll interval for --watch, e.g. 60s or 5m (default: 60s)
7929
+ --limit <value> Event count for sync events output (default: 20)
7467
7930
  --cache <path> Path to Granola cache JSON
7468
7931
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
7469
7932
  --supabase <path> Path to supabase.json
@@ -7489,12 +7952,13 @@ const syncCommand = {
7489
7952
  cache: { type: "string" },
7490
7953
  help: { type: "boolean" },
7491
7954
  interval: { type: "string" },
7955
+ limit: { type: "string" },
7492
7956
  timeout: { type: "string" },
7493
7957
  watch: { type: "boolean" }
7494
7958
  },
7495
7959
  help: syncHelp,
7496
7960
  name: "sync",
7497
- async run({ commandFlags, globalFlags }) {
7961
+ async run({ commandArgs, commandFlags, globalFlags }) {
7498
7962
  const config = await loadConfig({
7499
7963
  globalFlags,
7500
7964
  subcommandFlags: commandFlags
@@ -7505,6 +7969,16 @@ const syncCommand = {
7505
7969
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
7506
7970
  const app = await createGranolaApp(config);
7507
7971
  debug(config.debug, "authMode", app.getState().auth.mode);
7972
+ if (commandArgs[0] === "events") {
7973
+ const limit = typeof commandFlags.limit === "string" && /^\d+$/.test(commandFlags.limit) ? Number(commandFlags.limit) : 20;
7974
+ const result = await app.listSyncEvents({ limit });
7975
+ if (result.events.length === 0) {
7976
+ console.log("No sync events yet.");
7977
+ return 0;
7978
+ }
7979
+ for (const event of result.events) console.log(`${event.occurredAt} ${event.kind.padEnd(18)} ${event.title} (${event.meetingId})`);
7980
+ return 0;
7981
+ }
7508
7982
  const result = await app.sync();
7509
7983
  printSyncResult(result);
7510
7984
  if (result.state.lastCompletedAt) debug(config.debug, "syncCompletedAt", result.state.lastCompletedAt);
@@ -7810,6 +8284,7 @@ Commands:
7810
8284
  ${commands.map((command) => ` ${command.name.padEnd(commandWidth)} ${command.description}`).join("\n")}
7811
8285
 
7812
8286
  Global options:
8287
+ --api-key <token> Granola Personal API key
7813
8288
  --config <path> Path to .granola.toml
7814
8289
  --debug Enable debug logging
7815
8290
  --supabase <path> Path to supabase.json
@@ -7827,6 +8302,7 @@ async function runCli(argv) {
7827
8302
  try {
7828
8303
  const { command, rest } = splitCommand(argv);
7829
8304
  const global = parseFlags(rest, {
8305
+ "api-key": { type: "string" },
7830
8306
  config: { type: "string" },
7831
8307
  debug: { type: "boolean" },
7832
8308
  help: { type: "boolean" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.36.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",