granola-toolkit 0.18.0 → 0.20.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 +20 -0
  2. package/dist/cli.js +1033 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,7 @@ granola meeting --help
37
37
  granola notes --help
38
38
  granola serve --help
39
39
  granola transcripts --help
40
+ granola web --help
40
41
  ```
41
42
 
42
43
  The published package exposes both `granola` and `granola-toolkit` as executable names.
@@ -50,6 +51,7 @@ node dist/cli.js meeting --help
50
51
  node dist/cli.js notes --help
51
52
  node dist/cli.js serve --help
52
53
  node dist/cli.js transcripts --help
54
+ node dist/cli.js web --help
53
55
  ```
54
56
 
55
57
  You can also use the package scripts:
@@ -97,6 +99,9 @@ Run the local API server:
97
99
  granola serve
98
100
  granola serve --port 4096
99
101
  granola serve --hostname 0.0.0.0 --port 4096
102
+
103
+ granola web
104
+ granola web --open=false --port 4096
100
105
  ```
101
106
 
102
107
  ## How It Works
@@ -187,12 +192,27 @@ The initial server API includes:
187
192
  - `GET /state`
188
193
  - `GET /events` for server-sent state updates
189
194
  - `GET /meetings`
195
+ - `GET /meetings/resolve?q=<query>`
190
196
  - `GET /meetings/:id`
191
197
  - `POST /exports/notes`
192
198
  - `POST /exports/transcripts`
193
199
 
194
200
  This is the foundation for the future `granola web` client and any attachable TUI flows.
195
201
 
202
+ ### Web
203
+
204
+ `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`.
205
+
206
+ The initial browser client includes:
207
+
208
+ - a searchable meeting list
209
+ - sort and updated-date filters
210
+ - quick open by meeting id or title
211
+ - a meeting detail view with notes and transcript panes
212
+ - app-state status from the shared core
213
+ - note and transcript export actions backed by the same local API
214
+ - stronger empty and error states for list/detail failures
215
+
196
216
  ## Auth
197
217
 
198
218
  If you do not want to keep passing `--supabase`, import the desktop app session once:
package/dist/cli.js CHANGED
@@ -154,7 +154,7 @@ function transcriptSpeakerLabel(segment) {
154
154
  }
155
155
  //#endregion
156
156
  //#region src/client/auth.ts
157
- const execFileAsync = promisify(execFile);
157
+ const execFileAsync$1 = promisify(execFile);
158
158
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
159
159
  const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
160
160
  const KEYCHAIN_ACCOUNT_NAME = "session";
@@ -244,7 +244,7 @@ var FileSessionStore = class {
244
244
  var KeychainSessionStore = class {
245
245
  async clearSession() {
246
246
  try {
247
- await execFileAsync("security", [
247
+ await execFileAsync$1("security", [
248
248
  "delete-generic-password",
249
249
  "-s",
250
250
  KEYCHAIN_SERVICE_NAME,
@@ -255,7 +255,7 @@ var KeychainSessionStore = class {
255
255
  }
256
256
  async readSession() {
257
257
  try {
258
- const { stdout } = await execFileAsync("security", [
258
+ const { stdout } = await execFileAsync$1("security", [
259
259
  "find-generic-password",
260
260
  "-s",
261
261
  KEYCHAIN_SERVICE_NAME,
@@ -270,7 +270,7 @@ var KeychainSessionStore = class {
270
270
  }
271
271
  }
272
272
  async writeSession(session) {
273
- await execFileAsync("security", [
273
+ await execFileAsync$1("security", [
274
274
  "add-generic-password",
275
275
  "-U",
276
276
  "-s",
@@ -1275,6 +1275,17 @@ function compareTimestampsDescending(left, right) {
1275
1275
  function compareMeetingDocuments(left, right) {
1276
1276
  return compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1277
1277
  }
1278
+ function compareMeetingDocumentsByTitle(left, right) {
1279
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareStrings(left.id, right.id);
1280
+ }
1281
+ function compareMeetingDocumentsBySort(left, right, sort) {
1282
+ switch (sort) {
1283
+ case "title-asc": return compareMeetingDocumentsByTitle(left, right);
1284
+ case "title-desc": return -compareMeetingDocumentsByTitle(left, right);
1285
+ case "updated-asc": return -compareMeetingDocuments(left, right);
1286
+ default: return compareMeetingDocuments(left, right);
1287
+ }
1288
+ }
1278
1289
  function serialiseNote(note) {
1279
1290
  return {
1280
1291
  content: note.content,
@@ -1338,6 +1349,23 @@ function matchesMeetingSearch(document, search) {
1338
1349
  ...document.tags
1339
1350
  ].some((value) => value.toLowerCase().includes(query));
1340
1351
  }
1352
+ function parseDateFilter(value, label) {
1353
+ const trimmed = value?.trim();
1354
+ if (!trimmed) return;
1355
+ const candidate = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T${label === "updatedFrom" ? "00:00:00.000" : "23:59:59.999"}` : trimmed;
1356
+ const timestamp = Date.parse(candidate);
1357
+ if (Number.isNaN(timestamp)) throw new Error(`invalid ${label}: expected ISO timestamp or YYYY-MM-DD`);
1358
+ return timestamp;
1359
+ }
1360
+ function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1361
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1362
+ const to = parseDateFilter(updatedTo, "updatedTo");
1363
+ const updatedAt = parseTimestamp(latestDocumentTimestamp(document));
1364
+ if (updatedAt == null) return from == null && to == null;
1365
+ if (from != null && updatedAt < from) return false;
1366
+ if (to != null && updatedAt > to) return false;
1367
+ return true;
1368
+ }
1341
1369
  function truncate(value, width) {
1342
1370
  if (value.length <= width) return value.padEnd(width);
1343
1371
  return `${value.slice(0, Math.max(0, width - 1))}…`;
@@ -1390,7 +1418,23 @@ function buildMeetingRecord(document, cacheData) {
1390
1418
  }
1391
1419
  function listMeetings(documents, options = {}) {
1392
1420
  const limit = options.limit ?? 20;
1393
- return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).sort(compareMeetingDocuments).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1421
+ const sort = options.sort ?? "updated-desc";
1422
+ return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1423
+ }
1424
+ function resolveMeetingQuery(documents, query) {
1425
+ const trimmed = query.trim();
1426
+ if (!trimmed) throw new Error("meeting query is required");
1427
+ const lower = trimmed.toLowerCase();
1428
+ const exactId = documents.find((document) => document.id === trimmed);
1429
+ if (exactId) return exactId;
1430
+ const exactTitleMatches = documents.filter((document) => document.title.toLowerCase() === lower);
1431
+ if (exactTitleMatches.length === 1) return exactTitleMatches[0];
1432
+ const prefixMatches = documents.filter((document) => document.id.startsWith(trimmed));
1433
+ if (prefixMatches.length === 1) return prefixMatches[0];
1434
+ const titleMatches = documents.filter((document) => document.title.toLowerCase().includes(lower)).sort(compareMeetingDocuments);
1435
+ if (titleMatches.length === 1) return titleMatches[0];
1436
+ if (exactTitleMatches.length > 1 || prefixMatches.length > 1 || titleMatches.length > 1) throw new Error(`ambiguous meeting query: ${trimmed}`);
1437
+ throw new Error(`meeting not found: ${trimmed}`);
1394
1438
  }
1395
1439
  function resolveMeeting(documents, id) {
1396
1440
  const exactMatch = documents.find((document) => document.id === id);
@@ -1612,10 +1656,16 @@ var GranolaApp = class {
1612
1656
  const meetings = listMeetings(await this.listDocuments(), {
1613
1657
  cacheData: await this.loadCache(),
1614
1658
  limit: options.limit,
1615
- search: options.search
1659
+ search: options.search,
1660
+ sort: options.sort,
1661
+ updatedFrom: options.updatedFrom,
1662
+ updatedTo: options.updatedTo
1616
1663
  });
1617
1664
  this.setUiState({
1618
1665
  meetingSearch: options.search,
1666
+ meetingSort: options.sort,
1667
+ meetingUpdatedFrom: options.updatedFrom,
1668
+ meetingUpdatedTo: options.updatedTo,
1619
1669
  selectedMeetingId: void 0,
1620
1670
  view: "meeting-list"
1621
1671
  });
@@ -1636,6 +1686,21 @@ var GranolaApp = class {
1636
1686
  meeting
1637
1687
  };
1638
1688
  }
1689
+ async findMeeting(query, options = {}) {
1690
+ const documents = await this.listDocuments();
1691
+ const cacheData = await this.loadCache({ required: options.requireCache });
1692
+ const document = resolveMeetingQuery(documents, query);
1693
+ const meeting = buildMeetingRecord(document, cacheData);
1694
+ this.setUiState({
1695
+ selectedMeetingId: document.id,
1696
+ view: "meeting-detail"
1697
+ });
1698
+ return {
1699
+ cacheData,
1700
+ document,
1701
+ meeting
1702
+ };
1703
+ }
1639
1704
  async exportNotes(format = "markdown") {
1640
1705
  const documents = await this.listDocuments();
1641
1706
  const written = await writeNotes(documents, this.config.notes.output, format);
@@ -1767,6 +1832,41 @@ async function loadConfig(options) {
1767
1832
  function debug(enabled, ...values) {
1768
1833
  if (enabled) console.error("[debug]", ...values);
1769
1834
  }
1835
+ function parsePort(value) {
1836
+ if (value === void 0) return;
1837
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
1838
+ const port = Number(value);
1839
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
1840
+ return port;
1841
+ }
1842
+ function pickHostname(value, fallback = "127.0.0.1") {
1843
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
1844
+ }
1845
+ async function waitForShutdown(close) {
1846
+ await new Promise((resolve, reject) => {
1847
+ let closing = false;
1848
+ const cleanup = () => {
1849
+ process.off("SIGINT", handleSignal);
1850
+ process.off("SIGTERM", handleSignal);
1851
+ };
1852
+ const finish = async () => {
1853
+ if (closing) return;
1854
+ closing = true;
1855
+ cleanup();
1856
+ try {
1857
+ await close();
1858
+ resolve();
1859
+ } catch (error) {
1860
+ reject(error);
1861
+ }
1862
+ };
1863
+ const handleSignal = () => {
1864
+ finish();
1865
+ };
1866
+ process.on("SIGINT", handleSignal);
1867
+ process.on("SIGTERM", handleSignal);
1868
+ });
1869
+ }
1770
1870
  //#endregion
1771
1871
  //#region src/commands/meeting.ts
1772
1872
  function meetingHelp() {
@@ -2032,6 +2132,788 @@ function resolveNoteFormat(value) {
2032
2132
  }
2033
2133
  }
2034
2134
  //#endregion
2135
+ //#region src/server/web.ts
2136
+ function renderGranolaWebPage() {
2137
+ return `<!doctype html>
2138
+ <html lang="en">
2139
+ <head>
2140
+ <meta charset="utf-8" />
2141
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2142
+ <title>Granola Toolkit</title>
2143
+ <style>
2144
+ :root {
2145
+ --bg: #f2ede2;
2146
+ --panel: rgba(255, 252, 247, 0.86);
2147
+ --panel-strong: #fffaf2;
2148
+ --line: rgba(36, 39, 44, 0.12);
2149
+ --ink: #1d242c;
2150
+ --muted: #5d6b77;
2151
+ --accent: #0d6a6d;
2152
+ --accent-soft: rgba(13, 106, 109, 0.12);
2153
+ --warm: #a34f2f;
2154
+ --ok: #246b4f;
2155
+ --error: #9d2c2c;
2156
+ --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2157
+ --radius: 24px;
2158
+ --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2159
+ --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2160
+ --sans: "Avenir Next", "Segoe UI", sans-serif;
2161
+ }
2162
+
2163
+ * { box-sizing: border-box; }
2164
+
2165
+ body {
2166
+ margin: 0;
2167
+ min-height: 100vh;
2168
+ font-family: var(--sans);
2169
+ color: var(--ink);
2170
+ background:
2171
+ radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2172
+ radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2173
+ linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2174
+ }
2175
+
2176
+ .shell {
2177
+ display: grid;
2178
+ grid-template-columns: 320px minmax(0, 1fr);
2179
+ gap: 18px;
2180
+ min-height: 100vh;
2181
+ padding: 24px;
2182
+ }
2183
+
2184
+ .pane {
2185
+ background: var(--panel);
2186
+ backdrop-filter: blur(18px);
2187
+ border: 1px solid var(--line);
2188
+ border-radius: var(--radius);
2189
+ box-shadow: var(--shadow);
2190
+ }
2191
+
2192
+ .sidebar {
2193
+ display: grid;
2194
+ grid-template-rows: auto auto 1fr;
2195
+ overflow: hidden;
2196
+ }
2197
+
2198
+ .hero, .toolbar, .detail-head {
2199
+ padding: 22px 24px;
2200
+ border-bottom: 1px solid var(--line);
2201
+ }
2202
+
2203
+ .hero h1 {
2204
+ margin: 0;
2205
+ font-family: var(--serif);
2206
+ font-size: clamp(2rem, 3vw, 2.8rem);
2207
+ font-weight: 600;
2208
+ letter-spacing: -0.04em;
2209
+ }
2210
+
2211
+ .hero p, .toolbar p {
2212
+ margin: 8px 0 0;
2213
+ color: var(--muted);
2214
+ line-height: 1.5;
2215
+ }
2216
+
2217
+ .search,
2218
+ .select,
2219
+ .field-input {
2220
+ width: 100%;
2221
+ margin-top: 16px;
2222
+ padding: 12px 14px;
2223
+ border: 1px solid var(--line);
2224
+ border-radius: 999px;
2225
+ background: rgba(255, 255, 255, 0.7);
2226
+ color: var(--ink);
2227
+ font: inherit;
2228
+ }
2229
+
2230
+ .field-row {
2231
+ display: grid;
2232
+ gap: 10px;
2233
+ margin-top: 12px;
2234
+ }
2235
+
2236
+ .field-row--inline {
2237
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2238
+ }
2239
+
2240
+ .field-label {
2241
+ display: block;
2242
+ margin-bottom: 6px;
2243
+ color: var(--muted);
2244
+ font-size: 0.78rem;
2245
+ font-weight: 700;
2246
+ letter-spacing: 0.08em;
2247
+ text-transform: uppercase;
2248
+ }
2249
+
2250
+ .meeting-list {
2251
+ padding: 14px;
2252
+ overflow: auto;
2253
+ }
2254
+
2255
+ .meeting-row {
2256
+ width: 100%;
2257
+ display: grid;
2258
+ gap: 4px;
2259
+ text-align: left;
2260
+ margin: 0 0 10px;
2261
+ padding: 14px 16px;
2262
+ border: 1px solid transparent;
2263
+ border-radius: 18px;
2264
+ background: rgba(255, 255, 255, 0.72);
2265
+ color: inherit;
2266
+ cursor: pointer;
2267
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2268
+ }
2269
+
2270
+ .meeting-row:hover,
2271
+ .meeting-row[data-selected="true"] {
2272
+ transform: translateY(-1px);
2273
+ border-color: rgba(13, 106, 109, 0.25);
2274
+ background: var(--panel-strong);
2275
+ }
2276
+
2277
+ .meeting-row__title {
2278
+ font-weight: 600;
2279
+ }
2280
+
2281
+ .meeting-row__meta {
2282
+ color: var(--muted);
2283
+ font-size: 0.92rem;
2284
+ }
2285
+
2286
+ .meeting-empty {
2287
+ padding: 18px;
2288
+ color: var(--muted);
2289
+ }
2290
+
2291
+ .meeting-empty--error {
2292
+ color: var(--error);
2293
+ }
2294
+
2295
+ .detail {
2296
+ display: grid;
2297
+ grid-template-rows: auto auto 1fr;
2298
+ min-width: 0;
2299
+ }
2300
+
2301
+ .detail-head {
2302
+ display: flex;
2303
+ align-items: center;
2304
+ justify-content: space-between;
2305
+ gap: 18px;
2306
+ }
2307
+
2308
+ .detail-head h2 {
2309
+ margin: 0;
2310
+ font-family: var(--serif);
2311
+ font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2312
+ font-weight: 600;
2313
+ }
2314
+
2315
+ .state-badge {
2316
+ padding: 10px 14px;
2317
+ border-radius: 999px;
2318
+ background: var(--accent-soft);
2319
+ color: var(--accent);
2320
+ font-size: 0.92rem;
2321
+ font-weight: 700;
2322
+ }
2323
+
2324
+ .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2325
+ .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2326
+ .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2327
+
2328
+ .toolbar {
2329
+ display: flex;
2330
+ flex-wrap: wrap;
2331
+ align-items: center;
2332
+ justify-content: space-between;
2333
+ gap: 14px;
2334
+ }
2335
+
2336
+ .toolbar-actions {
2337
+ display: flex;
2338
+ flex-wrap: wrap;
2339
+ gap: 10px;
2340
+ }
2341
+
2342
+ .toolbar-form {
2343
+ display: grid;
2344
+ grid-template-columns: minmax(0, 1fr) auto;
2345
+ gap: 10px;
2346
+ width: min(440px, 100%);
2347
+ }
2348
+
2349
+ .button {
2350
+ border: 0;
2351
+ border-radius: 999px;
2352
+ padding: 12px 16px;
2353
+ font: inherit;
2354
+ font-weight: 700;
2355
+ cursor: pointer;
2356
+ }
2357
+
2358
+ .button--primary {
2359
+ background: var(--ink);
2360
+ color: white;
2361
+ }
2362
+
2363
+ .button--secondary {
2364
+ background: rgba(255, 255, 255, 0.72);
2365
+ color: var(--ink);
2366
+ border: 1px solid var(--line);
2367
+ }
2368
+
2369
+ .status-grid {
2370
+ display: grid;
2371
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2372
+ gap: 14px;
2373
+ }
2374
+
2375
+ .status-label {
2376
+ display: block;
2377
+ margin-bottom: 6px;
2378
+ color: var(--muted);
2379
+ font-size: 0.78rem;
2380
+ letter-spacing: 0.08em;
2381
+ text-transform: uppercase;
2382
+ }
2383
+
2384
+ .detail-meta {
2385
+ display: flex;
2386
+ flex-wrap: wrap;
2387
+ gap: 10px;
2388
+ padding: 0 24px 18px;
2389
+ }
2390
+
2391
+ .detail-chip {
2392
+ padding: 10px 12px;
2393
+ border-radius: 999px;
2394
+ background: rgba(255, 255, 255, 0.72);
2395
+ border: 1px solid var(--line);
2396
+ color: var(--muted);
2397
+ font-size: 0.88rem;
2398
+ }
2399
+
2400
+ .detail-body {
2401
+ padding: 0 24px 24px;
2402
+ overflow: auto;
2403
+ }
2404
+
2405
+ .detail-section {
2406
+ margin-bottom: 20px;
2407
+ padding: 20px;
2408
+ background: rgba(255, 255, 255, 0.72);
2409
+ border: 1px solid var(--line);
2410
+ border-radius: 20px;
2411
+ }
2412
+
2413
+ .detail-section h2 {
2414
+ margin: 0 0 14px;
2415
+ font-size: 1rem;
2416
+ letter-spacing: 0.08em;
2417
+ text-transform: uppercase;
2418
+ }
2419
+
2420
+ .detail-pre {
2421
+ margin: 0;
2422
+ white-space: pre-wrap;
2423
+ word-break: break-word;
2424
+ font-family: var(--mono);
2425
+ line-height: 1.55;
2426
+ }
2427
+
2428
+ .empty {
2429
+ margin: 24px;
2430
+ padding: 24px;
2431
+ border-radius: 20px;
2432
+ background: rgba(255, 255, 255, 0.72);
2433
+ border: 1px dashed rgba(36, 39, 44, 0.2);
2434
+ color: var(--muted);
2435
+ }
2436
+
2437
+ @media (max-width: 900px) {
2438
+ .shell {
2439
+ grid-template-columns: 1fr;
2440
+ }
2441
+
2442
+ .field-row--inline,
2443
+ .toolbar-form {
2444
+ grid-template-columns: 1fr;
2445
+ }
2446
+ }
2447
+ </style>
2448
+ </head>
2449
+ <body>
2450
+ <div class="shell">
2451
+ <aside class="pane sidebar">
2452
+ <section class="hero">
2453
+ <h1>Granola Toolkit</h1>
2454
+ <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
2455
+ <input class="search" data-search placeholder="Search meetings, ids, or tags" />
2456
+ <div class="field-row field-row--inline">
2457
+ <label>
2458
+ <span class="field-label">Sort</span>
2459
+ <select class="select" data-sort>
2460
+ <option value="updated-desc">Newest first</option>
2461
+ <option value="updated-asc">Oldest first</option>
2462
+ <option value="title-asc">Title A-Z</option>
2463
+ <option value="title-desc">Title Z-A</option>
2464
+ </select>
2465
+ </label>
2466
+ <label>
2467
+ <span class="field-label">Updated From</span>
2468
+ <input class="field-input" data-updated-from type="date" />
2469
+ </label>
2470
+ </div>
2471
+ <label class="field-row">
2472
+ <span class="field-label">Updated To</span>
2473
+ <input class="field-input" data-updated-to type="date" />
2474
+ </label>
2475
+ </section>
2476
+ <section class="toolbar">
2477
+ <div>
2478
+ <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
2479
+ </div>
2480
+ <div class="toolbar-form">
2481
+ <input class="field-input" data-quick-open placeholder="Quick open by id or title" />
2482
+ <button class="button button--secondary" data-quick-open-button>Open</button>
2483
+ </div>
2484
+ </section>
2485
+ <section class="meeting-list" data-meeting-list></section>
2486
+ </aside>
2487
+ <main class="pane detail">
2488
+ <section class="detail-head">
2489
+ <div>
2490
+ <h2>Meeting Workspace</h2>
2491
+ <div data-app-state></div>
2492
+ </div>
2493
+ <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2494
+ </section>
2495
+ <section class="toolbar">
2496
+ <div class="toolbar-actions">
2497
+ <button class="button button--primary" data-refresh>Refresh</button>
2498
+ <button class="button button--secondary" data-export-notes>Export Notes</button>
2499
+ <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2500
+ </div>
2501
+ <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2502
+ </section>
2503
+ <div class="detail-meta" data-detail-meta></div>
2504
+ <div class="detail-body" data-detail-body>
2505
+ <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2506
+ </div>
2507
+ </main>
2508
+ </div>
2509
+ <script type="module">
2510
+ ${String.raw`
2511
+ const state = {
2512
+ appState: null,
2513
+ detailError: "",
2514
+ listError: "",
2515
+ meetings: [],
2516
+ quickOpen: "",
2517
+ search: "",
2518
+ selectedMeeting: null,
2519
+ selectedMeetingId: null,
2520
+ sort: "updated-desc",
2521
+ updatedFrom: "",
2522
+ updatedTo: "",
2523
+ };
2524
+
2525
+ const els = {
2526
+ appState: document.querySelector("[data-app-state]"),
2527
+ detailBody: document.querySelector("[data-detail-body]"),
2528
+ detailMeta: document.querySelector("[data-detail-meta]"),
2529
+ empty: document.querySelector("[data-empty]"),
2530
+ list: document.querySelector("[data-meeting-list]"),
2531
+ noteButton: document.querySelector("[data-export-notes]"),
2532
+ quickOpen: document.querySelector("[data-quick-open]"),
2533
+ quickOpenButton: document.querySelector("[data-quick-open-button]"),
2534
+ refreshButton: document.querySelector("[data-refresh]"),
2535
+ search: document.querySelector("[data-search]"),
2536
+ sort: document.querySelector("[data-sort]"),
2537
+ stateBadge: document.querySelector("[data-state-badge]"),
2538
+ transcriptButton: document.querySelector("[data-export-transcripts]"),
2539
+ updatedFrom: document.querySelector("[data-updated-from]"),
2540
+ updatedTo: document.querySelector("[data-updated-to]"),
2541
+ };
2542
+
2543
+ function escapeHtml(value) {
2544
+ return value
2545
+ .replaceAll("&", "&amp;")
2546
+ .replaceAll("<", "&lt;")
2547
+ .replaceAll(">", "&gt;")
2548
+ .replaceAll('"', "&quot;");
2549
+ }
2550
+
2551
+ function setStatus(label, tone = "idle") {
2552
+ els.stateBadge.textContent = label;
2553
+ els.stateBadge.dataset.tone = tone;
2554
+ }
2555
+
2556
+ function syncFilterInputs() {
2557
+ els.quickOpen.value = state.quickOpen;
2558
+ els.search.value = state.search;
2559
+ els.sort.value = state.sort;
2560
+ els.updatedFrom.value = state.updatedFrom;
2561
+ els.updatedTo.value = state.updatedTo;
2562
+ }
2563
+
2564
+ function currentFilterSummary() {
2565
+ const parts = [];
2566
+
2567
+ if (state.search) {
2568
+ parts.push('search "' + state.search + '"');
2569
+ }
2570
+
2571
+ if (state.updatedFrom) {
2572
+ parts.push("from " + state.updatedFrom);
2573
+ }
2574
+
2575
+ if (state.updatedTo) {
2576
+ parts.push("to " + state.updatedTo);
2577
+ }
2578
+
2579
+ return parts.join(", ");
2580
+ }
2581
+
2582
+ function renderAppState() {
2583
+ if (!state.appState) {
2584
+ els.appState.innerHTML = "<p>Waiting for server state…</p>";
2585
+ return;
2586
+ }
2587
+
2588
+ const appState = state.appState;
2589
+ const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
2590
+ const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
2591
+ const cache = appState.cache.loaded
2592
+ ? appState.cache.transcriptCount + " transcript sets"
2593
+ : appState.cache.configured
2594
+ ? "configured"
2595
+ : "not configured";
2596
+
2597
+ els.appState.innerHTML = [
2598
+ '<div class="status-grid">',
2599
+ '<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
2600
+ '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
2601
+ '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2602
+ '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2603
+ '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2604
+ "</div>",
2605
+ ].join("");
2606
+ }
2607
+
2608
+ function renderMeetingList() {
2609
+ if (state.listError) {
2610
+ els.list.innerHTML =
2611
+ '<div class="meeting-empty meeting-empty--error">' + escapeHtml(state.listError) + "</div>";
2612
+ return;
2613
+ }
2614
+
2615
+ if (state.meetings.length === 0) {
2616
+ state.selectedMeetingId = null;
2617
+ state.selectedMeeting = null;
2618
+ const filterSummary = currentFilterSummary();
2619
+ const message = filterSummary
2620
+ ? "No meetings match " + filterSummary + "."
2621
+ : "No meetings yet. Try Refresh.";
2622
+ els.list.innerHTML = '<div class="meeting-empty">' + escapeHtml(message) + "</div>";
2623
+ renderMeetingDetail();
2624
+ return;
2625
+ }
2626
+
2627
+ const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
2628
+ if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
2629
+ state.selectedMeetingId = state.meetings[0]?.id || null;
2630
+ }
2631
+
2632
+ els.list.innerHTML = state.meetings
2633
+ .map((meeting) => {
2634
+ const selected = meeting.id === state.selectedMeetingId ? ' data-selected="true"' : "";
2635
+ const tags = meeting.tags.length ? meeting.tags.map((tag) => "#" + tag).join(" ") : "untagged";
2636
+ return [
2637
+ '<button class="meeting-row"' + selected + ' data-meeting-id="' + escapeHtml(meeting.id) + '">',
2638
+ '<span class="meeting-row__title">' + escapeHtml(meeting.title || meeting.id) + "</span>",
2639
+ '<span class="meeting-row__meta">' + escapeHtml(tags) + "</span>",
2640
+ '<span class="meeting-row__meta">' + escapeHtml(meeting.updatedAt.slice(0, 10) || "unknown") + "</span>",
2641
+ "</button>",
2642
+ ].join("");
2643
+ })
2644
+ .join("");
2645
+ }
2646
+
2647
+ function renderMeetingDetail() {
2648
+ if (state.detailError) {
2649
+ els.empty.hidden = false;
2650
+ els.empty.textContent = state.detailError;
2651
+ els.detailMeta.innerHTML = "";
2652
+ els.detailBody.innerHTML = "";
2653
+ return;
2654
+ }
2655
+
2656
+ const record = state.selectedMeeting;
2657
+ if (!record) {
2658
+ els.empty.hidden = false;
2659
+ els.empty.textContent = "Select a meeting to inspect its notes and transcript.";
2660
+ els.detailMeta.innerHTML = "";
2661
+ els.detailBody.innerHTML = "";
2662
+ return;
2663
+ }
2664
+
2665
+ els.empty.hidden = true;
2666
+ els.detailMeta.innerHTML = [
2667
+ '<div class="detail-chip">ID: ' + escapeHtml(record.meeting.id) + "</div>",
2668
+ '<div class="detail-chip">Source: ' + escapeHtml(record.meeting.noteContentSource) + "</div>",
2669
+ '<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
2670
+ ].join("");
2671
+
2672
+ els.detailBody.innerHTML = [
2673
+ '<section class="detail-section">',
2674
+ "<h2>Notes</h2>",
2675
+ '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2676
+ "</section>",
2677
+ '<section class="detail-section">',
2678
+ "<h2>Transcript</h2>",
2679
+ '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
2680
+ "</section>",
2681
+ ].join("");
2682
+ }
2683
+
2684
+ async function fetchJson(path, init) {
2685
+ const response = await fetch(path, init);
2686
+ const payload = await response.json().catch(() => ({}));
2687
+ if (!response.ok) {
2688
+ throw new Error(payload.error || response.statusText || "Request failed");
2689
+ }
2690
+ return payload;
2691
+ }
2692
+
2693
+ function buildMeetingsQuery(limit = 100) {
2694
+ const params = new URLSearchParams();
2695
+ params.set("limit", String(limit));
2696
+ params.set("sort", state.sort);
2697
+
2698
+ if (state.search) {
2699
+ params.set("search", state.search);
2700
+ }
2701
+
2702
+ if (state.updatedFrom) {
2703
+ params.set("updatedFrom", state.updatedFrom);
2704
+ }
2705
+
2706
+ if (state.updatedTo) {
2707
+ params.set("updatedTo", state.updatedTo);
2708
+ }
2709
+
2710
+ return "?" + params.toString();
2711
+ }
2712
+
2713
+ async function loadMeetings(options = {}) {
2714
+ const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
2715
+
2716
+ try {
2717
+ state.listError = "";
2718
+ const payload = await fetchJson("/meetings" + buildMeetingsQuery());
2719
+ state.meetings = payload.meetings || [];
2720
+
2721
+ if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
2722
+ state.selectedMeetingId = preferredMeetingId;
2723
+ }
2724
+
2725
+ renderMeetingList();
2726
+ if (state.selectedMeetingId) {
2727
+ await loadMeeting(state.selectedMeetingId);
2728
+ return;
2729
+ }
2730
+
2731
+ state.detailError = "";
2732
+ renderMeetingDetail();
2733
+ } catch (error) {
2734
+ state.listError = error instanceof Error ? error.message : String(error);
2735
+ state.selectedMeeting = null;
2736
+ state.detailError = state.listError;
2737
+ renderMeetingList();
2738
+ renderMeetingDetail();
2739
+ }
2740
+ }
2741
+
2742
+ async function loadMeeting(id) {
2743
+ state.selectedMeetingId = id;
2744
+ renderMeetingList();
2745
+
2746
+ try {
2747
+ state.detailError = "";
2748
+ const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2749
+ state.selectedMeeting = payload.meeting || null;
2750
+ renderMeetingDetail();
2751
+ } catch (error) {
2752
+ state.selectedMeeting = null;
2753
+ state.detailError = error instanceof Error ? error.message : String(error);
2754
+ renderMeetingDetail();
2755
+ }
2756
+ }
2757
+
2758
+ async function quickOpenMeeting() {
2759
+ const query = els.quickOpen.value.trim();
2760
+ if (!query) {
2761
+ setStatus("Enter a title or id", "error");
2762
+ return;
2763
+ }
2764
+
2765
+ setStatus("Opening meeting…", "busy");
2766
+
2767
+ try {
2768
+ state.quickOpen = query;
2769
+ const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
2770
+ state.search = "";
2771
+ state.updatedFrom = "";
2772
+ state.updatedTo = "";
2773
+ syncFilterInputs();
2774
+ await loadMeetings({
2775
+ preferredMeetingId: payload.document.id,
2776
+ });
2777
+ setStatus("Connected", "ok");
2778
+ } catch (error) {
2779
+ state.detailError = error instanceof Error ? error.message : String(error);
2780
+ renderMeetingDetail();
2781
+ setStatus("Quick open failed", "error");
2782
+ }
2783
+ }
2784
+
2785
+ async function refreshAll() {
2786
+ setStatus("Refreshing…", "busy");
2787
+ const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2788
+ state.appState = appState;
2789
+ renderAppState();
2790
+ setStatus("Connected", "ok");
2791
+ }
2792
+
2793
+ async function exportNotes() {
2794
+ setStatus("Exporting notes…", "busy");
2795
+ await fetchJson("/exports/notes", {
2796
+ body: JSON.stringify({ format: "markdown" }),
2797
+ headers: { "content-type": "application/json" },
2798
+ method: "POST",
2799
+ });
2800
+ await refreshAll();
2801
+ }
2802
+
2803
+ async function exportTranscripts() {
2804
+ setStatus("Exporting transcripts…", "busy");
2805
+ await fetchJson("/exports/transcripts", {
2806
+ body: JSON.stringify({ format: "text" }),
2807
+ headers: { "content-type": "application/json" },
2808
+ method: "POST",
2809
+ });
2810
+ await refreshAll();
2811
+ }
2812
+
2813
+ els.list.addEventListener("click", (event) => {
2814
+ if (!(event.target instanceof Element)) {
2815
+ return;
2816
+ }
2817
+
2818
+ const button = event.target.closest("[data-meeting-id]");
2819
+ if (!button) return;
2820
+ void loadMeeting(button.dataset.meetingId);
2821
+ });
2822
+
2823
+ els.refreshButton.addEventListener("click", () => {
2824
+ void refreshAll();
2825
+ });
2826
+
2827
+ els.noteButton.addEventListener("click", () => {
2828
+ void exportNotes();
2829
+ });
2830
+
2831
+ els.transcriptButton.addEventListener("click", () => {
2832
+ void exportTranscripts();
2833
+ });
2834
+
2835
+ els.search.addEventListener("input", (event) => {
2836
+ if (!(event.target instanceof HTMLInputElement)) {
2837
+ return;
2838
+ }
2839
+
2840
+ state.search = event.target.value.trim();
2841
+ void loadMeetings();
2842
+ });
2843
+
2844
+ els.sort.addEventListener("change", (event) => {
2845
+ if (!(event.target instanceof HTMLSelectElement)) {
2846
+ return;
2847
+ }
2848
+
2849
+ state.sort = event.target.value;
2850
+ void loadMeetings();
2851
+ });
2852
+
2853
+ els.updatedFrom.addEventListener("change", (event) => {
2854
+ if (!(event.target instanceof HTMLInputElement)) {
2855
+ return;
2856
+ }
2857
+
2858
+ state.updatedFrom = event.target.value;
2859
+ void loadMeetings();
2860
+ });
2861
+
2862
+ els.updatedTo.addEventListener("change", (event) => {
2863
+ if (!(event.target instanceof HTMLInputElement)) {
2864
+ return;
2865
+ }
2866
+
2867
+ state.updatedTo = event.target.value;
2868
+ void loadMeetings();
2869
+ });
2870
+
2871
+ els.quickOpen.addEventListener("input", (event) => {
2872
+ if (!(event.target instanceof HTMLInputElement)) {
2873
+ return;
2874
+ }
2875
+
2876
+ state.quickOpen = event.target.value;
2877
+ });
2878
+
2879
+ els.quickOpen.addEventListener("keydown", (event) => {
2880
+ if (!(event.target instanceof HTMLInputElement)) {
2881
+ return;
2882
+ }
2883
+
2884
+ if (event.key === "Enter") {
2885
+ event.preventDefault();
2886
+ void quickOpenMeeting();
2887
+ }
2888
+ });
2889
+
2890
+ els.quickOpenButton.addEventListener("click", () => {
2891
+ void quickOpenMeeting();
2892
+ });
2893
+
2894
+ const events = new EventSource("/events");
2895
+ events.addEventListener("state.updated", (event) => {
2896
+ const payload = JSON.parse(event.data);
2897
+ state.appState = payload.state;
2898
+ renderAppState();
2899
+ });
2900
+ events.addEventListener("error", () => {
2901
+ setStatus("Disconnected", "error");
2902
+ });
2903
+
2904
+ syncFilterInputs();
2905
+
2906
+ void refreshAll().catch((error) => {
2907
+ setStatus("Error", "error");
2908
+ els.empty.hidden = false;
2909
+ els.empty.textContent = error.message;
2910
+ });
2911
+ `}
2912
+ <\/script>
2913
+ </body>
2914
+ </html>`;
2915
+ }
2916
+ //#endregion
2035
2917
  //#region src/server/http.ts
2036
2918
  function parseInteger(value) {
2037
2919
  if (!value?.trim()) return;
@@ -2040,6 +2922,17 @@ function parseInteger(value) {
2040
2922
  if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
2041
2923
  return parsed;
2042
2924
  }
2925
+ function parseMeetingSort(value) {
2926
+ switch (value) {
2927
+ case null:
2928
+ case "": return;
2929
+ case "title-asc":
2930
+ case "title-desc":
2931
+ case "updated-asc":
2932
+ case "updated-desc": return value;
2933
+ default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
2934
+ }
2935
+ }
2043
2936
  function sendJson(response, body, init = {}) {
2044
2937
  const payload = `${JSON.stringify(body, null, 2)}\n`;
2045
2938
  response.writeHead(init.status ?? 200, {
@@ -2055,6 +2948,13 @@ function sendText(response, body, status = 200) {
2055
2948
  });
2056
2949
  response.end(body);
2057
2950
  }
2951
+ function sendHtml(response, body, status = 200) {
2952
+ response.writeHead(status, {
2953
+ "content-length": Buffer.byteLength(body),
2954
+ "content-type": "text/html; charset=utf-8"
2955
+ });
2956
+ response.end(body);
2957
+ }
2058
2958
  async function readJsonBody(request) {
2059
2959
  const chunks = [];
2060
2960
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -2091,6 +2991,7 @@ function transcriptFormatFromBody(value) {
2091
2991
  }
2092
2992
  }
2093
2993
  async function startGranolaServer(app, options = {}) {
2994
+ const enableWebClient = options.enableWebClient ?? false;
2094
2995
  const hostname = options.hostname ?? "127.0.0.1";
2095
2996
  const port = options.port ?? 0;
2096
2997
  const server = createServer(async (request, response) => {
@@ -2098,6 +2999,10 @@ async function startGranolaServer(app, options = {}) {
2098
2999
  const url = new URL(request.url ?? "/", `http://${hostname}`);
2099
3000
  const path = url.pathname;
2100
3001
  try {
3002
+ if (method === "GET" && path === "/" && enableWebClient) {
3003
+ sendHtml(response, renderGranolaWebPage());
3004
+ return;
3005
+ }
2101
3006
  if (method === "GET" && path === "/health") {
2102
3007
  sendJson(response, {
2103
3008
  ok: true,
@@ -2133,15 +3038,30 @@ async function startGranolaServer(app, options = {}) {
2133
3038
  if (method === "GET" && path === "/meetings") {
2134
3039
  const limit = parseInteger(url.searchParams.get("limit"));
2135
3040
  const search = url.searchParams.get("search")?.trim() || void 0;
3041
+ const sort = parseMeetingSort(url.searchParams.get("sort"));
3042
+ const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
3043
+ const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
2136
3044
  sendJson(response, {
2137
3045
  meetings: await app.listMeetings({
2138
3046
  limit,
2139
- search
3047
+ search,
3048
+ sort,
3049
+ updatedFrom,
3050
+ updatedTo
2140
3051
  }),
2141
- search
3052
+ search,
3053
+ sort,
3054
+ updatedFrom,
3055
+ updatedTo
2142
3056
  });
2143
3057
  return;
2144
3058
  }
3059
+ if (method === "GET" && path === "/meetings/resolve") {
3060
+ const query = url.searchParams.get("q")?.trim();
3061
+ if (!query) throw new Error("meeting query is required");
3062
+ sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
3063
+ return;
3064
+ }
2145
3065
  if (method === "GET" && path.startsWith("/meetings/")) {
2146
3066
  const id = decodeURIComponent(path.slice(10));
2147
3067
  if (!id) throw new Error("meeting id is required");
@@ -2212,13 +3132,6 @@ Options:
2212
3132
  -h, --help Show help
2213
3133
  `;
2214
3134
  }
2215
- function parsePort(value) {
2216
- if (value === void 0) return;
2217
- if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
2218
- const port = Number(value);
2219
- if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
2220
- return port;
2221
- }
2222
3135
  const serveCommand = {
2223
3136
  description: "Start a local Granola API server",
2224
3137
  flags: {
@@ -2240,7 +3153,7 @@ const serveCommand = {
2240
3153
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2241
3154
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2242
3155
  const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
2243
- hostname: typeof commandFlags.hostname === "string" && commandFlags.hostname.trim() ? commandFlags.hostname.trim() : "127.0.0.1",
3156
+ hostname: pickHostname(commandFlags.hostname),
2244
3157
  port: parsePort(commandFlags.port)
2245
3158
  });
2246
3159
  console.log(`Granola server listening on ${server.url.href}`);
@@ -2252,26 +3165,7 @@ const serveCommand = {
2252
3165
  console.log(" GET /meetings/:id");
2253
3166
  console.log(" POST /exports/notes");
2254
3167
  console.log(" POST /exports/transcripts");
2255
- await new Promise((resolve, reject) => {
2256
- let closing = false;
2257
- const close = async () => {
2258
- if (closing) return;
2259
- closing = true;
2260
- process.off("SIGINT", handleSignal);
2261
- process.off("SIGTERM", handleSignal);
2262
- try {
2263
- await server.close();
2264
- resolve();
2265
- } catch (error) {
2266
- reject(error);
2267
- }
2268
- };
2269
- const handleSignal = () => {
2270
- close();
2271
- };
2272
- process.on("SIGINT", handleSignal);
2273
- process.on("SIGTERM", handleSignal);
2274
- });
3168
+ await waitForShutdown(async () => await server.close());
2275
3169
  return 0;
2276
3170
  }
2277
3171
  };
@@ -2331,13 +3225,115 @@ function resolveTranscriptFormat(value) {
2331
3225
  }
2332
3226
  }
2333
3227
  //#endregion
3228
+ //#region src/browser.ts
3229
+ const execFileAsync = promisify(execFile);
3230
+ function getBrowserOpenCommand(url, platform = process.platform) {
3231
+ const href = String(url);
3232
+ switch (platform) {
3233
+ case "darwin": return {
3234
+ args: [href],
3235
+ file: "open"
3236
+ };
3237
+ case "win32": return {
3238
+ args: [
3239
+ "/c",
3240
+ "start",
3241
+ "",
3242
+ href
3243
+ ],
3244
+ file: "cmd"
3245
+ };
3246
+ default: return {
3247
+ args: [href],
3248
+ file: "xdg-open"
3249
+ };
3250
+ }
3251
+ }
3252
+ async function openExternalUrl(url, options = {}) {
3253
+ const command = getBrowserOpenCommand(url, options.platform);
3254
+ await (options.run ?? (async (file, args) => {
3255
+ await execFileAsync(file, args);
3256
+ }))(command.file, command.args);
3257
+ }
3258
+ //#endregion
3259
+ //#region src/commands/web.ts
3260
+ function webHelp() {
3261
+ return `Granola web
3262
+
3263
+ Usage:
3264
+ granola web [options]
3265
+
3266
+ Options:
3267
+ --hostname <value> Hostname to bind (default: 127.0.0.1)
3268
+ --port <value> Port to bind (default: 0 for any available port)
3269
+ --cache <path> Path to Granola cache JSON
3270
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
3271
+ --supabase <path> Path to supabase.json
3272
+ --open[=true|false] Open the browser automatically (default: true)
3273
+ --debug Enable debug logging
3274
+ --config <path> Path to .granola.toml
3275
+ -h, --help Show help
3276
+ `;
3277
+ }
3278
+ //#endregion
2334
3279
  //#region src/commands/index.ts
2335
3280
  const commands = [
2336
3281
  authCommand,
2337
3282
  meetingCommand,
2338
3283
  notesCommand,
2339
3284
  serveCommand,
2340
- transcriptsCommand
3285
+ transcriptsCommand,
3286
+ {
3287
+ description: "Start the Granola Toolkit web workspace",
3288
+ flags: {
3289
+ cache: { type: "string" },
3290
+ help: { type: "boolean" },
3291
+ hostname: { type: "string" },
3292
+ open: { type: "boolean" },
3293
+ port: { type: "string" },
3294
+ timeout: { type: "string" }
3295
+ },
3296
+ help: webHelp,
3297
+ name: "web",
3298
+ async run({ commandFlags, globalFlags }) {
3299
+ const config = await loadConfig({
3300
+ globalFlags,
3301
+ subcommandFlags: commandFlags
3302
+ });
3303
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
3304
+ debug(config.debug, "supabase", config.supabase);
3305
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
3306
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
3307
+ const app = await createGranolaApp(config, { surface: "web" });
3308
+ const hostname = pickHostname(commandFlags.hostname);
3309
+ const port = parsePort(commandFlags.port);
3310
+ const openBrowser = commandFlags.open !== false;
3311
+ const server = await startGranolaServer(app, {
3312
+ enableWebClient: true,
3313
+ hostname,
3314
+ port
3315
+ });
3316
+ console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
3317
+ console.log("Routes:");
3318
+ console.log(" GET /");
3319
+ console.log(" GET /health");
3320
+ console.log(" GET /state");
3321
+ console.log(" GET /events");
3322
+ console.log(" GET /meetings");
3323
+ console.log(" GET /meetings/:id");
3324
+ console.log(" POST /exports/notes");
3325
+ console.log(" POST /exports/transcripts");
3326
+ if (openBrowser) try {
3327
+ await openExternalUrl(server.url);
3328
+ } catch (error) {
3329
+ const message = error instanceof Error ? error.message : String(error);
3330
+ console.error(`failed to open browser automatically: ${message}`);
3331
+ console.error(`open ${server.url.href} manually`);
3332
+ }
3333
+ await waitForShutdown(async () => await server.close());
3334
+ return 0;
3335
+ }
3336
+ }
2341
3337
  ];
2342
3338
  const commandMap = new Map(commands.map((command) => [command.name, command]));
2343
3339
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",