granola-toolkit 0.27.0 → 0.28.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 +8 -0
  2. package/dist/cli.js +483 -368
  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
package/dist/cli.js CHANGED
@@ -3452,274 +3452,41 @@ async function rerun(id, commandFlags, globalFlags) {
3452
3452
  return 0;
3453
3453
  }
3454
3454
  //#endregion
3455
- //#region src/commands/meeting.ts
3456
- function meetingHelp() {
3457
- return `Granola meeting
3458
-
3459
- Usage:
3460
- granola meeting <list|view|export|notes|transcript> [options]
3461
-
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
3468
-
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
- `;
3480
- }
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
- }
3498
- }
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
- }
3506
- }
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
- }
3516
- }
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
- }
3526
- }
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
- }
3455
+ //#region src/browser.ts
3456
+ const execFileAsync = promisify(execFile);
3457
+ function getBrowserOpenCommand(url, platform = process.platform) {
3458
+ const href = String(url);
3459
+ switch (platform) {
3460
+ case "darwin": return {
3461
+ args: [href],
3462
+ file: "open"
3463
+ };
3464
+ case "win32": return {
3465
+ args: [
3466
+ "/c",
3467
+ "start",
3468
+ "",
3469
+ href
3470
+ ],
3471
+ file: "cmd"
3472
+ };
3473
+ default: return {
3474
+ args: [href],
3475
+ file: "xdg-open"
3476
+ };
3567
3477
  }
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
3478
  }
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
- }
3479
+ async function openExternalUrl(url, options = {}) {
3480
+ const command = getBrowserOpenCommand(url, options.platform);
3481
+ await (options.run ?? (async (file, args) => {
3482
+ await execFileAsync(file, args);
3483
+ }))(command.file, command.args);
3718
3484
  }
3719
3485
  //#endregion
3720
3486
  //#region src/web/client-script.ts
3721
3487
  const granolaWebClientScript = String.raw`
3722
3488
  const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
3489
+ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
3723
3490
 
3724
3491
  const state = {
3725
3492
  appState: null,
@@ -3764,6 +3531,40 @@ const els = {
3764
3531
  workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
3765
3532
  };
3766
3533
 
3534
+ function parseWorkspaceTab(value) {
3535
+ return workspaceTabs.includes(value) ? value : "notes";
3536
+ }
3537
+
3538
+ function startupSelection() {
3539
+ const params = new URLSearchParams(window.location.search);
3540
+ return {
3541
+ meetingId: params.get("meeting")?.trim() || "",
3542
+ workspaceTab: parseWorkspaceTab(params.get("tab")),
3543
+ };
3544
+ }
3545
+
3546
+ function syncBrowserUrl() {
3547
+ const url = new URL(window.location.href);
3548
+
3549
+ if (state.selectedMeetingId) {
3550
+ url.searchParams.set("meeting", state.selectedMeetingId);
3551
+ } else {
3552
+ url.searchParams.delete("meeting");
3553
+ }
3554
+
3555
+ if (state.workspaceTab !== "notes") {
3556
+ url.searchParams.set("tab", state.workspaceTab);
3557
+ } else {
3558
+ url.searchParams.delete("tab");
3559
+ }
3560
+
3561
+ const nextPath = url.pathname + url.search + url.hash;
3562
+ const currentPath = window.location.pathname + window.location.search + window.location.hash;
3563
+ if (nextPath !== currentPath) {
3564
+ history.replaceState(null, "", nextPath);
3565
+ }
3566
+ }
3567
+
3767
3568
  function escapeHtml(value) {
3768
3569
  return value
3769
3570
  .replaceAll("&", "&amp;")
@@ -3927,6 +3728,7 @@ function renderMeetingList() {
3927
3728
  state.selectedMeetingId = null;
3928
3729
  state.selectedMeeting = null;
3929
3730
  state.selectedMeetingBundle = null;
3731
+ syncBrowserUrl();
3930
3732
  const filterSummary = currentFilterSummary();
3931
3733
  const message = filterSummary
3932
3734
  ? "No meetings match " + filterSummary + "."
@@ -3940,6 +3742,7 @@ function renderMeetingList() {
3940
3742
  if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
3941
3743
  state.selectedMeetingId = state.meetings[0]?.id || null;
3942
3744
  }
3745
+ syncBrowserUrl();
3943
3746
 
3944
3747
  els.list.innerHTML = state.meetings
3945
3748
  .map((meeting) => {
@@ -4138,6 +3941,7 @@ async function loadMeetings(options = {}) {
4138
3941
 
4139
3942
  async function loadMeeting(id) {
4140
3943
  state.selectedMeetingId = id;
3944
+ syncBrowserUrl();
4141
3945
  renderMeetingList();
4142
3946
 
4143
3947
  try {
@@ -4496,6 +4300,7 @@ els.quickOpenButton.addEventListener("click", () => {
4496
4300
  els.workspaceTabs.forEach((button) => {
4497
4301
  button.addEventListener("click", () => {
4498
4302
  state.workspaceTab = button.dataset.workspaceTab || "notes";
4303
+ syncBrowserUrl();
4499
4304
  renderMeetingDetail();
4500
4305
  });
4501
4306
  });
@@ -4512,24 +4317,28 @@ document.addEventListener("keydown", (event) => {
4512
4317
  const tabs = ["notes", "transcript", "metadata", "raw"];
4513
4318
  if (event.key === "1") {
4514
4319
  state.workspaceTab = "notes";
4320
+ syncBrowserUrl();
4515
4321
  renderMeetingDetail();
4516
4322
  return;
4517
4323
  }
4518
4324
 
4519
4325
  if (event.key === "2") {
4520
4326
  state.workspaceTab = "transcript";
4327
+ syncBrowserUrl();
4521
4328
  renderMeetingDetail();
4522
4329
  return;
4523
4330
  }
4524
4331
 
4525
4332
  if (event.key === "3") {
4526
4333
  state.workspaceTab = "metadata";
4334
+ syncBrowserUrl();
4527
4335
  renderMeetingDetail();
4528
4336
  return;
4529
4337
  }
4530
4338
 
4531
4339
  if (event.key === "4") {
4532
4340
  state.workspaceTab = "raw";
4341
+ syncBrowserUrl();
4533
4342
  renderMeetingDetail();
4534
4343
  return;
4535
4344
  }
@@ -4537,15 +4346,21 @@ document.addEventListener("keydown", (event) => {
4537
4346
  const currentIndex = tabs.indexOf(state.workspaceTab);
4538
4347
  if (event.key === "]") {
4539
4348
  state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
4349
+ syncBrowserUrl();
4540
4350
  renderMeetingDetail();
4541
4351
  }
4542
4352
 
4543
4353
  if (event.key === "[") {
4544
4354
  state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
4355
+ syncBrowserUrl();
4545
4356
  renderMeetingDetail();
4546
4357
  }
4547
4358
  });
4548
4359
 
4360
+ const initialSelection = startupSelection();
4361
+ state.selectedMeetingId = initialSelection.meetingId || null;
4362
+ state.workspaceTab = initialSelection.workspaceTab;
4363
+
4549
4364
  const events = new EventSource("/events");
4550
4365
  events.addEventListener("state.updated", (event) => {
4551
4366
  const previousLoadedAt = state.appState?.documents?.loadedAt;
@@ -5531,35 +5346,407 @@ async function startGranolaServer(app, options = {}) {
5531
5346
  });
5532
5347
  }
5533
5348
  });
5534
- await new Promise((resolve, reject) => {
5535
- server.once("error", reject);
5536
- server.listen(port, hostname, () => {
5537
- server.off("error", reject);
5538
- resolve();
5539
- });
5349
+ await new Promise((resolve, reject) => {
5350
+ server.once("error", reject);
5351
+ server.listen(port, hostname, () => {
5352
+ server.off("error", reject);
5353
+ resolve();
5354
+ });
5355
+ });
5356
+ const address = server.address();
5357
+ if (!address || typeof address === "string") throw new Error("failed to resolve server address");
5358
+ const resolved = address;
5359
+ const url = new URL(`http://${hostname}:${resolved.port}`);
5360
+ return {
5361
+ app,
5362
+ async close() {
5363
+ await new Promise((resolve, reject) => {
5364
+ server.close((error) => {
5365
+ if (error) {
5366
+ reject(error);
5367
+ return;
5368
+ }
5369
+ resolve();
5370
+ });
5371
+ });
5372
+ },
5373
+ hostname,
5374
+ port: resolved.port,
5375
+ server,
5376
+ url
5377
+ };
5378
+ }
5379
+ //#endregion
5380
+ //#region src/web-url.ts
5381
+ function buildGranolaMeetingUrl(baseUrl, meetingId) {
5382
+ const url = new URL(baseUrl);
5383
+ if (meetingId.trim()) url.searchParams.set("meeting", meetingId.trim());
5384
+ return url;
5385
+ }
5386
+ //#endregion
5387
+ //#region src/commands/web-shared.ts
5388
+ function resolveGranolaWebWorkspaceOptions(commandFlags) {
5389
+ const networkMode = parseNetworkMode(commandFlags.network);
5390
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
5391
+ const port = parsePort(commandFlags.port);
5392
+ return {
5393
+ hostname,
5394
+ networkMode,
5395
+ openBrowser: commandFlags.open !== false,
5396
+ password: typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0,
5397
+ port,
5398
+ trustedOrigins: parseTrustedOrigins(commandFlags["trusted-origins"])
5399
+ };
5400
+ }
5401
+ function printWebRoutes() {
5402
+ console.log("Routes:");
5403
+ console.log(" GET /");
5404
+ console.log(" GET /health");
5405
+ console.log(" POST /auth/unlock");
5406
+ console.log(" POST /auth/lock");
5407
+ console.log(" GET /auth/status");
5408
+ console.log(" GET /state");
5409
+ console.log(" GET /events");
5410
+ console.log(" GET /meetings");
5411
+ console.log(" GET /meetings/:id");
5412
+ console.log(" GET /exports/jobs");
5413
+ console.log(" POST /auth/login");
5414
+ console.log(" POST /auth/logout");
5415
+ console.log(" POST /auth/mode");
5416
+ console.log(" POST /auth/refresh");
5417
+ console.log(" POST /exports/notes");
5418
+ console.log(" POST /exports/jobs/:id/rerun");
5419
+ console.log(" POST /exports/transcripts");
5420
+ }
5421
+ async function runGranolaWebWorkspace(app, options) {
5422
+ const server = await startGranolaServer(app, {
5423
+ enableWebClient: true,
5424
+ hostname: options.hostname,
5425
+ port: options.port,
5426
+ security: {
5427
+ password: options.password,
5428
+ trustedOrigins: options.trustedOrigins
5429
+ }
5430
+ });
5431
+ const targetUrl = options.targetMeetingId ? buildGranolaMeetingUrl(server.url, options.targetMeetingId) : new URL(server.url);
5432
+ console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
5433
+ if (targetUrl.href !== server.url.href) console.log(`Focused meeting URL: ${targetUrl.href}`);
5434
+ console.log(`Network mode: ${options.networkMode}`);
5435
+ if (options.password) console.log("Server password protection: enabled");
5436
+ else if (options.networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
5437
+ if (options.trustedOrigins.length > 0) console.log(`Trusted origins: ${options.trustedOrigins.join(", ")}`);
5438
+ printWebRoutes();
5439
+ console.log(`Attach: granola attach ${server.url.href}`);
5440
+ if (options.password) console.log("Attach password: add --password <value>");
5441
+ if (options.openBrowser) try {
5442
+ await openExternalUrl(targetUrl);
5443
+ } catch (error) {
5444
+ const message = error instanceof Error ? error.message : String(error);
5445
+ console.error(`failed to open browser automatically: ${message}`);
5446
+ console.error(`open ${targetUrl.href} manually`);
5447
+ }
5448
+ await waitForShutdown(async () => await server.close());
5449
+ return 0;
5450
+ }
5451
+ //#endregion
5452
+ //#region src/commands/meeting.ts
5453
+ function meetingHelp() {
5454
+ return `Granola meeting
5455
+
5456
+ Usage:
5457
+ granola meeting <list|view|export|notes|transcript|open> [options]
5458
+
5459
+ Subcommands:
5460
+ list List meetings from the Granola API
5461
+ view <id> Show a single meeting with notes and transcript text
5462
+ export <id> Export a single meeting as JSON or YAML
5463
+ notes <id> Show a single meeting's notes
5464
+ transcript <id> Show a single meeting's transcript
5465
+ open <id> Start the web workspace focused on one meeting
5466
+
5467
+ Options:
5468
+ --cache <path> Path to Granola cache JSON for transcript data
5469
+ --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
5470
+ --network <mode> open: local or lan (default: local)
5471
+ --hostname <value> open: hostname to bind (overrides network default)
5472
+ --limit <n> Number of meetings for list (default: 20)
5473
+ --open[=true|false] open: launch the browser automatically (default: true)
5474
+ --password <value> open: optional server password
5475
+ --port <value> open: port to bind (default: 0 for any available port)
5476
+ --search <query> Filter list by title, id, or tag
5477
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5478
+ --trusted-origins <v> open: comma-separated extra browser origins to trust
5479
+ --supabase <path> Path to supabase.json
5480
+ --debug Enable debug logging
5481
+ --config <path> Path to .granola.toml
5482
+ -h, --help Show help
5483
+ `;
5484
+ }
5485
+ function resolveListFormat(value) {
5486
+ switch (value) {
5487
+ case void 0: return "text";
5488
+ case "json":
5489
+ case "text":
5490
+ case "yaml": return value;
5491
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
5492
+ }
5493
+ }
5494
+ function resolveViewFormat(value) {
5495
+ switch (value) {
5496
+ case void 0: return "text";
5497
+ case "json":
5498
+ case "text":
5499
+ case "yaml": return value;
5500
+ default: throw new Error("invalid meeting format: expected text, json, or yaml");
5501
+ }
5502
+ }
5503
+ function resolveExportFormat(value) {
5504
+ switch (value) {
5505
+ case void 0: return "json";
5506
+ case "json":
5507
+ case "yaml": return value;
5508
+ default: throw new Error("invalid meeting export format: expected json or yaml");
5509
+ }
5510
+ }
5511
+ function resolveNotesFormat(value) {
5512
+ switch (value) {
5513
+ case void 0: return "markdown";
5514
+ case "json":
5515
+ case "markdown":
5516
+ case "raw":
5517
+ case "yaml": return value;
5518
+ default: throw new Error("invalid meeting notes format: expected markdown, json, yaml, or raw");
5519
+ }
5520
+ }
5521
+ function resolveTranscriptFormat$1(value) {
5522
+ switch (value) {
5523
+ case void 0: return "text";
5524
+ case "json":
5525
+ case "raw":
5526
+ case "text":
5527
+ case "yaml": return value;
5528
+ default: throw new Error("invalid meeting transcript format: expected text, json, yaml, or raw");
5529
+ }
5530
+ }
5531
+ function parseLimit(value) {
5532
+ if (value === void 0) return 20;
5533
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
5534
+ const limit = Number(value);
5535
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
5536
+ return limit;
5537
+ }
5538
+ const meetingCommand = {
5539
+ description: "Inspect and export individual Granola meetings",
5540
+ flags: {
5541
+ cache: { type: "string" },
5542
+ format: { type: "string" },
5543
+ help: { type: "boolean" },
5544
+ hostname: { type: "string" },
5545
+ limit: { type: "string" },
5546
+ network: { type: "string" },
5547
+ open: { type: "boolean" },
5548
+ password: { type: "string" },
5549
+ port: { type: "string" },
5550
+ search: { type: "string" },
5551
+ timeout: { type: "string" },
5552
+ "trusted-origins": { type: "string" }
5553
+ },
5554
+ help: meetingHelp,
5555
+ name: "meeting",
5556
+ async run({ commandArgs, commandFlags, globalFlags }) {
5557
+ const [action, id] = commandArgs;
5558
+ switch (action) {
5559
+ case "list": return await list(commandFlags, globalFlags);
5560
+ case "view":
5561
+ if (!id) throw new Error("meeting view requires an id");
5562
+ return await view(id, commandFlags, globalFlags);
5563
+ case "export":
5564
+ if (!id) throw new Error("meeting export requires an id");
5565
+ return await exportMeeting(id, commandFlags, globalFlags);
5566
+ case "notes":
5567
+ if (!id) throw new Error("meeting notes requires an id");
5568
+ return await notes(id, commandFlags, globalFlags);
5569
+ case "transcript":
5570
+ if (!id) throw new Error("meeting transcript requires an id");
5571
+ return await transcript(id, commandFlags, globalFlags);
5572
+ case "open":
5573
+ if (!id) throw new Error("meeting open requires an id");
5574
+ return await openMeeting(id, commandFlags, globalFlags);
5575
+ case void 0:
5576
+ console.log(meetingHelp());
5577
+ return 1;
5578
+ default: throw new Error("invalid meeting command: expected list, view, export, notes, transcript, or open");
5579
+ }
5580
+ }
5581
+ };
5582
+ async function list(commandFlags, globalFlags) {
5583
+ const format = resolveListFormat(commandFlags.format);
5584
+ const limit = parseLimit(commandFlags.limit);
5585
+ const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
5586
+ const config = await loadConfig({
5587
+ globalFlags,
5588
+ subcommandFlags: commandFlags
5589
+ });
5590
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5591
+ debug(config.debug, "supabase", config.supabase);
5592
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5593
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5594
+ const app = await createGranolaApp(config);
5595
+ debug(config.debug, "authMode", app.getState().auth.mode);
5596
+ console.log("Loading meetings...");
5597
+ const result = await app.listMeetings({
5598
+ limit,
5599
+ search
5540
5600
  });
5541
- const address = server.address();
5542
- if (!address || typeof address === "string") throw new Error("failed to resolve server address");
5543
- const resolved = address;
5544
- const url = new URL(`http://${hostname}:${resolved.port}`);
5545
- return {
5546
- app,
5547
- async close() {
5548
- await new Promise((resolve, reject) => {
5549
- server.close((error) => {
5550
- if (error) {
5551
- reject(error);
5552
- return;
5553
- }
5554
- resolve();
5555
- });
5556
- });
5557
- },
5558
- hostname,
5559
- port: resolved.port,
5560
- server,
5561
- url
5562
- };
5601
+ console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
5602
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
5603
+ return 0;
5604
+ }
5605
+ async function view(id, commandFlags, globalFlags) {
5606
+ const format = resolveViewFormat(commandFlags.format);
5607
+ const config = await loadConfig({
5608
+ globalFlags,
5609
+ subcommandFlags: commandFlags
5610
+ });
5611
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5612
+ debug(config.debug, "supabase", config.supabase);
5613
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5614
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5615
+ const app = await createGranolaApp(config);
5616
+ debug(config.debug, "authMode", app.getState().auth.mode);
5617
+ console.log("Fetching meeting from Granola API...");
5618
+ const result = await app.getMeeting(id);
5619
+ console.log(renderMeetingView(result.meeting, format).trimEnd());
5620
+ return 0;
5621
+ }
5622
+ async function exportMeeting(id, commandFlags, globalFlags) {
5623
+ const format = resolveExportFormat(commandFlags.format);
5624
+ const config = await loadConfig({
5625
+ globalFlags,
5626
+ subcommandFlags: commandFlags
5627
+ });
5628
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5629
+ debug(config.debug, "supabase", config.supabase);
5630
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5631
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5632
+ const app = await createGranolaApp(config);
5633
+ debug(config.debug, "authMode", app.getState().auth.mode);
5634
+ console.log("Fetching meeting from Granola API...");
5635
+ const result = await app.getMeeting(id);
5636
+ console.log(renderMeetingExport(result.meeting, format).trimEnd());
5637
+ return 0;
5638
+ }
5639
+ async function notes(id, commandFlags, globalFlags) {
5640
+ const format = resolveNotesFormat(commandFlags.format);
5641
+ const config = await loadConfig({
5642
+ globalFlags,
5643
+ subcommandFlags: commandFlags
5644
+ });
5645
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5646
+ debug(config.debug, "supabase", config.supabase);
5647
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5648
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5649
+ const app = await createGranolaApp(config);
5650
+ debug(config.debug, "authMode", app.getState().auth.mode);
5651
+ console.log("Fetching meeting from Granola API...");
5652
+ const result = await app.getMeeting(id);
5653
+ console.log(renderMeetingNotes(result.document, format).trimEnd());
5654
+ return 0;
5655
+ }
5656
+ async function transcript(id, commandFlags, globalFlags) {
5657
+ const format = resolveTranscriptFormat$1(commandFlags.format);
5658
+ const config = await loadConfig({
5659
+ globalFlags,
5660
+ subcommandFlags: commandFlags
5661
+ });
5662
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5663
+ debug(config.debug, "supabase", config.supabase);
5664
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5665
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5666
+ const app = await createGranolaApp(config);
5667
+ debug(config.debug, "authMode", app.getState().auth.mode);
5668
+ console.log("Fetching meeting from Granola API...");
5669
+ const result = await app.getMeeting(id, { requireCache: true });
5670
+ const output = renderMeetingTranscript(result.document, result.cacheData, format);
5671
+ if (!output.trim()) throw new Error(`no transcript found for meeting: ${result.document.id}`);
5672
+ console.log(output.trimEnd());
5673
+ return 0;
5674
+ }
5675
+ async function openMeeting(id, commandFlags, globalFlags) {
5676
+ const config = await loadConfig({
5677
+ globalFlags,
5678
+ subcommandFlags: commandFlags
5679
+ });
5680
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5681
+ debug(config.debug, "supabase", config.supabase);
5682
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5683
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5684
+ const app = await createGranolaApp(config, { surface: "web" });
5685
+ debug(config.debug, "authMode", app.getState().auth.mode);
5686
+ console.log("Resolving meeting from Granola API...");
5687
+ const result = await app.getMeeting(id);
5688
+ console.log(`Preparing web workspace for ${result.document.title || result.document.id}...`);
5689
+ return await runGranolaWebWorkspace(app, {
5690
+ ...resolveGranolaWebWorkspaceOptions(commandFlags),
5691
+ targetMeetingId: result.document.id
5692
+ });
5693
+ }
5694
+ //#endregion
5695
+ //#region src/commands/notes.ts
5696
+ function notesHelp() {
5697
+ return `Granola notes
5698
+
5699
+ Usage:
5700
+ granola notes [options]
5701
+
5702
+ Options:
5703
+ --format <value> Output format: markdown, json, yaml, raw (default: markdown)
5704
+ --output <path> Output directory for note files (default: ./notes)
5705
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5706
+ --supabase <path> Path to supabase.json
5707
+ --debug Enable debug logging
5708
+ --config <path> Path to .granola.toml
5709
+ -h, --help Show help
5710
+ `;
5711
+ }
5712
+ const notesCommand = {
5713
+ description: "Export Granola notes",
5714
+ flags: {
5715
+ format: { type: "string" },
5716
+ help: { type: "boolean" },
5717
+ output: { type: "string" },
5718
+ timeout: { type: "string" }
5719
+ },
5720
+ help: notesHelp,
5721
+ name: "notes",
5722
+ async run({ commandFlags, globalFlags }) {
5723
+ const config = await loadConfig({
5724
+ globalFlags,
5725
+ subcommandFlags: commandFlags
5726
+ });
5727
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5728
+ debug(config.debug, "supabase", config.supabase);
5729
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5730
+ debug(config.debug, "output", config.notes.output);
5731
+ const format = resolveNoteFormat(commandFlags.format);
5732
+ debug(config.debug, "format", format);
5733
+ const app = await createGranolaApp(config);
5734
+ debug(config.debug, "authMode", app.getState().auth.mode);
5735
+ const result = await app.exportNotes(format);
5736
+ console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
5737
+ debug(config.debug, "notes written", result.written);
5738
+ return 0;
5739
+ }
5740
+ };
5741
+ function resolveNoteFormat(value) {
5742
+ switch (value) {
5743
+ case void 0: return "markdown";
5744
+ case "json":
5745
+ case "markdown":
5746
+ case "raw":
5747
+ case "yaml": return value;
5748
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
5749
+ }
5563
5750
  }
5564
5751
  //#endregion
5565
5752
  //#region src/commands/serve.ts
@@ -5744,37 +5931,6 @@ function resolveTranscriptFormat(value) {
5744
5931
  }
5745
5932
  }
5746
5933
  //#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
5934
  //#region src/commands/web.ts
5779
5935
  function webHelp() {
5780
5936
  return `Granola web
@@ -5783,6 +5939,7 @@ Usage:
5783
5939
  granola web [options]
5784
5940
 
5785
5941
  Options:
5942
+ --meeting <id> Open a specific meeting on load
5786
5943
  --network <mode> Network mode: local or lan (default: local)
5787
5944
  --hostname <value> Hostname to bind (overrides network default)
5788
5945
  --port <value> Port to bind (default: 0 for any available port)
@@ -5814,6 +5971,7 @@ const commands = [
5814
5971
  cache: { type: "string" },
5815
5972
  help: { type: "boolean" },
5816
5973
  hostname: { type: "string" },
5974
+ meeting: { type: "string" },
5817
5975
  network: { type: "string" },
5818
5976
  open: { type: "boolean" },
5819
5977
  password: { type: "string" },
@@ -5833,55 +5991,12 @@ const commands = [
5833
5991
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5834
5992
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5835
5993
  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
- }
5994
+ const options = resolveGranolaWebWorkspaceOptions(commandFlags);
5995
+ const targetMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
5996
+ return await runGranolaWebWorkspace(app, {
5997
+ ...options,
5998
+ targetMeetingId
5850
5999
  });
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
6000
  }
5886
6001
  }
5887
6002
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",