@terminal49/bridge-cli 0.1.1 → 0.1.3

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 CHANGED
@@ -1,10 +1,11 @@
1
1
  # @terminal49/bridge-cli
2
2
 
3
- Private npm package containing the Bridge CLI meeting search skill for LLMs.
3
+ Public npm package containing the Bridge CLI and meeting search skill for LLMs.
4
4
 
5
5
  ## Contents
6
6
 
7
7
  - `SKILL.md` — instructions for using the Bridge CLI to search Fathom meetings.
8
+ - CLI binary `t49bridge`
8
9
 
9
10
  ## Install
10
11
 
@@ -12,4 +13,23 @@ Private npm package containing the Bridge CLI meeting search skill for LLMs.
12
13
  npm install @terminal49/bridge-cli
13
14
  ```
14
15
 
15
- Then open `node_modules/@terminal49/bridge-cli/SKILL.md`.
16
+ Or install globally:
17
+
18
+ ```bash
19
+ npm install -g @terminal49/bridge-cli
20
+ ```
21
+
22
+ Or run directly:
23
+
24
+ ```bash
25
+ npx @terminal49/bridge-cli -- help
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ t49bridge login
32
+ t49bridge query "export status blocker" --limit 5
33
+ ```
34
+
35
+ Then open `node_modules/@terminal49/bridge-cli/SKILL.md` for the full workflow.
package/SKILL.md CHANGED
@@ -7,33 +7,37 @@ description: Search Terminal49 Fathom meeting transcripts, retrieve meeting deta
7
7
 
8
8
  Search and retrieve meeting data from Terminal49's Fathom.video ingestion platform via the Bridge CLI.
9
9
 
10
- ## Prerequisites
10
+ ## Install
11
11
 
12
- The CLI requires authentication. If commands fail with "Not logged in", run:
12
+ Install the public npm package (CLI included):
13
13
 
14
14
  ```bash
15
- npm run cli:t49bridge -- login
15
+ npm install -g @terminal49/bridge-cli
16
16
  ```
17
17
 
18
- Verify with:
18
+ Or run via npx:
19
19
 
20
20
  ```bash
21
- npm run cli:t49bridge -- whoami
21
+ npx @terminal49/bridge-cli -- help
22
22
  ```
23
23
 
24
- ## Repo Setup
24
+ ## Prerequisites
25
+
26
+ The CLI requires authentication. If commands fail with "Not logged in", run:
25
27
 
26
- Clone the Bridge repo and install dependencies:
28
+ ```bash
29
+ t49bridge login
30
+ ```
31
+
32
+ Verify with:
27
33
 
28
34
  ```bash
29
- git clone https://github.com/Terminal49/bridge.git
30
- cd bridge
31
- npm install
35
+ t49bridge whoami
32
36
  ```
33
37
 
34
38
  ## Commands
35
39
 
36
- Run commands from the cloned repo directory.
40
+ Use `t49bridge` if installed globally or via npx.
37
41
 
38
42
  ### Search Transcripts
39
43
 
@@ -41,13 +45,13 @@ Three search modes — use `query` (hybrid) by default:
41
45
 
42
46
  ```bash
43
47
  # Hybrid search (recommended) — combines keyword + semantic
44
- npm run cli:t49bridge -- query "<natural language question>" --limit 10
48
+ t49bridge query "<natural language question>" --limit 10
45
49
 
46
50
  # Keyword search (BM25) — best for exact terms, names, product names
47
- npm run cli:t49bridge -- search "<keywords>" --limit 10
51
+ t49bridge search "<keywords>" --limit 10
48
52
 
49
53
  # Semantic search — best for conceptual/meaning-based queries
50
- npm run cli:t49bridge -- vsearch "<description of what you're looking for>" --limit 10
54
+ t49bridge vsearch "<description of what you're looking for>" --limit 10
51
55
  ```
52
56
 
53
57
  #### Search filters (work with all three modes)
@@ -74,34 +78,34 @@ npm run cli:t49bridge -- vsearch "<description of what you're looking for>" --li
74
78
 
75
79
  ```bash
76
80
  # Summary and metadata
77
- npm run cli:t49bridge -- get <meetingId>
81
+ t49bridge get <meetingId>
78
82
 
79
83
  # Just the summary
80
- npm run cli:t49bridge -- get <meetingId> --summary
84
+ t49bridge get <meetingId> --summary
81
85
 
82
86
  # Full transcript
83
- npm run cli:t49bridge -- get <meetingId> --transcript
87
+ t49bridge get <meetingId> --transcript
84
88
 
85
89
  # Timestamped segments
86
- npm run cli:t49bridge -- get <meetingId> --segments
90
+ t49bridge get <meetingId> --segments
87
91
 
88
92
  # JSON output
89
- npm run cli:t49bridge -- get <meetingId> --json
93
+ t49bridge get <meetingId> --json
90
94
  ```
91
95
 
92
96
  ### List Meetings
93
97
 
94
98
  ```bash
95
99
  # Recent meetings
96
- npm run cli:t49bridge -- list --limit 20
100
+ t49bridge list --limit 20
97
101
 
98
102
  # Filter by date, company, host, type
99
- npm run cli:t49bridge -- list --from 2026-01-01 --to 2026-01-31 --company "Maersk"
100
- npm run cli:t49bridge -- list --host "akshay" --external --limit 50
101
- npm run cli:t49bridge -- list --attendee "john@example.com"
103
+ t49bridge list --from 2026-01-01 --to 2026-01-31 --company "Maersk"
104
+ t49bridge list --host "akshay" --external --limit 50
105
+ t49bridge list --attendee "john@example.com"
102
106
 
103
107
  # JSON output
104
- npm run cli:t49bridge -- list --json --limit 100
108
+ t49bridge list --json --limit 100
105
109
  ```
106
110
 
107
111
  ## Workflow
@@ -110,23 +114,23 @@ Follow this sequence to answer questions about meetings:
110
114
 
111
115
  1. **Search** — Start with `query` using the question as-is:
112
116
  ```bash
113
- npm run cli:t49bridge -- query "what pricing concerns did Maersk raise" --limit 10
117
+ t49bridge query "what pricing concerns did Maersk raise" --limit 10
114
118
  ```
115
119
 
116
120
  2. **Get details** — Fetch full context for relevant meetings:
117
121
  ```bash
118
- npm run cli:t49bridge -- get <meetingId> --summary
119
- npm run cli:t49bridge -- get <meetingId> --transcript
122
+ t49bridge get <meetingId> --summary
123
+ t49bridge get <meetingId> --transcript
120
124
  ```
121
125
 
122
126
  3. **Narrow** — Add filters if too many results:
123
127
  ```bash
124
- npm run cli:t49bridge -- query "pricing" --company "Maersk" --from 2026-01-01 --full-text
128
+ t49bridge query "pricing" --company "Maersk" --from 2026-01-01 --full-text
125
129
  ```
126
130
 
127
131
  4. **Browse** — Use `list` to see what meetings happened in a period:
128
132
  ```bash
129
- npm run cli:t49bridge -- list --from 2026-01-27 --to 2026-02-02 --external
133
+ t49bridge list --from 2026-01-27 --to 2026-02-02 --external
130
134
  ```
131
135
 
132
136
  ## Output Format
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ const { spawn } = require("node:child_process");
3
+ const { resolve } = require("node:path");
4
+
5
+ const cliPath = resolve(__dirname, "../cli/bridge-cli.ts");
6
+ const args = ["--import", "tsx", cliPath, ...process.argv.slice(2)];
7
+
8
+ const child = spawn(process.execPath, args, { stdio: "inherit" });
9
+ child.on("exit", (code) => process.exit(code ?? 0));
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env npx tsx
2
+
3
+ import { Command } from "commander";
4
+ import { api } from "../convex/_generated/api";
5
+ import { config } from "dotenv";
6
+ import { resolve } from "path";
7
+ import {
8
+ login,
9
+ deleteCredentials,
10
+ getCredentials,
11
+ getAuthenticatedClient,
12
+ } from "./lib/cli-auth";
13
+
14
+ // Load local env files if they exist (for non-Convex env vars like BRIDGE_APP_URL)
15
+ config({ path: resolve(__dirname, "../.env.local") });
16
+
17
+ // Default to production, but allow overrides for testing.
18
+ const convexUrl =
19
+ process.env.BRIDGE_CONVEX_URL ??
20
+ process.env.NEXT_PUBLIC_CONVEX_URL ??
21
+ "https://accomplished-loris-658.convex.cloud";
22
+
23
+ const program = new Command();
24
+
25
+ program
26
+ .name("t49bridge")
27
+ .description("Bridge CLI — search Fathom transcripts and manage ingestion")
28
+ .version("0.2.0");
29
+
30
+ // --- Auth commands (no client needed) ---
31
+
32
+ program
33
+ .command("login")
34
+ .description("Sign in with your Terminal49 Google account")
35
+ .action(async () => {
36
+ try {
37
+ await login();
38
+ // Verify with the server to get the real name/email
39
+ const client = await getAuthenticatedClient(convexUrl);
40
+ try {
41
+ const user = await client.query(api.users.whoami);
42
+ if (user?.name || user?.email) {
43
+ console.log(`Logged in as ${user.name ?? user.email}`);
44
+ } else {
45
+ console.log("Logged in successfully.");
46
+ }
47
+ } catch {
48
+ console.log("Logged in successfully.");
49
+ } finally {
50
+ await client.close();
51
+ }
52
+ } catch (err: any) {
53
+ console.error("Login failed:", err.message ?? err);
54
+ process.exit(1);
55
+ }
56
+ });
57
+
58
+ program
59
+ .command("logout")
60
+ .description("Remove stored credentials")
61
+ .action(() => {
62
+ if (deleteCredentials()) {
63
+ console.log("Logged out. Credentials removed.");
64
+ } else {
65
+ console.log("No credentials found.");
66
+ }
67
+ });
68
+
69
+ program
70
+ .command("whoami")
71
+ .description("Show the currently authenticated user")
72
+ .action(async () => {
73
+ const creds = getCredentials();
74
+ if (!creds) {
75
+ console.log("Not logged in. Run `t49bridge login` first.");
76
+ process.exit(1);
77
+ }
78
+
79
+ // Also verify with the server
80
+ const client = await getAuthenticatedClient(convexUrl);
81
+ try {
82
+ const user = await client.query(api.users.whoami);
83
+ if (user) {
84
+ console.log(`Name: ${user.name ?? "—"}`);
85
+ console.log(`Email: ${user.email ?? "—"}`);
86
+ } else {
87
+ console.log(
88
+ "Token is invalid or expired. Run `t49bridge login` to re-authenticate.",
89
+ );
90
+ }
91
+ } catch (err: any) {
92
+ console.error("Failed to verify identity:", err.message ?? err);
93
+ } finally {
94
+ await client.close();
95
+ }
96
+ });
97
+
98
+ // --- Authenticated commands ---
99
+
100
+ /**
101
+ * Get an authenticated ConvexClient. Exits if not logged in.
102
+ * Automatically refreshes expired tokens.
103
+ */
104
+ async function requireClient() {
105
+ return await getAuthenticatedClient(convexUrl);
106
+ }
107
+
108
+ function buildFilters(options: any) {
109
+ const filters: any = {};
110
+ if (options.meetingId) filters.meetingId = options.meetingId;
111
+ if (options.speaker) filters.speaker = options.speaker;
112
+ if (options.meetingDay) filters.meetingDay = options.meetingDay;
113
+ if (options.team) filters.team = options.team;
114
+ if (options.company) filters.company = options.company;
115
+ if (options.crm) filters.crmRecordName = options.crm;
116
+ if (options.includePrivate) filters.includePrivate = true;
117
+ if (typeof options.recentDays === "number" && !Number.isNaN(options.recentDays)) {
118
+ filters.recentDays = options.recentDays;
119
+ }
120
+ if (
121
+ typeof options.recencyWeight === "number" &&
122
+ !Number.isNaN(options.recencyWeight)
123
+ ) {
124
+ filters.recencyWeight = options.recencyWeight;
125
+ }
126
+ if (options.from && options.to) {
127
+ filters.dateRange = { start: options.from, end: options.to };
128
+ }
129
+ return Object.keys(filters).length > 0 ? filters : undefined;
130
+ }
131
+
132
+ function formatTimestamp(seconds?: number): string | null {
133
+ if (typeof seconds !== "number" || Number.isNaN(seconds)) return null;
134
+ const mins = Math.floor(seconds / 60);
135
+ const secs = Math.floor(seconds % 60);
136
+ return `${mins}:${String(secs).padStart(2, "0")}`;
137
+ }
138
+
139
+ function extractQueryTokens(query: string): string[] {
140
+ return query
141
+ .toLowerCase()
142
+ .split(/\s+/)
143
+ .map((token) => token.replace(/[^a-z0-9]/g, ""))
144
+ .filter((token) => token.length >= 2);
145
+ }
146
+
147
+ function escapeRegExp(value: string): string {
148
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+
151
+ function highlightTerms(text: string, query: string): string {
152
+ const tokens = extractQueryTokens(query).sort((a, b) => b.length - a.length);
153
+ let output = text;
154
+ for (const token of tokens) {
155
+ const pattern = new RegExp(`\\b(${escapeRegExp(token)})\\b`, "gi");
156
+ output = output.replace(pattern, "**$1**");
157
+ }
158
+ return output;
159
+ }
160
+
161
+ function makeSnippet(text: string, query: string, maxLength: number): string {
162
+ const tokens = extractQueryTokens(query);
163
+ if (tokens.length === 0) {
164
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
165
+ }
166
+
167
+ const lowered = text.toLowerCase();
168
+ let matchIndex = -1;
169
+ for (const token of tokens) {
170
+ const index = lowered.indexOf(token);
171
+ if (index !== -1 && (matchIndex === -1 || index < matchIndex)) {
172
+ matchIndex = index;
173
+ }
174
+ }
175
+
176
+ if (matchIndex === -1) {
177
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
178
+ }
179
+
180
+ const start = Math.max(0, matchIndex - Math.floor(maxLength / 2));
181
+ const end = Math.min(text.length, start + maxLength);
182
+ let snippet = text.slice(start, end).trim();
183
+ if (start > 0) snippet = `...${snippet}`;
184
+ if (end < text.length) snippet = `${snippet}...`;
185
+ return snippet;
186
+ }
187
+
188
+ function formatScore(result: any): string {
189
+ const score = result?.score;
190
+ const source = result?.source ?? result?.sources?.[0] ?? "unknown";
191
+ if (typeof score !== "number") {
192
+ return `n/a (${source})`;
193
+ }
194
+ if (score >= 0 && score <= 1) {
195
+ return `${Math.round(score * 100)}% (${source})`;
196
+ }
197
+ return `${score.toFixed(3)} (${source})`;
198
+ }
199
+
200
+ function getOutputFormat(options: any): "human" | "json" | "llm" {
201
+ const format =
202
+ typeof options.format === "string" ? options.format.toLowerCase() : undefined;
203
+ if (format === "llm") return "llm";
204
+ if (format === "json") return "json";
205
+ if (options.json) return "json";
206
+ return "human";
207
+ }
208
+
209
+ function printResults(
210
+ results: any[],
211
+ options: { query: string; snippetChars: number; fullText: boolean; highlight?: boolean },
212
+ ) {
213
+ if (results.length === 0) {
214
+ console.log("No results.");
215
+ return;
216
+ }
217
+
218
+ results.forEach((result) => {
219
+ const resultType = result.resultType || "chunk";
220
+ const title = result.meetingTitle || result.title || "Untitled";
221
+ const date =
222
+ result.meetingDay || (result.meetingStart ? result.meetingStart.slice(0, 10) : "");
223
+ const shortId = String(result.chunkId || "").slice(-6);
224
+ const header =
225
+ resultType === "summary"
226
+ ? `${date || "meeting"} [summary]`
227
+ : `${date || "meeting"} #${shortId}`;
228
+ console.log(header);
229
+ console.log(`Meeting: ${title}`);
230
+ if (result.meetingId) {
231
+ console.log(`MeetingId: ${result.meetingId}`);
232
+ }
233
+ if (resultType !== "summary") {
234
+ const speaker = result.primarySpeaker || "Unknown";
235
+ console.log(`Speaker: ${speaker}`);
236
+ const start = formatTimestamp(result.startTime);
237
+ const end = formatTimestamp(result.endTime);
238
+ if (start && end) {
239
+ console.log(`Range: ${start}–${end}`);
240
+ } else if (start) {
241
+ console.log(`Timestamp: ${start}`);
242
+ }
243
+ }
244
+ const sources =
245
+ result.sources && result.sources.length > 0
246
+ ? result.sources
247
+ : result.source
248
+ ? [result.source]
249
+ : [];
250
+ if (sources.length > 0) {
251
+ console.log(`Sources: ${sources.join(", ")}`);
252
+ }
253
+ if (typeof result.rank === "number") {
254
+ console.log(`Rank: ${result.rank}`);
255
+ }
256
+ console.log(`Score: ${formatScore(result)}`);
257
+ if (result.shareUrl) {
258
+ console.log(`ShareUrl: ${result.shareUrl}`);
259
+ }
260
+
261
+ const text =
262
+ resultType === "summary"
263
+ ? result.summary || result.text || "No summary available."
264
+ : result.text || "";
265
+ const snippet = options.fullText
266
+ ? text
267
+ : result.snippet || makeSnippet(text, options.query, options.snippetChars);
268
+ const highlighted = options.highlight ? highlightTerms(snippet, options.query) : snippet;
269
+ const label = resultType === "summary" ? "Summary" : "Snippet";
270
+ console.log(`${label}: ${highlighted}`);
271
+ console.log("");
272
+ });
273
+ }
274
+
275
+ function printLlmResults(results: any[], options: { query: string }) {
276
+ if (results.length === 0) {
277
+ console.log(JSON.stringify({ type: "empty", query: options.query, results: [] }));
278
+ return;
279
+ }
280
+
281
+ results.forEach((result) => {
282
+ const resultType = result.resultType || "chunk";
283
+ const start = formatTimestamp(result.startTime);
284
+ const end = formatTimestamp(result.endTime);
285
+ const payload: Record<string, any> = {
286
+ type: resultType,
287
+ meetingId: result.meetingId,
288
+ title: result.meetingTitle || result.title,
289
+ day:
290
+ result.meetingDay ||
291
+ (result.meetingStart ? result.meetingStart.slice(0, 10) : undefined),
292
+ sources:
293
+ result.sources && result.sources.length > 0
294
+ ? result.sources
295
+ : result.source
296
+ ? [result.source]
297
+ : [],
298
+ rank: result.rank,
299
+ score: result.score,
300
+ timestamp: start || undefined,
301
+ range: start && end ? `${start}–${end}` : undefined,
302
+ };
303
+
304
+ if (result.shareUrl) payload.shareUrl = result.shareUrl;
305
+
306
+ if (resultType === "summary") {
307
+ payload.summary = result.summary || result.text || "";
308
+ } else {
309
+ payload.snippet = result.snippet || result.text || "";
310
+ if (result.primarySpeaker) payload.speaker = result.primarySpeaker;
311
+ }
312
+
313
+ console.log(JSON.stringify(payload));
314
+ });
315
+ }
316
+
317
+ const commonOptions = (command: Command) =>
318
+ command
319
+ .option("--meeting-id <id>")
320
+ .option("--speaker <name>")
321
+ .option("--meeting-day <YYYY-MM-DD>")
322
+ .option("--from <date>")
323
+ .option("--to <date>")
324
+ .option("--team <name>")
325
+ .option("--company <name>")
326
+ .option("--crm <record>")
327
+ .option("--include-private", "include private calls")
328
+ .option("--include-url", "include share URL in output")
329
+ .option(
330
+ "--recent-days <n>",
331
+ "boost meetings within the last N days",
332
+ (value) => parseInt(value, 10),
333
+ )
334
+ .option(
335
+ "--recency-weight <n>",
336
+ "recency weight (default: 0.25)",
337
+ (value) => parseFloat(value),
338
+ )
339
+ .option(
340
+ "--snippet-chars <n>",
341
+ "snippet length in characters",
342
+ (value) => parseInt(value, 10),
343
+ )
344
+ .option("--full-text", "print full chunk text")
345
+ .option("--format <format>", "output format: human | llm | json")
346
+ .option("--json", "print raw JSON output")
347
+ .option(
348
+ "--limit <n>",
349
+ "number of results",
350
+ (value) => parseInt(value, 10),
351
+ );
352
+
353
+ commonOptions(
354
+ program
355
+ .command("search")
356
+ .argument("<query>")
357
+ .description("BM25 search over transcript chunks")
358
+ .action(async (query, options) => {
359
+ const client = await requireClient();
360
+ const filters = buildFilters(options);
361
+ const limit = options.limit ? Number(options.limit) : undefined;
362
+ const includeUrl = Boolean(options.includeUrl);
363
+ const response = await client.query(api.transcriptSearch.searchBm25, {
364
+ query,
365
+ filters,
366
+ limit,
367
+ includeUrl,
368
+ });
369
+ const output = getOutputFormat(options);
370
+ if (output === "json") {
371
+ console.log(JSON.stringify(response, null, 2));
372
+ } else if (output === "llm") {
373
+ printLlmResults(response.results, { query });
374
+ } else {
375
+ printResults(response.results, {
376
+ query,
377
+ snippetChars: options.snippetChars ?? 240,
378
+ fullText: Boolean(options.fullText),
379
+ highlight: true,
380
+ });
381
+ }
382
+ await client.close();
383
+ }),
384
+ );
385
+
386
+ commonOptions(
387
+ program
388
+ .command("vsearch")
389
+ .argument("<query>")
390
+ .description("Vector search over transcript chunks")
391
+ .action(async (query, options) => {
392
+ const client = await requireClient();
393
+ const filters = buildFilters(options);
394
+ const limit = options.limit ? Number(options.limit) : undefined;
395
+ const includeUrl = Boolean(options.includeUrl);
396
+ const response = await client.action(api.transcriptSearch.searchVector, {
397
+ query,
398
+ filters,
399
+ limit,
400
+ includeUrl,
401
+ });
402
+ const output = getOutputFormat(options);
403
+ if (output === "json") {
404
+ console.log(JSON.stringify(response, null, 2));
405
+ } else if (output === "llm") {
406
+ printLlmResults(response.results, { query });
407
+ } else {
408
+ printResults(response.results, {
409
+ query,
410
+ snippetChars: options.snippetChars ?? 240,
411
+ fullText: Boolean(options.fullText),
412
+ highlight: true,
413
+ });
414
+ }
415
+ await client.close();
416
+ }),
417
+ );
418
+
419
+ commonOptions(
420
+ program
421
+ .command("query")
422
+ .argument("<query>")
423
+ .description("Hybrid BM25 + vector search")
424
+ .action(async (query, options) => {
425
+ const client = await requireClient();
426
+ const filters = buildFilters(options);
427
+ const limit = options.limit ? Number(options.limit) : undefined;
428
+ const includeUrl = Boolean(options.includeUrl);
429
+ const response = await client.action(api.transcriptSearch.searchHybrid, {
430
+ query,
431
+ filters,
432
+ limit,
433
+ includeUrl,
434
+ });
435
+ const output = getOutputFormat(options);
436
+ if (output === "json") {
437
+ console.log(JSON.stringify(response, null, 2));
438
+ } else if (output === "llm") {
439
+ printLlmResults(response.results, { query });
440
+ } else {
441
+ printResults(response.results, {
442
+ query,
443
+ snippetChars: options.snippetChars ?? 240,
444
+ fullText: Boolean(options.fullText),
445
+ highlight: true,
446
+ });
447
+ }
448
+ await client.close();
449
+ }),
450
+ );
451
+
452
+ program
453
+ .command("backfill")
454
+ .description("Backfill transcript chunks and embeddings")
455
+ .option(
456
+ "--batch-size <n>",
457
+ "number of meetings per batch",
458
+ (value) => parseInt(value, 10),
459
+ )
460
+ .option("--no-embed", "skip embedding generation")
461
+ .option("--force", "rebuild chunks and embeddings")
462
+ .action(async (options) => {
463
+ const client = await requireClient();
464
+ const response = await client.action(
465
+ api.transcriptIndexing.startTranscriptSearchBackfill,
466
+ {
467
+ batchSize: options.batchSize,
468
+ embed: options.embed,
469
+ force: options.force,
470
+ },
471
+ );
472
+ console.log("Backfill started:", response);
473
+ await client.close();
474
+ });
475
+
476
+ program
477
+ .command("get")
478
+ .argument("<meetingId>")
479
+ .description("Fetch meeting details with optional transcript")
480
+ .option("--transcript", "include transcript")
481
+ .option("--segments", "print transcript segments instead of full text")
482
+ .option(
483
+ "--max-segments <n>",
484
+ "limit transcript segments",
485
+ (value) => parseInt(value, 10),
486
+ )
487
+ .option("--summary", "print only the meeting summary")
488
+ .option("--json", "print raw JSON output")
489
+ .action(async (meetingId, options) => {
490
+ const client = await requireClient();
491
+ const response = await client.query(api.meetings.getByMeetingId, {
492
+ meetingId,
493
+ includeTranscript: Boolean(options.transcript || options.segments),
494
+ });
495
+
496
+ if (options.json) {
497
+ console.log(JSON.stringify(response, null, 2));
498
+ await client.close();
499
+ return;
500
+ }
501
+
502
+ if (!response) {
503
+ console.log("Meeting not found.");
504
+ await client.close();
505
+ return;
506
+ }
507
+
508
+ const meeting = response.meeting;
509
+ if (options.summary) {
510
+ console.log(meeting.summary || "No summary available.");
511
+ await client.close();
512
+ return;
513
+ }
514
+
515
+ console.log(`Meeting: ${meeting.title}`);
516
+ console.log(`MeetingId: ${meeting.meetingId}`);
517
+ console.log(`Date: ${meeting.startedAt}`);
518
+ console.log(`Duration: ${Math.round(meeting.duration / 60)} min`);
519
+ console.log(`Host: ${meeting.hostName} <${meeting.hostEmail}>`);
520
+ console.log(`Internal: ${meeting.isInternal ? "yes" : "no"}`);
521
+ if (meeting.teams?.length)
522
+ console.log(`Teams: ${meeting.teams.join(", ")}`);
523
+ if (meeting.externalCompanies?.length) {
524
+ console.log(`Companies: ${meeting.externalCompanies.join(", ")}`);
525
+ }
526
+ if (meeting.shareUrl) console.log(`ShareUrl: ${meeting.shareUrl}`);
527
+ if (meeting.visibility) console.log(`Visibility: ${meeting.visibility}`);
528
+ if (meeting.summary) console.log(`Summary: ${meeting.summary}`);
529
+ if (meeting.actionItems?.length) {
530
+ console.log("Action Items:");
531
+ meeting.actionItems.forEach((item: any, index: number) => {
532
+ console.log(
533
+ ` ${index + 1}. ${item.text}${item.assignee ? ` (${item.assignee})` : ""}`,
534
+ );
535
+ });
536
+ }
537
+
538
+ if (options.transcript || options.segments) {
539
+ const transcript = response.transcript;
540
+ if (!transcript) {
541
+ console.log("Transcript: not found.");
542
+ } else if (options.segments) {
543
+ const maxSegments =
544
+ typeof options.maxSegments === "number"
545
+ ? options.maxSegments
546
+ : transcript.segments.length;
547
+ transcript.segments.slice(0, maxSegments).forEach((seg: any) => {
548
+ const start = formatTimestamp(seg.startTime);
549
+ const end = formatTimestamp(seg.endTime);
550
+ console.log(
551
+ `[${start ?? "?"}-${end ?? "?"}] ${seg.speakerName || "Unknown"}: ${seg.text}`,
552
+ );
553
+ });
554
+ } else {
555
+ console.log(transcript.fullText || "");
556
+ }
557
+ }
558
+
559
+ await client.close();
560
+ });
561
+
562
+ program
563
+ .command("list")
564
+ .description("List recent meetings")
565
+ .option("--limit <n>", "number of meetings to show", (value) => parseInt(value, 10))
566
+ .option("--from <date>", "filter meetings from date (YYYY-MM-DD)")
567
+ .option("--to <date>", "filter meetings to date (YYYY-MM-DD)")
568
+ .option("--company <name>", "filter by company (partial match)")
569
+ .option("--host <name>", "filter by host name or email (partial match)")
570
+ .option("--attendee <name>", "filter by attendee name or email (partial match)")
571
+ .option("--internal", "show only internal meetings")
572
+ .option("--external", "show only external meetings")
573
+ .option("--asc", "sort by oldest first (default: newest first)")
574
+ .option("--json", "print raw JSON output")
575
+ .action(async (options) => {
576
+ const client = await requireClient();
577
+ const limit = options.limit ?? 20;
578
+ const sortOrder = options.asc ? "asc" : "desc";
579
+ const fromDate = options.from ? new Date(options.from + "T00:00:00Z") : null;
580
+ const toDate = options.to ? new Date(options.to + "T23:59:59Z") : null;
581
+ const companyFilter = options.company?.toLowerCase();
582
+ const hostFilter = options.host?.toLowerCase();
583
+ const attendeeFilter = options.attendee?.toLowerCase();
584
+
585
+ // Check if any filters are applied
586
+ const hasFilters =
587
+ fromDate ||
588
+ toDate ||
589
+ companyFilter ||
590
+ hostFilter ||
591
+ attendeeFilter ||
592
+ options.internal ||
593
+ options.external;
594
+
595
+ // Fetch more meetings if filtering to ensure we get enough results
596
+ const fetchLimit = hasFilters ? Math.max(limit * 5, 100) : limit;
597
+
598
+ let meetings = await client.query(api.meetings.getRecentMeetings, {
599
+ limit: fetchLimit,
600
+ sortOrder,
601
+ });
602
+
603
+ // Apply filters client-side
604
+ if (hasFilters) {
605
+ meetings = meetings.filter((meeting) => {
606
+ // Date filter
607
+ if (fromDate || toDate) {
608
+ const meetingDate = new Date(meeting.startedAt);
609
+ if (fromDate && meetingDate < fromDate) return false;
610
+ if (toDate && meetingDate > toDate) return false;
611
+ }
612
+
613
+ // Company filter (partial match on external companies)
614
+ if (companyFilter) {
615
+ const companies = meeting.externalCompanies || [];
616
+ const hasMatch = companies.some((c: string) =>
617
+ c.toLowerCase().includes(companyFilter),
618
+ );
619
+ if (!hasMatch) return false;
620
+ }
621
+
622
+ // Host filter (partial match on name or email)
623
+ if (hostFilter) {
624
+ const hostName = meeting.hostName?.toLowerCase() || "";
625
+ const hostEmail = meeting.hostEmail?.toLowerCase() || "";
626
+ if (!hostName.includes(hostFilter) && !hostEmail.includes(hostFilter)) return false;
627
+ }
628
+
629
+ // Attendee filter (partial match on external participants)
630
+ if (attendeeFilter) {
631
+ const participants = meeting.externalParticipants || [];
632
+ const hasMatch = participants.some((p: any) => {
633
+ const name = p.name?.toLowerCase() || "";
634
+ const email = p.email?.toLowerCase() || "";
635
+ const company = p.company?.toLowerCase() || "";
636
+ return (
637
+ name.includes(attendeeFilter) ||
638
+ email.includes(attendeeFilter) ||
639
+ company.includes(attendeeFilter)
640
+ );
641
+ });
642
+ if (!hasMatch) return false;
643
+ }
644
+
645
+ // Internal/external filter
646
+ if (options.internal && !meeting.isInternal) return false;
647
+ if (options.external && meeting.isInternal) return false;
648
+
649
+ return true;
650
+ });
651
+
652
+ // Apply limit after filtering
653
+ meetings = meetings.slice(0, limit);
654
+ }
655
+
656
+ if (options.json) {
657
+ console.log(JSON.stringify(meetings, null, 2));
658
+ await client.close();
659
+ return;
660
+ }
661
+
662
+ if (meetings.length === 0) {
663
+ console.log("No meetings found.");
664
+ const filters: string[] = [];
665
+ if (fromDate || toDate)
666
+ filters.push(`date: ${options.from || "any"} to ${options.to || "any"}`);
667
+ if (companyFilter) filters.push(`company: ${options.company}`);
668
+ if (hostFilter) filters.push(`host: ${options.host}`);
669
+ if (attendeeFilter) filters.push(`attendee: ${options.attendee}`);
670
+ if (options.internal) filters.push("internal only");
671
+ if (options.external) filters.push("external only");
672
+ if (filters.length > 0) console.log(`Filters: ${filters.join(", ")}`);
673
+ await client.close();
674
+ return;
675
+ }
676
+
677
+ // Build filter info string
678
+ const filterParts: string[] = [];
679
+ if (fromDate || toDate) filterParts.push(`${options.from || "any"} to ${options.to || "any"}`);
680
+ if (companyFilter) filterParts.push(`company: ${options.company}`);
681
+ if (hostFilter) filterParts.push(`host: ${options.host}`);
682
+ if (attendeeFilter) filterParts.push(`attendee: ${options.attendee}`);
683
+ if (options.internal) filterParts.push("internal");
684
+ if (options.external) filterParts.push("external");
685
+ const filterInfo = filterParts.length > 0 ? ` (${filterParts.join(", ")})` : "";
686
+
687
+ console.log(`Found ${meetings.length} meeting(s)${filterInfo}:\n`);
688
+
689
+ // Group meetings by date
690
+ const byDate = new Map<string, typeof meetings>();
691
+ for (const meeting of meetings) {
692
+ const date = meeting.startedAt.split("T")[0];
693
+ if (!byDate.has(date)) byDate.set(date, []);
694
+ byDate.get(date)!.push(meeting);
695
+ }
696
+
697
+ for (const [date, dateMeetings] of byDate) {
698
+ console.log(`=== ${date} ===`);
699
+ for (const meeting of dateMeetings) {
700
+ const time = meeting.startedAt.split("T")[1]?.slice(0, 5) || "";
701
+ const duration = Math.round(meeting.duration / 60);
702
+ const type = meeting.isInternal ? "internal" : "external";
703
+ const companies = meeting.externalCompanies?.join(", ") || "";
704
+ const transcript = meeting.hasTranscript ? "✓" : "✗";
705
+
706
+ console.log(` ${time} | ${meeting.title}`);
707
+ console.log(` ID: ${meeting.meetingId} | Host: ${meeting.hostName}`);
708
+ console.log(
709
+ ` Duration: ${duration} min | Type: ${type} | Transcript: ${transcript}`,
710
+ );
711
+ if (companies) {
712
+ console.log(` Companies: ${companies}`);
713
+ }
714
+ if (meeting.actionItemCount > 0) {
715
+ console.log(` Action Items: ${meeting.actionItemCount}`);
716
+ }
717
+ console.log("");
718
+ }
719
+ }
720
+
721
+ await client.close();
722
+ });
723
+
724
+ program.parseAsync(process.argv).catch(async (error) => {
725
+ console.error(error);
726
+ process.exit(1);
727
+ });
@@ -0,0 +1,292 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ chmodSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { randomBytes } from "node:crypto";
13
+ import { exec } from "node:child_process";
14
+ import { ConvexClient } from "convex/browser";
15
+
16
+ const CREDENTIALS_DIR = join(homedir(), ".t49bridge");
17
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
18
+ const APP_URL = process.env.BRIDGE_APP_URL ?? "https://bridge.t49.co";
19
+
20
+ interface Credentials {
21
+ token: string;
22
+ refreshToken: string | null;
23
+ email: string | null;
24
+ name: string | null;
25
+ savedAt: string;
26
+ }
27
+
28
+ /**
29
+ * Open a URL in the default browser (cross-platform).
30
+ */
31
+ function openBrowser(url: string): void {
32
+ const cmd =
33
+ process.platform === "darwin"
34
+ ? `open "${url}"`
35
+ : process.platform === "win32"
36
+ ? `start "${url}"`
37
+ : `xdg-open "${url}"`;
38
+ exec(cmd);
39
+ }
40
+
41
+ /**
42
+ * Run the browser-based login flow:
43
+ * 1. Start a local HTTP server on a random port
44
+ * 2. Open the browser to the CLI auth page
45
+ * 3. Wait for the callback with the token
46
+ * 4. Save credentials to ~/.t49bridge/credentials.json
47
+ */
48
+ export async function login(): Promise<Credentials> {
49
+ const state = randomBytes(16).toString("hex");
50
+
51
+ return new Promise<Credentials>((resolve, reject) => {
52
+ const server = createServer(
53
+ (req: IncomingMessage, res: ServerResponse) => {
54
+ const url = new URL(req.url ?? "/", `http://localhost`);
55
+
56
+ if (url.pathname !== "/callback") {
57
+ res.writeHead(404);
58
+ res.end("Not found");
59
+ return;
60
+ }
61
+
62
+ const receivedState = url.searchParams.get("state");
63
+ const token = url.searchParams.get("token");
64
+ const refreshToken = url.searchParams.get("refreshToken") || null;
65
+ const email = url.searchParams.get("email") || null;
66
+ const name = url.searchParams.get("name") || null;
67
+
68
+ if (receivedState !== state) {
69
+ res.writeHead(403);
70
+ res.end("State mismatch — possible CSRF attack. Please try again.");
71
+ return;
72
+ }
73
+
74
+ if (!token) {
75
+ res.writeHead(400);
76
+ res.end("No token received. Please try again.");
77
+ return;
78
+ }
79
+
80
+ // Save credentials
81
+ const credentials: Credentials = {
82
+ token,
83
+ refreshToken,
84
+ email,
85
+ name,
86
+ savedAt: new Date().toISOString(),
87
+ };
88
+ saveCredentials(credentials);
89
+
90
+ // Send success page to browser
91
+ res.writeHead(200, { "Content-Type": "text/html" });
92
+ res.end(`
93
+ <!DOCTYPE html>
94
+ <html>
95
+ <head><title>Bridge CLI - Authenticated</title></head>
96
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #18181b; color: #fafafa;">
97
+ <div style="text-align: center;">
98
+ <h1>Authenticated!</h1>
99
+ <p>You can close this tab and return to your terminal.</p>
100
+ </div>
101
+ </body>
102
+ </html>
103
+ `);
104
+
105
+ // Close server, clear timeout, and resolve
106
+ clearTimeout(timeout);
107
+ server.close();
108
+ resolve(credentials);
109
+ },
110
+ );
111
+
112
+ server.listen(0, "127.0.0.1", () => {
113
+ const addr = server.address();
114
+ if (!addr || typeof addr === "string") {
115
+ reject(new Error("Failed to start local server"));
116
+ return;
117
+ }
118
+
119
+ const port = addr.port;
120
+ const authUrl = `${APP_URL}/cli/auth?port=${port}&state=${state}`;
121
+
122
+ console.log(`Opening browser for authentication...`);
123
+ console.log(`If the browser doesn't open, visit: ${authUrl}`);
124
+ console.log();
125
+
126
+ openBrowser(authUrl);
127
+ });
128
+
129
+ // Timeout after 5 minutes
130
+ const timeout = setTimeout(() => {
131
+ server.close();
132
+ reject(new Error("Login timed out after 5 minutes."));
133
+ }, 5 * 60 * 1000);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Save credentials to disk.
139
+ */
140
+ function saveCredentials(credentials: Credentials): void {
141
+ if (!existsSync(CREDENTIALS_DIR)) {
142
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
143
+ }
144
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
145
+ // Restrict permissions to owner only
146
+ chmodSync(CREDENTIALS_FILE, 0o600);
147
+ }
148
+
149
+ /**
150
+ * Load stored credentials from disk.
151
+ */
152
+ export function getCredentials(): Credentials | null {
153
+ if (!existsSync(CREDENTIALS_FILE)) {
154
+ return null;
155
+ }
156
+ try {
157
+ const data = readFileSync(CREDENTIALS_FILE, "utf-8");
158
+ return JSON.parse(data) as Credentials;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Delete stored credentials (logout).
166
+ */
167
+ export function deleteCredentials(): boolean {
168
+ if (!existsSync(CREDENTIALS_FILE)) {
169
+ return false;
170
+ }
171
+ unlinkSync(CREDENTIALS_FILE);
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * Decode a JWT payload without verification (for expiry checks only).
177
+ */
178
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
179
+ try {
180
+ const parts = token.split(".");
181
+ if (parts.length !== 3) return null;
182
+ const payload = Buffer.from(parts[1], "base64url").toString();
183
+ return JSON.parse(payload);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if a JWT token is expired (with a 60-second buffer).
191
+ */
192
+ function isTokenExpired(token: string): boolean {
193
+ const payload = decodeJwtPayload(token);
194
+ if (!payload || typeof payload.exp !== "number") return false;
195
+ const now = Math.floor(Date.now() / 1000);
196
+ return payload.exp < now + 60; // expired or expiring within 60s
197
+ }
198
+
199
+ /**
200
+ * Refresh an expired token using the stored refresh token.
201
+ * Calls the Convex auth:signIn action directly via the HTTP API.
202
+ * Returns new credentials on success, or null if refresh failed.
203
+ */
204
+ async function refreshTokens(
205
+ convexUrl: string,
206
+ refreshToken: string,
207
+ ): Promise<{ token: string; refreshToken: string } | null> {
208
+ try {
209
+ const res = await fetch(`${convexUrl}/api/action`, {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify({
213
+ path: "auth:signIn",
214
+ args: { refreshToken },
215
+ format: "json",
216
+ }),
217
+ });
218
+
219
+ if (!res.ok) {
220
+ return null;
221
+ }
222
+
223
+ const result = await res.json();
224
+ const tokens = result?.value?.tokens ?? result?.tokens;
225
+
226
+ if (tokens?.token && tokens?.refreshToken) {
227
+ return { token: tokens.token, refreshToken: tokens.refreshToken };
228
+ }
229
+
230
+ return null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Get the stored auth token, or null if not logged in.
238
+ */
239
+ export function getAuthToken(): string | null {
240
+ const credentials = getCredentials();
241
+ return credentials?.token ?? null;
242
+ }
243
+
244
+ /**
245
+ * Create a ConvexClient authenticated with the stored token.
246
+ * Automatically refreshes expired tokens using the refresh token.
247
+ * Throws if not logged in or refresh fails.
248
+ */
249
+ export async function getAuthenticatedClient(convexUrl: string): Promise<ConvexClient> {
250
+ const credentials = getCredentials();
251
+ if (!credentials?.token) {
252
+ console.error(
253
+ "Not logged in. Run `t49bridge login` first.",
254
+ );
255
+ process.exit(1);
256
+ }
257
+
258
+ let { token } = credentials;
259
+
260
+ // Auto-refresh if token is expired or about to expire
261
+ if (isTokenExpired(token)) {
262
+ if (!credentials.refreshToken) {
263
+ console.error(
264
+ "Session expired and no refresh token available. Run `t49bridge login` to re-authenticate.",
265
+ );
266
+ process.exit(1);
267
+ }
268
+
269
+ const newTokens = await refreshTokens(convexUrl, credentials.refreshToken);
270
+ if (!newTokens) {
271
+ console.error(
272
+ "Session expired and refresh failed. Run `t49bridge login` to re-authenticate.",
273
+ );
274
+ process.exit(1);
275
+ }
276
+
277
+ // Save refreshed credentials
278
+ const updated: Credentials = {
279
+ ...credentials,
280
+ token: newTokens.token,
281
+ refreshToken: newTokens.refreshToken,
282
+ savedAt: new Date().toISOString(),
283
+ };
284
+ saveCredentials(updated);
285
+ token = newTokens.token;
286
+ console.error("Session refreshed.");
287
+ }
288
+
289
+ const client = new ConvexClient(convexUrl);
290
+ client.setAuth(() => Promise.resolve(token));
291
+ return client;
292
+ }
@@ -0,0 +1,23 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `api` utility.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import { anyApi, componentsGeneric } from "convex/server";
12
+
13
+ /**
14
+ * A utility for referencing Convex functions in your app's API.
15
+ *
16
+ * Usage:
17
+ * ```js
18
+ * const myFunctionReference = api.myModule.myFunction;
19
+ * ```
20
+ */
21
+ export const api = anyApi;
22
+ export const internal = anyApi;
23
+ export const components = componentsGeneric();
package/package.json CHANGED
@@ -1,13 +1,33 @@
1
1
  {
2
2
  "name": "@terminal49/bridge-cli",
3
- "version": "0.1.1",
4
- "description": "Bridge CLI meeting search skill for LLMs",
3
+ "version": "0.1.3",
4
+ "description": "Bridge CLI and meeting search skill for LLMs",
5
5
  "license": "UNLICENSED",
6
+ "bin": {
7
+ "t49bridge": "bin/t49bridge.js"
8
+ },
6
9
  "publishConfig": {
7
10
  "access": "public"
8
11
  },
9
12
  "files": [
10
13
  "SKILL.md",
11
- "README.md"
12
- ]
14
+ "README.md",
15
+ "bin/**",
16
+ "cli/**",
17
+ "convex/**"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/Terminal49/bridge.git",
22
+ "directory": "packages/bridge-cli"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "dependencies": {
28
+ "commander": "^12.1.0",
29
+ "convex": "^1.25.4",
30
+ "dotenv": "^17.2.1",
31
+ "tsx": "^4.20.3"
32
+ }
13
33
  }