granola-toolkit 0.27.0 → 0.29.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 +10 -0
  2. package/dist/cli.js +754 -411
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,7 @@ granola meeting view 1234abcd
100
100
  granola meeting notes 1234abcd
101
101
  granola meeting transcript 1234abcd --format json
102
102
  granola meeting export 1234abcd --format yaml
103
+ granola meeting open 1234abcd
103
104
  granola tui
104
105
  granola tui --meeting 1234abcd
105
106
  ```
@@ -116,6 +117,7 @@ granola attach http://127.0.0.1:4096 --meeting 1234abcd
116
117
  granola attach http://127.0.0.1:4096 --password "change-me"
117
118
 
118
119
  granola web
120
+ granola web --meeting 1234abcd
119
121
  granola web --open=false --port 4096
120
122
  granola web --network lan --password "change-me" --trusted-origins "https://trusted.example"
121
123
  ```
@@ -191,6 +193,7 @@ The focused meeting subcommands are:
191
193
 
192
194
  - `meeting notes` for just the selected note output
193
195
  - `meeting transcript` for just the selected transcript output
196
+ - `meeting open` to start the web workspace focused on one meeting
194
197
 
195
198
  The machine-readable `export` command includes:
196
199
 
@@ -237,6 +240,11 @@ Server hardening now includes:
237
240
 
238
241
  `web` starts the same local server as `serve`, enables the browser client at `/`, and opens that workspace in your default browser unless you pass `--open=false`.
239
242
 
243
+ You can deep-link into a specific meeting with either:
244
+
245
+ - `granola web --meeting <id>`
246
+ - `granola meeting open <id>`
247
+
240
248
  The initial browser client includes:
241
249
 
242
250
  - a searchable meeting list
@@ -272,6 +280,7 @@ The initial terminal workspace includes:
272
280
 
273
281
  - a meeting list pane with keyboard navigation
274
282
  - a detail pane with notes, transcript, metadata, and raw views
283
+ - an auth session overlay for import, refresh, source switching, and sign-out
275
284
  - a footer with app state and key hints
276
285
  - a quick-open overlay for jumping by title, id, or tag
277
286
 
@@ -279,6 +288,7 @@ The main keyboard controls are:
279
288
 
280
289
  - `j` / `k` or arrow keys to move between meetings
281
290
  - `/` or `Ctrl+P` to open quick open
291
+ - `a` to open auth session actions
282
292
  - `1`-`4` to switch detail tabs
283
293
  - `PageUp` / `PageDown` to scroll the detail pane
284
294
  - `r` to refresh from live Granola data
package/dist/cli.js CHANGED
@@ -1305,6 +1305,151 @@ const granolaTuiTheme = {
1305
1305
  }
1306
1306
  };
1307
1307
  //#endregion
1308
+ //#region src/tui/auth.ts
1309
+ function padLine$2(text, width) {
1310
+ const clipped = truncateToWidth(text, width, "");
1311
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
1312
+ }
1313
+ function frameLine$1(text, width) {
1314
+ return `| ${padLine$2(text, Math.max(1, width - 4))} |`;
1315
+ }
1316
+ function actionDisabledReason(auth, actionId) {
1317
+ switch (actionId) {
1318
+ case "login": return auth.supabaseAvailable ? "" : "supabase.json unavailable";
1319
+ case "refresh":
1320
+ if (!auth.storedSessionAvailable) return "stored session missing";
1321
+ return auth.refreshAvailable ? "" : "refresh unavailable";
1322
+ case "use-stored":
1323
+ if (!auth.storedSessionAvailable) return "stored session missing";
1324
+ return auth.mode === "stored-session" ? "already active" : "";
1325
+ case "use-supabase":
1326
+ if (!auth.supabaseAvailable) return "supabase.json unavailable";
1327
+ return auth.mode === "supabase-file" ? "already active" : "";
1328
+ case "logout": return auth.storedSessionAvailable ? "" : "stored session missing";
1329
+ }
1330
+ }
1331
+ function buildGranolaTuiAuthActions(auth) {
1332
+ return [
1333
+ {
1334
+ description: "Import the Granola desktop session from supabase.json",
1335
+ id: "login",
1336
+ key: "1",
1337
+ label: "Import desktop session"
1338
+ },
1339
+ {
1340
+ description: "Refresh the stored Granola session",
1341
+ id: "refresh",
1342
+ key: "2",
1343
+ label: "Refresh stored session"
1344
+ },
1345
+ {
1346
+ description: "Switch the active auth source to the stored session",
1347
+ id: "use-stored",
1348
+ key: "3",
1349
+ label: "Use stored session"
1350
+ },
1351
+ {
1352
+ description: "Switch the active auth source to supabase.json",
1353
+ id: "use-supabase",
1354
+ key: "4",
1355
+ label: "Use supabase.json"
1356
+ },
1357
+ {
1358
+ description: "Delete the stored session and fall back to supabase.json",
1359
+ id: "logout",
1360
+ key: "5",
1361
+ label: "Sign out"
1362
+ }
1363
+ ].map((action) => {
1364
+ const disabledReason = actionDisabledReason(auth, action.id);
1365
+ return {
1366
+ ...action,
1367
+ disabled: disabledReason.length > 0,
1368
+ disabledReason: disabledReason || void 0
1369
+ };
1370
+ });
1371
+ }
1372
+ function renderGranolaTuiAuthState(auth) {
1373
+ const lines = [
1374
+ `Active source: ${auth.mode === "stored-session" ? "Stored session" : "supabase.json"}`,
1375
+ `Stored session: ${auth.storedSessionAvailable ? "available" : "missing"}`,
1376
+ `supabase.json: ${auth.supabaseAvailable ? "available" : "missing"}`,
1377
+ `Refresh: ${auth.refreshAvailable ? "available" : "missing"}`
1378
+ ];
1379
+ if (auth.clientId) lines.push(`Client ID: ${auth.clientId}`);
1380
+ if (auth.signInMethod) lines.push(`Sign-in method: ${auth.signInMethod}`);
1381
+ if (auth.supabasePath) lines.push(`supabase path: ${auth.supabasePath}`);
1382
+ if (auth.lastError) lines.push(`Last error: ${auth.lastError}`);
1383
+ return lines.join("\n");
1384
+ }
1385
+ function nextEnabledIndex(actions, startIndex, delta) {
1386
+ if (actions.length === 0) return -1;
1387
+ for (let attempts = 0; attempts < actions.length; attempts += 1) {
1388
+ const nextIndex = (startIndex + delta * (attempts + 1) + actions.length) % actions.length;
1389
+ if (!actions[nextIndex]?.disabled) return nextIndex;
1390
+ }
1391
+ return Math.max(0, Math.min(actions.length - 1, startIndex));
1392
+ }
1393
+ var GranolaTuiAuthOverlay = class {
1394
+ focused = false;
1395
+ #actions;
1396
+ #selectedIndex;
1397
+ constructor(options) {
1398
+ this.options = options;
1399
+ this.#actions = buildGranolaTuiAuthActions(this.options.auth);
1400
+ const firstEnabledIndex = this.#actions.findIndex((action) => !action.disabled);
1401
+ this.#selectedIndex = firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
1402
+ }
1403
+ invalidate() {}
1404
+ async runAction(actionId) {
1405
+ const action = this.#actions.find((candidate) => candidate.id === actionId);
1406
+ if (!action || action.disabled) return;
1407
+ await this.options.onRun(action.id);
1408
+ }
1409
+ handleInput(data) {
1410
+ if (matchesKey(data, "esc")) {
1411
+ this.options.onCancel();
1412
+ return;
1413
+ }
1414
+ if (matchesKey(data, "up")) {
1415
+ this.#selectedIndex = nextEnabledIndex(this.#actions, this.#selectedIndex, -1);
1416
+ return;
1417
+ }
1418
+ if (matchesKey(data, "down")) {
1419
+ this.#selectedIndex = nextEnabledIndex(this.#actions, this.#selectedIndex, 1);
1420
+ return;
1421
+ }
1422
+ const selected = this.#actions[this.#selectedIndex];
1423
+ if (matchesKey(data, "enter")) {
1424
+ if (selected && !selected.disabled) this.runAction(selected.id);
1425
+ return;
1426
+ }
1427
+ const hotkeyAction = this.#actions.find((action) => action.key === data);
1428
+ if (hotkeyAction && !hotkeyAction.disabled) this.runAction(hotkeyAction.id);
1429
+ }
1430
+ render(width) {
1431
+ const lines = [];
1432
+ const bodyWidth = Math.max(48, width);
1433
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1434
+ lines.push(frameLine$1(granolaTuiTheme.strong("Auth Session"), bodyWidth));
1435
+ lines.push(frameLine$1("", bodyWidth));
1436
+ for (const detailLine of renderGranolaTuiAuthState(this.options.auth).split("\n")) lines.push(frameLine$1(detailLine, bodyWidth));
1437
+ lines.push(frameLine$1("", bodyWidth));
1438
+ for (const action of this.#actions) {
1439
+ const selected = this.#actions[this.#selectedIndex]?.id === action.id;
1440
+ const label = `${action.key}. ${action.label}`;
1441
+ const titleLine = action.disabled ? granolaTuiTheme.dim(label) : selected ? granolaTuiTheme.selected(label) : label;
1442
+ lines.push(frameLine$1(titleLine, bodyWidth));
1443
+ const detail = action.disabled ? granolaTuiTheme.warning(action.disabledReason ?? "Unavailable") : granolaTuiTheme.dim(action.description);
1444
+ for (const wrapped of wrapTextWithAnsi(detail, Math.max(1, bodyWidth - 6))) lines.push(frameLine$1(` ${wrapped}`, bodyWidth));
1445
+ }
1446
+ lines.push(frameLine$1("", bodyWidth));
1447
+ lines.push(frameLine$1(granolaTuiTheme.dim("Enter to run, Esc to cancel, arrows to move"), bodyWidth));
1448
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1449
+ return lines;
1450
+ }
1451
+ };
1452
+ //#endregion
1308
1453
  //#region src/tui/palette.ts
1309
1454
  function padLine$1(text, width) {
1310
1455
  const clipped = truncateToWidth(text, width, "");
@@ -1597,6 +1742,85 @@ var GranolaTuiWorkspace = class {
1597
1742
  this.#detailScroll = 0;
1598
1743
  this.tui.requestRender();
1599
1744
  }
1745
+ async reloadAfterAuthChange() {
1746
+ const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
1747
+ try {
1748
+ await this.loadMeetings({
1749
+ forceRefresh: true,
1750
+ preferredMeetingId,
1751
+ setStatus: false
1752
+ });
1753
+ if (this.#selectedMeetingId) {
1754
+ await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1755
+ return;
1756
+ }
1757
+ this.#selectedMeeting = void 0;
1758
+ this.#detailError = "";
1759
+ this.#detailScroll = 0;
1760
+ this.tui.requestRender();
1761
+ } catch {}
1762
+ }
1763
+ async runAuthAction(actionId) {
1764
+ let successMessage = "";
1765
+ try {
1766
+ switch (actionId) {
1767
+ case "login":
1768
+ this.setStatus("Importing desktop session…");
1769
+ await this.app.loginAuth();
1770
+ successMessage = "Stored session imported";
1771
+ break;
1772
+ case "refresh":
1773
+ this.setStatus("Refreshing stored session…");
1774
+ await this.app.refreshAuth();
1775
+ successMessage = "Stored session refreshed";
1776
+ break;
1777
+ case "use-stored":
1778
+ this.setStatus("Switching to stored session…");
1779
+ await this.app.switchAuthMode("stored-session");
1780
+ successMessage = "Using stored session";
1781
+ break;
1782
+ case "use-supabase":
1783
+ this.setStatus("Switching to supabase.json…");
1784
+ await this.app.switchAuthMode("supabase-file");
1785
+ successMessage = "Using supabase.json";
1786
+ break;
1787
+ case "logout":
1788
+ this.setStatus("Signing out…");
1789
+ await this.app.logoutAuth();
1790
+ successMessage = "Stored session removed";
1791
+ break;
1792
+ }
1793
+ await this.reloadAfterAuthChange();
1794
+ this.setStatus(successMessage);
1795
+ } catch (error) {
1796
+ const message = error instanceof Error ? error.message : String(error);
1797
+ this.setStatus(message, "error");
1798
+ }
1799
+ }
1800
+ openAuthPanel(auth = this.#appState.auth) {
1801
+ if (this.#overlay) return;
1802
+ const closeOverlay = () => {
1803
+ this.#overlay?.hide();
1804
+ this.#overlay = void 0;
1805
+ this.tui.setFocus(this);
1806
+ this.tui.requestRender();
1807
+ };
1808
+ const overlay = new GranolaTuiAuthOverlay({
1809
+ auth,
1810
+ onCancel: closeOverlay,
1811
+ onRun: async (actionId) => {
1812
+ closeOverlay();
1813
+ await this.runAuthAction(actionId);
1814
+ }
1815
+ });
1816
+ this.#overlay = this.tui.showOverlay(overlay, {
1817
+ anchor: "center",
1818
+ maxHeight: "70%",
1819
+ minWidth: 52,
1820
+ width: "72%"
1821
+ });
1822
+ this.setStatus("Auth session");
1823
+ }
1600
1824
  openQuickOpen() {
1601
1825
  if (this.#overlay) return;
1602
1826
  const closeOverlay = () => {
@@ -1641,6 +1865,10 @@ var GranolaTuiWorkspace = class {
1641
1865
  this.openQuickOpen();
1642
1866
  return;
1643
1867
  }
1868
+ if (matchesKey(data, "a")) {
1869
+ this.openAuthPanel();
1870
+ return;
1871
+ }
1644
1872
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
1645
1873
  this.moveSelection(-1);
1646
1874
  return;
@@ -1779,7 +2007,7 @@ var GranolaTuiWorkspace = class {
1779
2007
  const bodyLines = [];
1780
2008
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
1781
2009
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
1782
- const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2010
+ const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
1783
2011
  return [
1784
2012
  headerTitle,
1785
2013
  headerSummary,
@@ -3452,337 +3680,138 @@ async function rerun(id, commandFlags, globalFlags) {
3452
3680
  return 0;
3453
3681
  }
3454
3682
  //#endregion
3455
- //#region src/commands/meeting.ts
3456
- function meetingHelp() {
3457
- return `Granola meeting
3683
+ //#region src/browser.ts
3684
+ const execFileAsync = promisify(execFile);
3685
+ function getBrowserOpenCommand(url, platform = process.platform) {
3686
+ const href = String(url);
3687
+ switch (platform) {
3688
+ case "darwin": return {
3689
+ args: [href],
3690
+ file: "open"
3691
+ };
3692
+ case "win32": return {
3693
+ args: [
3694
+ "/c",
3695
+ "start",
3696
+ "",
3697
+ href
3698
+ ],
3699
+ file: "cmd"
3700
+ };
3701
+ default: return {
3702
+ args: [href],
3703
+ file: "xdg-open"
3704
+ };
3705
+ }
3706
+ }
3707
+ async function openExternalUrl(url, options = {}) {
3708
+ const command = getBrowserOpenCommand(url, options.platform);
3709
+ await (options.run ?? (async (file, args) => {
3710
+ await execFileAsync(file, args);
3711
+ }))(command.file, command.args);
3712
+ }
3713
+ //#endregion
3714
+ //#region src/web/client-script.ts
3715
+ const granolaWebClientScript = String.raw`
3716
+ const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
3717
+ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
3458
3718
 
3459
- Usage:
3460
- granola meeting <list|view|export|notes|transcript> [options]
3719
+ const state = {
3720
+ appState: null,
3721
+ detailError: "",
3722
+ listError: "",
3723
+ meetings: [],
3724
+ quickOpen: "",
3725
+ search: "",
3726
+ selectedMeeting: null,
3727
+ selectedMeetingBundle: null,
3728
+ selectedMeetingId: null,
3729
+ meetingSource: "live",
3730
+ serverLocked: Boolean(serverConfig.passwordRequired),
3731
+ sort: "updated-desc",
3732
+ updatedFrom: "",
3733
+ updatedTo: "",
3734
+ workspaceTab: "notes",
3735
+ };
3461
3736
 
3462
- Subcommands:
3463
- list List meetings from the Granola API
3464
- view <id> Show a single meeting with notes and transcript text
3465
- export <id> Export a single meeting as JSON or YAML
3466
- notes <id> Show a single meeting's notes
3467
- transcript <id> Show a single meeting's transcript
3737
+ const els = {
3738
+ appState: document.querySelector("[data-app-state]"),
3739
+ authPanel: document.querySelector("[data-auth-panel]"),
3740
+ detailBody: document.querySelector("[data-detail-body]"),
3741
+ detailMeta: document.querySelector("[data-detail-meta]"),
3742
+ empty: document.querySelector("[data-empty]"),
3743
+ jobsList: document.querySelector("[data-jobs-list]"),
3744
+ list: document.querySelector("[data-meeting-list]"),
3745
+ noteButton: document.querySelector("[data-export-notes]"),
3746
+ quickOpen: document.querySelector("[data-quick-open]"),
3747
+ quickOpenButton: document.querySelector("[data-quick-open-button]"),
3748
+ refreshButton: document.querySelector("[data-refresh]"),
3749
+ search: document.querySelector("[data-search]"),
3750
+ securityPanel: document.querySelector("[data-security-panel]"),
3751
+ serverPassword: document.querySelector("[data-server-password]"),
3752
+ lockServerButton: document.querySelector("[data-lock-server]"),
3753
+ sort: document.querySelector("[data-sort]"),
3754
+ stateBadge: document.querySelector("[data-state-badge]"),
3755
+ transcriptButton: document.querySelector("[data-export-transcripts]"),
3756
+ unlockServerButton: document.querySelector("[data-unlock-server]"),
3757
+ updatedFrom: document.querySelector("[data-updated-from]"),
3758
+ updatedTo: document.querySelector("[data-updated-to]"),
3759
+ workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
3760
+ };
3468
3761
 
3469
- Options:
3470
- --cache <path> Path to Granola cache JSON for transcript data
3471
- --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
3472
- --limit <n> Number of meetings for list (default: 20)
3473
- --search <query> Filter list by title, id, or tag
3474
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
3475
- --supabase <path> Path to supabase.json
3476
- --debug Enable debug logging
3477
- --config <path> Path to .granola.toml
3478
- -h, --help Show help
3479
- `;
3762
+ function parseWorkspaceTab(value) {
3763
+ return workspaceTabs.includes(value) ? value : "notes";
3480
3764
  }
3481
- function resolveListFormat(value) {
3482
- switch (value) {
3483
- case void 0: return "text";
3484
- case "json":
3485
- case "text":
3486
- case "yaml": return value;
3487
- default: throw new Error("invalid meeting format: expected text, json, or yaml");
3488
- }
3489
- }
3490
- function resolveViewFormat(value) {
3491
- switch (value) {
3492
- case void 0: return "text";
3493
- case "json":
3494
- case "text":
3495
- case "yaml": return value;
3496
- default: throw new Error("invalid meeting format: expected text, json, or yaml");
3497
- }
3765
+
3766
+ function startupSelection() {
3767
+ const params = new URLSearchParams(window.location.search);
3768
+ return {
3769
+ meetingId: params.get("meeting")?.trim() || "",
3770
+ workspaceTab: parseWorkspaceTab(params.get("tab")),
3771
+ };
3498
3772
  }
3499
- function resolveExportFormat(value) {
3500
- switch (value) {
3501
- case void 0: return "json";
3502
- case "json":
3503
- case "yaml": return value;
3504
- default: throw new Error("invalid meeting export format: expected json or yaml");
3505
- }
3773
+
3774
+ function syncBrowserUrl() {
3775
+ const url = new URL(window.location.href);
3776
+
3777
+ if (state.selectedMeetingId) {
3778
+ url.searchParams.set("meeting", state.selectedMeetingId);
3779
+ } else {
3780
+ url.searchParams.delete("meeting");
3781
+ }
3782
+
3783
+ if (state.workspaceTab !== "notes") {
3784
+ url.searchParams.set("tab", state.workspaceTab);
3785
+ } else {
3786
+ url.searchParams.delete("tab");
3787
+ }
3788
+
3789
+ const nextPath = url.pathname + url.search + url.hash;
3790
+ const currentPath = window.location.pathname + window.location.search + window.location.hash;
3791
+ if (nextPath !== currentPath) {
3792
+ history.replaceState(null, "", nextPath);
3793
+ }
3506
3794
  }
3507
- function resolveNotesFormat(value) {
3508
- switch (value) {
3509
- case void 0: return "markdown";
3510
- case "json":
3511
- case "markdown":
3512
- case "raw":
3513
- case "yaml": return value;
3514
- default: throw new Error("invalid meeting notes format: expected markdown, json, yaml, or raw");
3515
- }
3795
+
3796
+ function escapeHtml(value) {
3797
+ return value
3798
+ .replaceAll("&", "&amp;")
3799
+ .replaceAll("<", "&lt;")
3800
+ .replaceAll(">", "&gt;")
3801
+ .replaceAll('"', "&quot;");
3516
3802
  }
3517
- function resolveTranscriptFormat$1(value) {
3518
- switch (value) {
3519
- case void 0: return "text";
3520
- case "json":
3521
- case "raw":
3522
- case "text":
3523
- case "yaml": return value;
3524
- default: throw new Error("invalid meeting transcript format: expected text, json, yaml, or raw");
3525
- }
3803
+
3804
+ function setStatus(label, tone = "idle") {
3805
+ els.stateBadge.textContent = label;
3806
+ els.stateBadge.dataset.tone = tone;
3526
3807
  }
3527
- function parseLimit(value) {
3528
- if (value === void 0) return 20;
3529
- if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
3530
- const limit = Number(value);
3531
- if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
3532
- return limit;
3533
- }
3534
- const meetingCommand = {
3535
- description: "Inspect and export individual Granola meetings",
3536
- flags: {
3537
- cache: { type: "string" },
3538
- format: { type: "string" },
3539
- help: { type: "boolean" },
3540
- limit: { type: "string" },
3541
- search: { type: "string" },
3542
- timeout: { type: "string" }
3543
- },
3544
- help: meetingHelp,
3545
- name: "meeting",
3546
- async run({ commandArgs, commandFlags, globalFlags }) {
3547
- const [action, id] = commandArgs;
3548
- switch (action) {
3549
- case "list": return await list(commandFlags, globalFlags);
3550
- case "view":
3551
- if (!id) throw new Error("meeting view requires an id");
3552
- return await view(id, commandFlags, globalFlags);
3553
- case "export":
3554
- if (!id) throw new Error("meeting export requires an id");
3555
- return await exportMeeting(id, commandFlags, globalFlags);
3556
- case "notes":
3557
- if (!id) throw new Error("meeting notes requires an id");
3558
- return await notes(id, commandFlags, globalFlags);
3559
- case "transcript":
3560
- if (!id) throw new Error("meeting transcript requires an id");
3561
- return await transcript(id, commandFlags, globalFlags);
3562
- case void 0:
3563
- console.log(meetingHelp());
3564
- return 1;
3565
- default: throw new Error("invalid meeting command: expected list, view, export, notes, or transcript");
3566
- }
3567
- }
3568
- };
3569
- async function list(commandFlags, globalFlags) {
3570
- const format = resolveListFormat(commandFlags.format);
3571
- const limit = parseLimit(commandFlags.limit);
3572
- const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
3573
- const config = await loadConfig({
3574
- globalFlags,
3575
- subcommandFlags: commandFlags
3576
- });
3577
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3578
- debug(config.debug, "supabase", config.supabase);
3579
- debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3580
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3581
- const app = await createGranolaApp(config);
3582
- debug(config.debug, "authMode", app.getState().auth.mode);
3583
- console.log("Loading meetings...");
3584
- const result = await app.listMeetings({
3585
- limit,
3586
- search
3587
- });
3588
- console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
3589
- console.log(renderMeetingList(result.meetings, format).trimEnd());
3590
- return 0;
3591
- }
3592
- async function view(id, commandFlags, globalFlags) {
3593
- const format = resolveViewFormat(commandFlags.format);
3594
- const config = await loadConfig({
3595
- globalFlags,
3596
- subcommandFlags: commandFlags
3597
- });
3598
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3599
- debug(config.debug, "supabase", config.supabase);
3600
- debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3601
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3602
- const app = await createGranolaApp(config);
3603
- debug(config.debug, "authMode", app.getState().auth.mode);
3604
- console.log("Fetching meeting from Granola API...");
3605
- const result = await app.getMeeting(id);
3606
- console.log(renderMeetingView(result.meeting, format).trimEnd());
3607
- return 0;
3608
- }
3609
- async function exportMeeting(id, commandFlags, globalFlags) {
3610
- const format = resolveExportFormat(commandFlags.format);
3611
- const config = await loadConfig({
3612
- globalFlags,
3613
- subcommandFlags: commandFlags
3614
- });
3615
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3616
- debug(config.debug, "supabase", config.supabase);
3617
- debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3618
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3619
- const app = await createGranolaApp(config);
3620
- debug(config.debug, "authMode", app.getState().auth.mode);
3621
- console.log("Fetching meeting from Granola API...");
3622
- const result = await app.getMeeting(id);
3623
- console.log(renderMeetingExport(result.meeting, format).trimEnd());
3624
- return 0;
3625
- }
3626
- async function notes(id, commandFlags, globalFlags) {
3627
- const format = resolveNotesFormat(commandFlags.format);
3628
- const config = await loadConfig({
3629
- globalFlags,
3630
- subcommandFlags: commandFlags
3631
- });
3632
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3633
- debug(config.debug, "supabase", config.supabase);
3634
- debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3635
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3636
- const app = await createGranolaApp(config);
3637
- debug(config.debug, "authMode", app.getState().auth.mode);
3638
- console.log("Fetching meeting from Granola API...");
3639
- const result = await app.getMeeting(id);
3640
- console.log(renderMeetingNotes(result.document, format).trimEnd());
3641
- return 0;
3642
- }
3643
- async function transcript(id, commandFlags, globalFlags) {
3644
- const format = resolveTranscriptFormat$1(commandFlags.format);
3645
- const config = await loadConfig({
3646
- globalFlags,
3647
- subcommandFlags: commandFlags
3648
- });
3649
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3650
- debug(config.debug, "supabase", config.supabase);
3651
- debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3652
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3653
- const app = await createGranolaApp(config);
3654
- debug(config.debug, "authMode", app.getState().auth.mode);
3655
- console.log("Fetching meeting from Granola API...");
3656
- const result = await app.getMeeting(id, { requireCache: true });
3657
- const output = renderMeetingTranscript(result.document, result.cacheData, format);
3658
- if (!output.trim()) throw new Error(`no transcript found for meeting: ${result.document.id}`);
3659
- console.log(output.trimEnd());
3660
- return 0;
3661
- }
3662
- //#endregion
3663
- //#region src/commands/notes.ts
3664
- function notesHelp() {
3665
- return `Granola notes
3666
-
3667
- Usage:
3668
- granola notes [options]
3669
-
3670
- Options:
3671
- --format <value> Output format: markdown, json, yaml, raw (default: markdown)
3672
- --output <path> Output directory for note files (default: ./notes)
3673
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
3674
- --supabase <path> Path to supabase.json
3675
- --debug Enable debug logging
3676
- --config <path> Path to .granola.toml
3677
- -h, --help Show help
3678
- `;
3679
- }
3680
- const notesCommand = {
3681
- description: "Export Granola notes",
3682
- flags: {
3683
- format: { type: "string" },
3684
- help: { type: "boolean" },
3685
- output: { type: "string" },
3686
- timeout: { type: "string" }
3687
- },
3688
- help: notesHelp,
3689
- name: "notes",
3690
- async run({ commandFlags, globalFlags }) {
3691
- const config = await loadConfig({
3692
- globalFlags,
3693
- subcommandFlags: commandFlags
3694
- });
3695
- debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3696
- debug(config.debug, "supabase", config.supabase);
3697
- debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3698
- debug(config.debug, "output", config.notes.output);
3699
- const format = resolveNoteFormat(commandFlags.format);
3700
- debug(config.debug, "format", format);
3701
- const app = await createGranolaApp(config);
3702
- debug(config.debug, "authMode", app.getState().auth.mode);
3703
- const result = await app.exportNotes(format);
3704
- console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
3705
- debug(config.debug, "notes written", result.written);
3706
- return 0;
3707
- }
3708
- };
3709
- function resolveNoteFormat(value) {
3710
- switch (value) {
3711
- case void 0: return "markdown";
3712
- case "json":
3713
- case "markdown":
3714
- case "raw":
3715
- case "yaml": return value;
3716
- default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
3717
- }
3718
- }
3719
- //#endregion
3720
- //#region src/web/client-script.ts
3721
- const granolaWebClientScript = String.raw`
3722
- const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
3723
-
3724
- const state = {
3725
- appState: null,
3726
- detailError: "",
3727
- listError: "",
3728
- meetings: [],
3729
- quickOpen: "",
3730
- search: "",
3731
- selectedMeeting: null,
3732
- selectedMeetingBundle: null,
3733
- selectedMeetingId: null,
3734
- meetingSource: "live",
3735
- serverLocked: Boolean(serverConfig.passwordRequired),
3736
- sort: "updated-desc",
3737
- updatedFrom: "",
3738
- updatedTo: "",
3739
- workspaceTab: "notes",
3740
- };
3741
-
3742
- const els = {
3743
- appState: document.querySelector("[data-app-state]"),
3744
- authPanel: document.querySelector("[data-auth-panel]"),
3745
- detailBody: document.querySelector("[data-detail-body]"),
3746
- detailMeta: document.querySelector("[data-detail-meta]"),
3747
- empty: document.querySelector("[data-empty]"),
3748
- jobsList: document.querySelector("[data-jobs-list]"),
3749
- list: document.querySelector("[data-meeting-list]"),
3750
- noteButton: document.querySelector("[data-export-notes]"),
3751
- quickOpen: document.querySelector("[data-quick-open]"),
3752
- quickOpenButton: document.querySelector("[data-quick-open-button]"),
3753
- refreshButton: document.querySelector("[data-refresh]"),
3754
- search: document.querySelector("[data-search]"),
3755
- securityPanel: document.querySelector("[data-security-panel]"),
3756
- serverPassword: document.querySelector("[data-server-password]"),
3757
- lockServerButton: document.querySelector("[data-lock-server]"),
3758
- sort: document.querySelector("[data-sort]"),
3759
- stateBadge: document.querySelector("[data-state-badge]"),
3760
- transcriptButton: document.querySelector("[data-export-transcripts]"),
3761
- unlockServerButton: document.querySelector("[data-unlock-server]"),
3762
- updatedFrom: document.querySelector("[data-updated-from]"),
3763
- updatedTo: document.querySelector("[data-updated-to]"),
3764
- workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
3765
- };
3766
-
3767
- function escapeHtml(value) {
3768
- return value
3769
- .replaceAll("&", "&amp;")
3770
- .replaceAll("<", "&lt;")
3771
- .replaceAll(">", "&gt;")
3772
- .replaceAll('"', "&quot;");
3773
- }
3774
-
3775
- function setStatus(label, tone = "idle") {
3776
- els.stateBadge.textContent = label;
3777
- els.stateBadge.dataset.tone = tone;
3778
- }
3779
-
3780
- function syncFilterInputs() {
3781
- els.quickOpen.value = state.quickOpen;
3782
- els.search.value = state.search;
3783
- els.sort.value = state.sort;
3784
- els.updatedFrom.value = state.updatedFrom;
3785
- els.updatedTo.value = state.updatedTo;
3808
+
3809
+ function syncFilterInputs() {
3810
+ els.quickOpen.value = state.quickOpen;
3811
+ els.search.value = state.search;
3812
+ els.sort.value = state.sort;
3813
+ els.updatedFrom.value = state.updatedFrom;
3814
+ els.updatedTo.value = state.updatedTo;
3786
3815
  }
3787
3816
 
3788
3817
  function currentFilterSummary() {
@@ -3927,6 +3956,7 @@ function renderMeetingList() {
3927
3956
  state.selectedMeetingId = null;
3928
3957
  state.selectedMeeting = null;
3929
3958
  state.selectedMeetingBundle = null;
3959
+ syncBrowserUrl();
3930
3960
  const filterSummary = currentFilterSummary();
3931
3961
  const message = filterSummary
3932
3962
  ? "No meetings match " + filterSummary + "."
@@ -3940,6 +3970,7 @@ function renderMeetingList() {
3940
3970
  if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
3941
3971
  state.selectedMeetingId = state.meetings[0]?.id || null;
3942
3972
  }
3973
+ syncBrowserUrl();
3943
3974
 
3944
3975
  els.list.innerHTML = state.meetings
3945
3976
  .map((meeting) => {
@@ -4138,6 +4169,7 @@ async function loadMeetings(options = {}) {
4138
4169
 
4139
4170
  async function loadMeeting(id) {
4140
4171
  state.selectedMeetingId = id;
4172
+ syncBrowserUrl();
4141
4173
  renderMeetingList();
4142
4174
 
4143
4175
  try {
@@ -4496,6 +4528,7 @@ els.quickOpenButton.addEventListener("click", () => {
4496
4528
  els.workspaceTabs.forEach((button) => {
4497
4529
  button.addEventListener("click", () => {
4498
4530
  state.workspaceTab = button.dataset.workspaceTab || "notes";
4531
+ syncBrowserUrl();
4499
4532
  renderMeetingDetail();
4500
4533
  });
4501
4534
  });
@@ -4512,24 +4545,28 @@ document.addEventListener("keydown", (event) => {
4512
4545
  const tabs = ["notes", "transcript", "metadata", "raw"];
4513
4546
  if (event.key === "1") {
4514
4547
  state.workspaceTab = "notes";
4548
+ syncBrowserUrl();
4515
4549
  renderMeetingDetail();
4516
4550
  return;
4517
4551
  }
4518
4552
 
4519
4553
  if (event.key === "2") {
4520
4554
  state.workspaceTab = "transcript";
4555
+ syncBrowserUrl();
4521
4556
  renderMeetingDetail();
4522
4557
  return;
4523
4558
  }
4524
4559
 
4525
4560
  if (event.key === "3") {
4526
4561
  state.workspaceTab = "metadata";
4562
+ syncBrowserUrl();
4527
4563
  renderMeetingDetail();
4528
4564
  return;
4529
4565
  }
4530
4566
 
4531
4567
  if (event.key === "4") {
4532
4568
  state.workspaceTab = "raw";
4569
+ syncBrowserUrl();
4533
4570
  renderMeetingDetail();
4534
4571
  return;
4535
4572
  }
@@ -4537,15 +4574,21 @@ document.addEventListener("keydown", (event) => {
4537
4574
  const currentIndex = tabs.indexOf(state.workspaceTab);
4538
4575
  if (event.key === "]") {
4539
4576
  state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
4577
+ syncBrowserUrl();
4540
4578
  renderMeetingDetail();
4541
4579
  }
4542
4580
 
4543
4581
  if (event.key === "[") {
4544
4582
  state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
4583
+ syncBrowserUrl();
4545
4584
  renderMeetingDetail();
4546
4585
  }
4547
4586
  });
4548
4587
 
4588
+ const initialSelection = startupSelection();
4589
+ state.selectedMeetingId = initialSelection.meetingId || null;
4590
+ state.workspaceTab = initialSelection.workspaceTab;
4591
+
4549
4592
  const events = new EventSource("/events");
4550
4593
  events.addEventListener("state.updated", (event) => {
4551
4594
  const previousLoadedAt = state.appState?.documents?.loadedAt;
@@ -5562,14 +5605,386 @@ async function startGranolaServer(app, options = {}) {
5562
5605
  };
5563
5606
  }
5564
5607
  //#endregion
5565
- //#region src/commands/serve.ts
5566
- function serveHelp() {
5567
- return `Granola serve
5568
-
5569
- Usage:
5570
- granola serve [options]
5571
-
5572
- Options:
5608
+ //#region src/web-url.ts
5609
+ function buildGranolaMeetingUrl(baseUrl, meetingId) {
5610
+ const url = new URL(baseUrl);
5611
+ if (meetingId.trim()) url.searchParams.set("meeting", meetingId.trim());
5612
+ return url;
5613
+ }
5614
+ //#endregion
5615
+ //#region src/commands/web-shared.ts
5616
+ function resolveGranolaWebWorkspaceOptions(commandFlags) {
5617
+ const networkMode = parseNetworkMode(commandFlags.network);
5618
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
5619
+ const port = parsePort(commandFlags.port);
5620
+ return {
5621
+ hostname,
5622
+ networkMode,
5623
+ openBrowser: commandFlags.open !== false,
5624
+ password: typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0,
5625
+ port,
5626
+ trustedOrigins: parseTrustedOrigins(commandFlags["trusted-origins"])
5627
+ };
5628
+ }
5629
+ function printWebRoutes() {
5630
+ console.log("Routes:");
5631
+ console.log(" GET /");
5632
+ console.log(" GET /health");
5633
+ console.log(" POST /auth/unlock");
5634
+ console.log(" POST /auth/lock");
5635
+ console.log(" GET /auth/status");
5636
+ console.log(" GET /state");
5637
+ console.log(" GET /events");
5638
+ console.log(" GET /meetings");
5639
+ console.log(" GET /meetings/:id");
5640
+ console.log(" GET /exports/jobs");
5641
+ console.log(" POST /auth/login");
5642
+ console.log(" POST /auth/logout");
5643
+ console.log(" POST /auth/mode");
5644
+ console.log(" POST /auth/refresh");
5645
+ console.log(" POST /exports/notes");
5646
+ console.log(" POST /exports/jobs/:id/rerun");
5647
+ console.log(" POST /exports/transcripts");
5648
+ }
5649
+ async function runGranolaWebWorkspace(app, options) {
5650
+ const server = await startGranolaServer(app, {
5651
+ enableWebClient: true,
5652
+ hostname: options.hostname,
5653
+ port: options.port,
5654
+ security: {
5655
+ password: options.password,
5656
+ trustedOrigins: options.trustedOrigins
5657
+ }
5658
+ });
5659
+ const targetUrl = options.targetMeetingId ? buildGranolaMeetingUrl(server.url, options.targetMeetingId) : new URL(server.url);
5660
+ console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
5661
+ if (targetUrl.href !== server.url.href) console.log(`Focused meeting URL: ${targetUrl.href}`);
5662
+ console.log(`Network mode: ${options.networkMode}`);
5663
+ if (options.password) console.log("Server password protection: enabled");
5664
+ else if (options.networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
5665
+ if (options.trustedOrigins.length > 0) console.log(`Trusted origins: ${options.trustedOrigins.join(", ")}`);
5666
+ printWebRoutes();
5667
+ console.log(`Attach: granola attach ${server.url.href}`);
5668
+ if (options.password) console.log("Attach password: add --password <value>");
5669
+ if (options.openBrowser) try {
5670
+ await openExternalUrl(targetUrl);
5671
+ } catch (error) {
5672
+ const message = error instanceof Error ? error.message : String(error);
5673
+ console.error(`failed to open browser automatically: ${message}`);
5674
+ console.error(`open ${targetUrl.href} manually`);
5675
+ }
5676
+ await waitForShutdown(async () => await server.close());
5677
+ return 0;
5678
+ }
5679
+ //#endregion
5680
+ //#region src/commands/meeting.ts
5681
+ function meetingHelp() {
5682
+ return `Granola meeting
5683
+
5684
+ Usage:
5685
+ granola meeting <list|view|export|notes|transcript|open> [options]
5686
+
5687
+ Subcommands:
5688
+ list List meetings from the Granola API
5689
+ view <id> Show a single meeting with notes and transcript text
5690
+ export <id> Export a single meeting as JSON or YAML
5691
+ notes <id> Show a single meeting's notes
5692
+ transcript <id> Show a single meeting's transcript
5693
+ open <id> Start the web workspace focused on one meeting
5694
+
5695
+ Options:
5696
+ --cache <path> Path to Granola cache JSON for transcript data
5697
+ --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
5698
+ --network <mode> open: local or lan (default: local)
5699
+ --hostname <value> open: hostname to bind (overrides network default)
5700
+ --limit <n> Number of meetings for list (default: 20)
5701
+ --open[=true|false] open: launch the browser automatically (default: true)
5702
+ --password <value> open: optional server password
5703
+ --port <value> open: port to bind (default: 0 for any available port)
5704
+ --search <query> Filter list by title, id, or tag
5705
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5706
+ --trusted-origins <v> open: comma-separated extra browser origins to trust
5707
+ --supabase <path> Path to supabase.json
5708
+ --debug Enable debug logging
5709
+ --config <path> Path to .granola.toml
5710
+ -h, --help Show help
5711
+ `;
5712
+ }
5713
+ function resolveListFormat(value) {
5714
+ switch (value) {
5715
+ case void 0: return "text";
5716
+ case "json":
5717
+ case "text":
5718
+ case "yaml": return value;
5719
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
5720
+ }
5721
+ }
5722
+ function resolveViewFormat(value) {
5723
+ switch (value) {
5724
+ case void 0: return "text";
5725
+ case "json":
5726
+ case "text":
5727
+ case "yaml": return value;
5728
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
5729
+ }
5730
+ }
5731
+ function resolveExportFormat(value) {
5732
+ switch (value) {
5733
+ case void 0: return "json";
5734
+ case "json":
5735
+ case "yaml": return value;
5736
+ default: throw new Error("invalid meeting export format: expected json or yaml");
5737
+ }
5738
+ }
5739
+ function resolveNotesFormat(value) {
5740
+ switch (value) {
5741
+ case void 0: return "markdown";
5742
+ case "json":
5743
+ case "markdown":
5744
+ case "raw":
5745
+ case "yaml": return value;
5746
+ default: throw new Error("invalid meeting notes format: expected markdown, json, yaml, or raw");
5747
+ }
5748
+ }
5749
+ function resolveTranscriptFormat$1(value) {
5750
+ switch (value) {
5751
+ case void 0: return "text";
5752
+ case "json":
5753
+ case "raw":
5754
+ case "text":
5755
+ case "yaml": return value;
5756
+ default: throw new Error("invalid meeting transcript format: expected text, json, yaml, or raw");
5757
+ }
5758
+ }
5759
+ function parseLimit(value) {
5760
+ if (value === void 0) return 20;
5761
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
5762
+ const limit = Number(value);
5763
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
5764
+ return limit;
5765
+ }
5766
+ const meetingCommand = {
5767
+ description: "Inspect and export individual Granola meetings",
5768
+ flags: {
5769
+ cache: { type: "string" },
5770
+ format: { type: "string" },
5771
+ help: { type: "boolean" },
5772
+ hostname: { type: "string" },
5773
+ limit: { type: "string" },
5774
+ network: { type: "string" },
5775
+ open: { type: "boolean" },
5776
+ password: { type: "string" },
5777
+ port: { type: "string" },
5778
+ search: { type: "string" },
5779
+ timeout: { type: "string" },
5780
+ "trusted-origins": { type: "string" }
5781
+ },
5782
+ help: meetingHelp,
5783
+ name: "meeting",
5784
+ async run({ commandArgs, commandFlags, globalFlags }) {
5785
+ const [action, id] = commandArgs;
5786
+ switch (action) {
5787
+ case "list": return await list(commandFlags, globalFlags);
5788
+ case "view":
5789
+ if (!id) throw new Error("meeting view requires an id");
5790
+ return await view(id, commandFlags, globalFlags);
5791
+ case "export":
5792
+ if (!id) throw new Error("meeting export requires an id");
5793
+ return await exportMeeting(id, commandFlags, globalFlags);
5794
+ case "notes":
5795
+ if (!id) throw new Error("meeting notes requires an id");
5796
+ return await notes(id, commandFlags, globalFlags);
5797
+ case "transcript":
5798
+ if (!id) throw new Error("meeting transcript requires an id");
5799
+ return await transcript(id, commandFlags, globalFlags);
5800
+ case "open":
5801
+ if (!id) throw new Error("meeting open requires an id");
5802
+ return await openMeeting(id, commandFlags, globalFlags);
5803
+ case void 0:
5804
+ console.log(meetingHelp());
5805
+ return 1;
5806
+ default: throw new Error("invalid meeting command: expected list, view, export, notes, transcript, or open");
5807
+ }
5808
+ }
5809
+ };
5810
+ async function list(commandFlags, globalFlags) {
5811
+ const format = resolveListFormat(commandFlags.format);
5812
+ const limit = parseLimit(commandFlags.limit);
5813
+ const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
5814
+ const config = await loadConfig({
5815
+ globalFlags,
5816
+ subcommandFlags: commandFlags
5817
+ });
5818
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5819
+ debug(config.debug, "supabase", config.supabase);
5820
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5821
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5822
+ const app = await createGranolaApp(config);
5823
+ debug(config.debug, "authMode", app.getState().auth.mode);
5824
+ console.log("Loading meetings...");
5825
+ const result = await app.listMeetings({
5826
+ limit,
5827
+ search
5828
+ });
5829
+ console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
5830
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
5831
+ return 0;
5832
+ }
5833
+ async function view(id, commandFlags, globalFlags) {
5834
+ const format = resolveViewFormat(commandFlags.format);
5835
+ const config = await loadConfig({
5836
+ globalFlags,
5837
+ subcommandFlags: commandFlags
5838
+ });
5839
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5840
+ debug(config.debug, "supabase", config.supabase);
5841
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5842
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5843
+ const app = await createGranolaApp(config);
5844
+ debug(config.debug, "authMode", app.getState().auth.mode);
5845
+ console.log("Fetching meeting from Granola API...");
5846
+ const result = await app.getMeeting(id);
5847
+ console.log(renderMeetingView(result.meeting, format).trimEnd());
5848
+ return 0;
5849
+ }
5850
+ async function exportMeeting(id, commandFlags, globalFlags) {
5851
+ const format = resolveExportFormat(commandFlags.format);
5852
+ const config = await loadConfig({
5853
+ globalFlags,
5854
+ subcommandFlags: commandFlags
5855
+ });
5856
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5857
+ debug(config.debug, "supabase", config.supabase);
5858
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5859
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5860
+ const app = await createGranolaApp(config);
5861
+ debug(config.debug, "authMode", app.getState().auth.mode);
5862
+ console.log("Fetching meeting from Granola API...");
5863
+ const result = await app.getMeeting(id);
5864
+ console.log(renderMeetingExport(result.meeting, format).trimEnd());
5865
+ return 0;
5866
+ }
5867
+ async function notes(id, commandFlags, globalFlags) {
5868
+ const format = resolveNotesFormat(commandFlags.format);
5869
+ const config = await loadConfig({
5870
+ globalFlags,
5871
+ subcommandFlags: commandFlags
5872
+ });
5873
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5874
+ debug(config.debug, "supabase", config.supabase);
5875
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5876
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5877
+ const app = await createGranolaApp(config);
5878
+ debug(config.debug, "authMode", app.getState().auth.mode);
5879
+ console.log("Fetching meeting from Granola API...");
5880
+ const result = await app.getMeeting(id);
5881
+ console.log(renderMeetingNotes(result.document, format).trimEnd());
5882
+ return 0;
5883
+ }
5884
+ async function transcript(id, commandFlags, globalFlags) {
5885
+ const format = resolveTranscriptFormat$1(commandFlags.format);
5886
+ const config = await loadConfig({
5887
+ globalFlags,
5888
+ subcommandFlags: commandFlags
5889
+ });
5890
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5891
+ debug(config.debug, "supabase", config.supabase);
5892
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5893
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5894
+ const app = await createGranolaApp(config);
5895
+ debug(config.debug, "authMode", app.getState().auth.mode);
5896
+ console.log("Fetching meeting from Granola API...");
5897
+ const result = await app.getMeeting(id, { requireCache: true });
5898
+ const output = renderMeetingTranscript(result.document, result.cacheData, format);
5899
+ if (!output.trim()) throw new Error(`no transcript found for meeting: ${result.document.id}`);
5900
+ console.log(output.trimEnd());
5901
+ return 0;
5902
+ }
5903
+ async function openMeeting(id, commandFlags, globalFlags) {
5904
+ const config = await loadConfig({
5905
+ globalFlags,
5906
+ subcommandFlags: commandFlags
5907
+ });
5908
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5909
+ debug(config.debug, "supabase", config.supabase);
5910
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5911
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5912
+ const app = await createGranolaApp(config, { surface: "web" });
5913
+ debug(config.debug, "authMode", app.getState().auth.mode);
5914
+ console.log("Resolving meeting from Granola API...");
5915
+ const result = await app.getMeeting(id);
5916
+ console.log(`Preparing web workspace for ${result.document.title || result.document.id}...`);
5917
+ return await runGranolaWebWorkspace(app, {
5918
+ ...resolveGranolaWebWorkspaceOptions(commandFlags),
5919
+ targetMeetingId: result.document.id
5920
+ });
5921
+ }
5922
+ //#endregion
5923
+ //#region src/commands/notes.ts
5924
+ function notesHelp() {
5925
+ return `Granola notes
5926
+
5927
+ Usage:
5928
+ granola notes [options]
5929
+
5930
+ Options:
5931
+ --format <value> Output format: markdown, json, yaml, raw (default: markdown)
5932
+ --output <path> Output directory for note files (default: ./notes)
5933
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5934
+ --supabase <path> Path to supabase.json
5935
+ --debug Enable debug logging
5936
+ --config <path> Path to .granola.toml
5937
+ -h, --help Show help
5938
+ `;
5939
+ }
5940
+ const notesCommand = {
5941
+ description: "Export Granola notes",
5942
+ flags: {
5943
+ format: { type: "string" },
5944
+ help: { type: "boolean" },
5945
+ output: { type: "string" },
5946
+ timeout: { type: "string" }
5947
+ },
5948
+ help: notesHelp,
5949
+ name: "notes",
5950
+ async run({ commandFlags, globalFlags }) {
5951
+ const config = await loadConfig({
5952
+ globalFlags,
5953
+ subcommandFlags: commandFlags
5954
+ });
5955
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5956
+ debug(config.debug, "supabase", config.supabase);
5957
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5958
+ debug(config.debug, "output", config.notes.output);
5959
+ const format = resolveNoteFormat(commandFlags.format);
5960
+ debug(config.debug, "format", format);
5961
+ const app = await createGranolaApp(config);
5962
+ debug(config.debug, "authMode", app.getState().auth.mode);
5963
+ const result = await app.exportNotes(format);
5964
+ console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
5965
+ debug(config.debug, "notes written", result.written);
5966
+ return 0;
5967
+ }
5968
+ };
5969
+ function resolveNoteFormat(value) {
5970
+ switch (value) {
5971
+ case void 0: return "markdown";
5972
+ case "json":
5973
+ case "markdown":
5974
+ case "raw":
5975
+ case "yaml": return value;
5976
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
5977
+ }
5978
+ }
5979
+ //#endregion
5980
+ //#region src/commands/serve.ts
5981
+ function serveHelp() {
5982
+ return `Granola serve
5983
+
5984
+ Usage:
5985
+ granola serve [options]
5986
+
5987
+ Options:
5573
5988
  --network <mode> Network mode: local or lan (default: local)
5574
5989
  --hostname <value> Hostname to bind (overrides network default)
5575
5990
  --port <value> Port to bind (default: 0 for any available port)
@@ -5744,37 +6159,6 @@ function resolveTranscriptFormat(value) {
5744
6159
  }
5745
6160
  }
5746
6161
  //#endregion
5747
- //#region src/browser.ts
5748
- const execFileAsync = promisify(execFile);
5749
- function getBrowserOpenCommand(url, platform = process.platform) {
5750
- const href = String(url);
5751
- switch (platform) {
5752
- case "darwin": return {
5753
- args: [href],
5754
- file: "open"
5755
- };
5756
- case "win32": return {
5757
- args: [
5758
- "/c",
5759
- "start",
5760
- "",
5761
- href
5762
- ],
5763
- file: "cmd"
5764
- };
5765
- default: return {
5766
- args: [href],
5767
- file: "xdg-open"
5768
- };
5769
- }
5770
- }
5771
- async function openExternalUrl(url, options = {}) {
5772
- const command = getBrowserOpenCommand(url, options.platform);
5773
- await (options.run ?? (async (file, args) => {
5774
- await execFileAsync(file, args);
5775
- }))(command.file, command.args);
5776
- }
5777
- //#endregion
5778
6162
  //#region src/commands/web.ts
5779
6163
  function webHelp() {
5780
6164
  return `Granola web
@@ -5783,6 +6167,7 @@ Usage:
5783
6167
  granola web [options]
5784
6168
 
5785
6169
  Options:
6170
+ --meeting <id> Open a specific meeting on load
5786
6171
  --network <mode> Network mode: local or lan (default: local)
5787
6172
  --hostname <value> Hostname to bind (overrides network default)
5788
6173
  --port <value> Port to bind (default: 0 for any available port)
@@ -5814,6 +6199,7 @@ const commands = [
5814
6199
  cache: { type: "string" },
5815
6200
  help: { type: "boolean" },
5816
6201
  hostname: { type: "string" },
6202
+ meeting: { type: "string" },
5817
6203
  network: { type: "string" },
5818
6204
  open: { type: "boolean" },
5819
6205
  password: { type: "string" },
@@ -5833,55 +6219,12 @@ const commands = [
5833
6219
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5834
6220
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5835
6221
  const app = await createGranolaApp(config, { surface: "web" });
5836
- const networkMode = parseNetworkMode(commandFlags.network);
5837
- const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
5838
- const port = parsePort(commandFlags.port);
5839
- const openBrowser = commandFlags.open !== false;
5840
- const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
5841
- const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
5842
- const server = await startGranolaServer(app, {
5843
- enableWebClient: true,
5844
- hostname,
5845
- port,
5846
- security: {
5847
- password,
5848
- trustedOrigins
5849
- }
6222
+ const options = resolveGranolaWebWorkspaceOptions(commandFlags);
6223
+ const targetMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
6224
+ return await runGranolaWebWorkspace(app, {
6225
+ ...options,
6226
+ targetMeetingId
5850
6227
  });
5851
- console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
5852
- console.log(`Network mode: ${networkMode}`);
5853
- if (password) console.log("Server password protection: enabled");
5854
- else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
5855
- if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
5856
- console.log("Routes:");
5857
- console.log(" GET /");
5858
- console.log(" GET /health");
5859
- console.log(" POST /auth/unlock");
5860
- console.log(" POST /auth/lock");
5861
- console.log(" GET /auth/status");
5862
- console.log(" GET /state");
5863
- console.log(" GET /events");
5864
- console.log(" GET /meetings");
5865
- console.log(" GET /meetings/:id");
5866
- console.log(" GET /exports/jobs");
5867
- console.log(" POST /auth/login");
5868
- console.log(" POST /auth/logout");
5869
- console.log(" POST /auth/mode");
5870
- console.log(" POST /auth/refresh");
5871
- console.log(" POST /exports/notes");
5872
- console.log(" POST /exports/jobs/:id/rerun");
5873
- console.log(" POST /exports/transcripts");
5874
- console.log(`Attach: granola attach ${server.url.href}`);
5875
- if (password) console.log("Attach password: add --password <value>");
5876
- if (openBrowser) try {
5877
- await openExternalUrl(server.url);
5878
- } catch (error) {
5879
- const message = error instanceof Error ? error.message : String(error);
5880
- console.error(`failed to open browser automatically: ${message}`);
5881
- console.error(`open ${server.url.href} manually`);
5882
- }
5883
- await waitForShutdown(async () => await server.close());
5884
- return 0;
5885
6228
  }
5886
6229
  }
5887
6230
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",