granola-toolkit 0.7.0 → 0.9.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.
- package/README.md +16 -1
- package/dist/cli.js +256 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Installed CLI:
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
granola --help
|
|
34
|
+
granola auth login
|
|
34
35
|
granola notes --help
|
|
35
36
|
granola transcripts --help
|
|
36
37
|
```
|
|
@@ -57,6 +58,9 @@ npm run transcripts -- --help
|
|
|
57
58
|
Export notes:
|
|
58
59
|
|
|
59
60
|
```bash
|
|
61
|
+
granola auth login
|
|
62
|
+
granola notes
|
|
63
|
+
|
|
60
64
|
node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
|
|
61
65
|
node dist/cli.js notes --format json --output ./notes-json
|
|
62
66
|
```
|
|
@@ -76,7 +80,7 @@ node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
|
|
|
76
80
|
|
|
77
81
|
The flow is:
|
|
78
82
|
|
|
79
|
-
1. read your local `supabase.json`
|
|
83
|
+
1. read a stored Granola session, or fall back to your local `supabase.json`
|
|
80
84
|
2. extract the WorkOS access token from it
|
|
81
85
|
3. call Granola's paginated documents API
|
|
82
86
|
4. normalise each document into a structured note export
|
|
@@ -117,6 +121,17 @@ Speaker labels are currently normalised to:
|
|
|
117
121
|
|
|
118
122
|
Structured output formats are useful when you want to post-process exports in scripts instead of reading the default human-oriented Markdown or text files.
|
|
119
123
|
|
|
124
|
+
## Auth
|
|
125
|
+
|
|
126
|
+
If you do not want to keep passing `--supabase`, import the desktop app session once:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
granola auth login
|
|
130
|
+
granola auth status
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
That stores a reusable Granola session locally and lets `granola notes` use it directly. `granola auth logout` deletes the stored session.
|
|
134
|
+
|
|
120
135
|
### Incremental Writes
|
|
121
136
|
|
|
122
137
|
Both commands keep a small hidden state file in the output directory to track:
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
6
8
|
import { createHash } from "node:crypto";
|
|
7
9
|
//#region src/utils.ts
|
|
8
10
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
@@ -162,16 +164,46 @@ function transcriptSpeakerLabel(segment) {
|
|
|
162
164
|
}
|
|
163
165
|
//#endregion
|
|
164
166
|
//#region src/client/auth.ts
|
|
165
|
-
|
|
167
|
+
const execFileAsync = promisify(execFile);
|
|
168
|
+
const DEFAULT_CLIENT_ID = "client_GranolaMac";
|
|
169
|
+
const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
|
|
170
|
+
const KEYCHAIN_ACCOUNT_NAME = "session";
|
|
171
|
+
const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
|
|
172
|
+
function numberValue(value) {
|
|
173
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
174
|
+
}
|
|
175
|
+
function parseSessionRecord(record) {
|
|
176
|
+
const accessToken = stringValue(record.access_token);
|
|
177
|
+
if (!accessToken.trim()) return;
|
|
178
|
+
return {
|
|
179
|
+
accessToken,
|
|
180
|
+
clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
|
|
181
|
+
expiresIn: numberValue(record.expires_in),
|
|
182
|
+
externalId: stringValue(record.external_id) || void 0,
|
|
183
|
+
obtainedAt: stringValue(record.obtained_at) || void 0,
|
|
184
|
+
refreshToken: stringValue(record.refresh_token) || void 0,
|
|
185
|
+
sessionId: stringValue(record.session_id) || void 0,
|
|
186
|
+
signInMethod: stringValue(record.sign_in_method) || void 0,
|
|
187
|
+
tokenType: stringValue(record.token_type) || void 0
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseNestedRecord(value) {
|
|
191
|
+
if (typeof value === "string") return parseJsonString(value);
|
|
192
|
+
return asRecord(value);
|
|
193
|
+
}
|
|
194
|
+
function getSessionFromSupabaseContents(supabaseContents) {
|
|
166
195
|
const wrapper = parseJsonString(supabaseContents);
|
|
167
196
|
if (!wrapper) throw new Error("failed to parse supabase.json");
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
197
|
+
const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
|
|
198
|
+
if (workOsSession) return workOsSession;
|
|
199
|
+
const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
|
|
200
|
+
if (cognitoSession) return cognitoSession;
|
|
201
|
+
const legacySession = parseSessionRecord(wrapper);
|
|
202
|
+
if (legacySession) return legacySession;
|
|
203
|
+
throw new Error("access token not found in supabase.json");
|
|
204
|
+
}
|
|
205
|
+
function getAccessTokenFromSupabaseContents(supabaseContents) {
|
|
206
|
+
return getSessionFromSupabaseContents(supabaseContents).accessToken;
|
|
175
207
|
}
|
|
176
208
|
var SupabaseFileTokenSource = class {
|
|
177
209
|
constructor(filePath) {
|
|
@@ -181,11 +213,85 @@ var SupabaseFileTokenSource = class {
|
|
|
181
213
|
return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
|
|
182
214
|
}
|
|
183
215
|
};
|
|
216
|
+
var SupabaseFileSessionSource = class {
|
|
217
|
+
constructor(filePath) {
|
|
218
|
+
this.filePath = filePath;
|
|
219
|
+
}
|
|
220
|
+
async loadSession() {
|
|
221
|
+
return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
184
224
|
var NoopTokenStore = class {
|
|
185
225
|
async clearToken() {}
|
|
186
226
|
async readToken() {}
|
|
187
227
|
async writeToken(_token) {}
|
|
188
228
|
};
|
|
229
|
+
var FileSessionStore = class {
|
|
230
|
+
constructor(filePath = defaultSessionFilePath()) {
|
|
231
|
+
this.filePath = filePath;
|
|
232
|
+
}
|
|
233
|
+
async clearSession() {
|
|
234
|
+
try {
|
|
235
|
+
await unlink(this.filePath);
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
async readSession() {
|
|
239
|
+
try {
|
|
240
|
+
const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
|
|
241
|
+
return parsed?.accessToken ? parsed : void 0;
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async writeSession(session) {
|
|
247
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
248
|
+
await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
mode: 384
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var KeychainSessionStore = class {
|
|
255
|
+
async clearSession() {
|
|
256
|
+
try {
|
|
257
|
+
await execFileAsync("security", [
|
|
258
|
+
"delete-generic-password",
|
|
259
|
+
"-s",
|
|
260
|
+
KEYCHAIN_SERVICE_NAME,
|
|
261
|
+
"-a",
|
|
262
|
+
KEYCHAIN_ACCOUNT_NAME
|
|
263
|
+
]);
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
async readSession() {
|
|
267
|
+
try {
|
|
268
|
+
const { stdout } = await execFileAsync("security", [
|
|
269
|
+
"find-generic-password",
|
|
270
|
+
"-s",
|
|
271
|
+
KEYCHAIN_SERVICE_NAME,
|
|
272
|
+
"-a",
|
|
273
|
+
KEYCHAIN_ACCOUNT_NAME,
|
|
274
|
+
"-w"
|
|
275
|
+
]);
|
|
276
|
+
const parsed = parseJsonString(stdout.trim());
|
|
277
|
+
return parsed?.accessToken ? parsed : void 0;
|
|
278
|
+
} catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async writeSession(session) {
|
|
283
|
+
await execFileAsync("security", [
|
|
284
|
+
"add-generic-password",
|
|
285
|
+
"-U",
|
|
286
|
+
"-s",
|
|
287
|
+
KEYCHAIN_SERVICE_NAME,
|
|
288
|
+
"-a",
|
|
289
|
+
KEYCHAIN_ACCOUNT_NAME,
|
|
290
|
+
"-w",
|
|
291
|
+
JSON.stringify(session)
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
189
295
|
var CachedTokenProvider = class {
|
|
190
296
|
#token;
|
|
191
297
|
constructor(source, store = new NoopTokenStore()) {
|
|
@@ -209,6 +315,135 @@ var CachedTokenProvider = class {
|
|
|
209
315
|
await this.store.clearToken();
|
|
210
316
|
}
|
|
211
317
|
};
|
|
318
|
+
var StoredSessionTokenProvider = class {
|
|
319
|
+
#session;
|
|
320
|
+
constructor(store, options = {}) {
|
|
321
|
+
this.store = store;
|
|
322
|
+
this.options = options;
|
|
323
|
+
}
|
|
324
|
+
async loadSession() {
|
|
325
|
+
if (this.#session) return this.#session;
|
|
326
|
+
const storedSession = await this.store.readSession();
|
|
327
|
+
if (storedSession?.accessToken.trim()) {
|
|
328
|
+
this.#session = storedSession;
|
|
329
|
+
return storedSession;
|
|
330
|
+
}
|
|
331
|
+
if (!this.options.source) throw new Error("no stored Granola session found");
|
|
332
|
+
const sourcedSession = await this.options.source.loadSession();
|
|
333
|
+
this.#session = sourcedSession;
|
|
334
|
+
return sourcedSession;
|
|
335
|
+
}
|
|
336
|
+
async getAccessToken() {
|
|
337
|
+
return (await this.loadSession()).accessToken;
|
|
338
|
+
}
|
|
339
|
+
async invalidate() {
|
|
340
|
+
const session = await this.loadSession().catch(() => void 0);
|
|
341
|
+
if (session?.refreshToken && session.clientId) {
|
|
342
|
+
const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
|
|
343
|
+
this.#session = refreshedSession;
|
|
344
|
+
await this.store.writeSession(refreshedSession);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (this.options.source) {
|
|
348
|
+
this.#session = await this.options.source.loadSession();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.#session = void 0;
|
|
352
|
+
await this.store.clearSession();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
async function refreshGranolaSession(session, fetchImpl = fetch) {
|
|
356
|
+
if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
|
|
357
|
+
const response = await fetchImpl(WORKOS_AUTH_URL, {
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
client_id: session.clientId,
|
|
360
|
+
grant_type: "refresh_token",
|
|
361
|
+
refresh_token: session.refreshToken
|
|
362
|
+
}),
|
|
363
|
+
headers: { "Content-Type": "application/json" },
|
|
364
|
+
method: "POST"
|
|
365
|
+
});
|
|
366
|
+
if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
|
|
367
|
+
const refreshed = parseSessionRecord(await response.json());
|
|
368
|
+
if (!refreshed) throw new Error("failed to parse refreshed session");
|
|
369
|
+
return {
|
|
370
|
+
...session,
|
|
371
|
+
...refreshed,
|
|
372
|
+
clientId: refreshed.clientId || session.clientId,
|
|
373
|
+
obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
374
|
+
refreshToken: refreshed.refreshToken ?? session.refreshToken
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function defaultSessionFilePath() {
|
|
378
|
+
const home = homedir();
|
|
379
|
+
return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
|
|
380
|
+
}
|
|
381
|
+
function createDefaultSessionStore() {
|
|
382
|
+
return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/commands/auth.ts
|
|
386
|
+
function authHelp() {
|
|
387
|
+
return `Granola auth
|
|
388
|
+
|
|
389
|
+
Usage:
|
|
390
|
+
granola auth <login|status|logout>
|
|
391
|
+
|
|
392
|
+
Subcommands:
|
|
393
|
+
login Import credentials from the Granola desktop app
|
|
394
|
+
status Show whether a stored Granola session is available
|
|
395
|
+
logout Delete the stored Granola session
|
|
396
|
+
|
|
397
|
+
Options:
|
|
398
|
+
--supabase <path> Path to supabase.json for auth login
|
|
399
|
+
-h, --help Show help
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
const authCommand = {
|
|
403
|
+
description: "Manage stored Granola sessions",
|
|
404
|
+
flags: { help: { type: "boolean" } },
|
|
405
|
+
help: authHelp,
|
|
406
|
+
name: "auth",
|
|
407
|
+
async run({ commandArgs, globalFlags }) {
|
|
408
|
+
const [action] = commandArgs;
|
|
409
|
+
switch (action) {
|
|
410
|
+
case "login": return await login(globalFlags.supabase);
|
|
411
|
+
case "logout": return await logout();
|
|
412
|
+
case "status": return await status();
|
|
413
|
+
case void 0:
|
|
414
|
+
console.log(authHelp());
|
|
415
|
+
return 1;
|
|
416
|
+
default: throw new Error("invalid auth command: expected login, status, or logout");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
async function login(supabaseFlag) {
|
|
421
|
+
const supabasePath = typeof supabaseFlag === "string" && supabaseFlag.trim() || firstExistingPath(granolaSupabaseCandidates());
|
|
422
|
+
if (!supabasePath) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
423
|
+
if (!existsSync(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
|
|
424
|
+
const sessionStore = createDefaultSessionStore();
|
|
425
|
+
const session = await new SupabaseFileSessionSource(supabasePath).loadSession();
|
|
426
|
+
await sessionStore.writeSession(session);
|
|
427
|
+
console.log(`Imported Granola session from ${supabasePath}`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
async function status() {
|
|
431
|
+
const session = await createDefaultSessionStore().readSession();
|
|
432
|
+
if (!session) {
|
|
433
|
+
console.log("No stored Granola session");
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
console.log("Stored Granola session");
|
|
437
|
+
console.log(`Client ID: ${session.clientId}`);
|
|
438
|
+
console.log(`Refresh token: ${session.refreshToken ? "available" : "missing"}`);
|
|
439
|
+
console.log(`Sign-in method: ${session.signInMethod ?? "unknown"}`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
async function logout() {
|
|
443
|
+
await createDefaultSessionStore().clearSession();
|
|
444
|
+
console.log("Stored Granola session deleted");
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
212
447
|
//#endregion
|
|
213
448
|
//#region src/client/parsers.ts
|
|
214
449
|
function parseProseMirrorDoc(value, options = {}) {
|
|
@@ -819,8 +1054,10 @@ const notesCommand = {
|
|
|
819
1054
|
globalFlags,
|
|
820
1055
|
subcommandFlags: commandFlags
|
|
821
1056
|
});
|
|
822
|
-
|
|
823
|
-
|
|
1057
|
+
const sessionStore = createDefaultSessionStore();
|
|
1058
|
+
const storedSession = await sessionStore.readSession();
|
|
1059
|
+
if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
1060
|
+
if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
824
1061
|
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
825
1062
|
debug(config.debug, "supabase", config.supabase);
|
|
826
1063
|
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
@@ -828,7 +1065,7 @@ const notesCommand = {
|
|
|
828
1065
|
const format = resolveNoteFormat(commandFlags.format);
|
|
829
1066
|
debug(config.debug, "format", format);
|
|
830
1067
|
console.log("Fetching documents from Granola API...");
|
|
831
|
-
const tokenProvider = new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
|
|
1068
|
+
const tokenProvider = storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
|
|
832
1069
|
const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
|
|
833
1070
|
logger: console,
|
|
834
1071
|
tokenProvider
|
|
@@ -1098,7 +1335,11 @@ function resolveTranscriptFormat(value) {
|
|
|
1098
1335
|
}
|
|
1099
1336
|
//#endregion
|
|
1100
1337
|
//#region src/commands/index.ts
|
|
1101
|
-
const commands = [
|
|
1338
|
+
const commands = [
|
|
1339
|
+
authCommand,
|
|
1340
|
+
notesCommand,
|
|
1341
|
+
transcriptsCommand
|
|
1342
|
+
];
|
|
1102
1343
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|
|
1103
1344
|
//#endregion
|
|
1104
1345
|
//#region src/flags.ts
|
|
@@ -1213,6 +1454,7 @@ async function runCli(argv) {
|
|
|
1213
1454
|
return 0;
|
|
1214
1455
|
}
|
|
1215
1456
|
return await command.run({
|
|
1457
|
+
commandArgs: subcommand.rest,
|
|
1216
1458
|
commandFlags: subcommand.values,
|
|
1217
1459
|
globalFlags: global.values
|
|
1218
1460
|
});
|