@terminal49/bridge-cli 0.1.0 → 0.1.2
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 +22 -2
- package/SKILL.md +39 -27
- package/bin/t49bridge.js +9 -0
- package/cli/bridge-cli.ts +727 -0
- package/cli/lib/cli-auth.ts +292 -0
- package/convex/_generated/api.js +23 -0
- package/package.json +25 -5
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# @terminal49/bridge-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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,23 +7,21 @@ 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
|
-
##
|
|
10
|
+
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Preferred: install the public npm package (CLI included):
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npm
|
|
15
|
+
npm install -g @terminal49/bridge-cli
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Or run via npx:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
|
|
21
|
+
npx @terminal49/bridge-cli -- help
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
Clone the Bridge repo and install dependencies:
|
|
24
|
+
Alternative: clone the Bridge repo and install dependencies:
|
|
27
25
|
|
|
28
26
|
```bash
|
|
29
27
|
git clone https://github.com/Terminal49/bridge.git
|
|
@@ -31,9 +29,23 @@ cd bridge
|
|
|
31
29
|
npm install
|
|
32
30
|
```
|
|
33
31
|
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
The CLI requires authentication. If commands fail with "Not logged in", run:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
t49bridge login
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Verify with:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
t49bridge whoami
|
|
44
|
+
```
|
|
45
|
+
|
|
34
46
|
## Commands
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
Use `t49bridge` if installed globally or via npx. If using the repo, prefix with `npm run cli:t49bridge --`.
|
|
37
49
|
|
|
38
50
|
### Search Transcripts
|
|
39
51
|
|
|
@@ -41,13 +53,13 @@ Three search modes — use `query` (hybrid) by default:
|
|
|
41
53
|
|
|
42
54
|
```bash
|
|
43
55
|
# Hybrid search (recommended) — combines keyword + semantic
|
|
44
|
-
|
|
56
|
+
t49bridge query "<natural language question>" --limit 10
|
|
45
57
|
|
|
46
58
|
# Keyword search (BM25) — best for exact terms, names, product names
|
|
47
|
-
|
|
59
|
+
t49bridge search "<keywords>" --limit 10
|
|
48
60
|
|
|
49
61
|
# Semantic search — best for conceptual/meaning-based queries
|
|
50
|
-
|
|
62
|
+
t49bridge vsearch "<description of what you're looking for>" --limit 10
|
|
51
63
|
```
|
|
52
64
|
|
|
53
65
|
#### Search filters (work with all three modes)
|
|
@@ -74,34 +86,34 @@ npm run cli:t49bridge -- vsearch "<description of what you're looking for>" --li
|
|
|
74
86
|
|
|
75
87
|
```bash
|
|
76
88
|
# Summary and metadata
|
|
77
|
-
|
|
89
|
+
t49bridge get <meetingId>
|
|
78
90
|
|
|
79
91
|
# Just the summary
|
|
80
|
-
|
|
92
|
+
t49bridge get <meetingId> --summary
|
|
81
93
|
|
|
82
94
|
# Full transcript
|
|
83
|
-
|
|
95
|
+
t49bridge get <meetingId> --transcript
|
|
84
96
|
|
|
85
97
|
# Timestamped segments
|
|
86
|
-
|
|
98
|
+
t49bridge get <meetingId> --segments
|
|
87
99
|
|
|
88
100
|
# JSON output
|
|
89
|
-
|
|
101
|
+
t49bridge get <meetingId> --json
|
|
90
102
|
```
|
|
91
103
|
|
|
92
104
|
### List Meetings
|
|
93
105
|
|
|
94
106
|
```bash
|
|
95
107
|
# Recent meetings
|
|
96
|
-
|
|
108
|
+
t49bridge list --limit 20
|
|
97
109
|
|
|
98
110
|
# Filter by date, company, host, type
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
t49bridge list --from 2026-01-01 --to 2026-01-31 --company "Maersk"
|
|
112
|
+
t49bridge list --host "akshay" --external --limit 50
|
|
113
|
+
t49bridge list --attendee "john@example.com"
|
|
102
114
|
|
|
103
115
|
# JSON output
|
|
104
|
-
|
|
116
|
+
t49bridge list --json --limit 100
|
|
105
117
|
```
|
|
106
118
|
|
|
107
119
|
## Workflow
|
|
@@ -110,23 +122,23 @@ Follow this sequence to answer questions about meetings:
|
|
|
110
122
|
|
|
111
123
|
1. **Search** — Start with `query` using the question as-is:
|
|
112
124
|
```bash
|
|
113
|
-
|
|
125
|
+
t49bridge query "what pricing concerns did Maersk raise" --limit 10
|
|
114
126
|
```
|
|
115
127
|
|
|
116
128
|
2. **Get details** — Fetch full context for relevant meetings:
|
|
117
129
|
```bash
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
t49bridge get <meetingId> --summary
|
|
131
|
+
t49bridge get <meetingId> --transcript
|
|
120
132
|
```
|
|
121
133
|
|
|
122
134
|
3. **Narrow** — Add filters if too many results:
|
|
123
135
|
```bash
|
|
124
|
-
|
|
136
|
+
t49bridge query "pricing" --company "Maersk" --from 2026-01-01 --full-text
|
|
125
137
|
```
|
|
126
138
|
|
|
127
139
|
4. **Browse** — Use `list` to see what meetings happened in a period:
|
|
128
140
|
```bash
|
|
129
|
-
|
|
141
|
+
t49bridge list --from 2026-01-27 --to 2026-02-02 --external
|
|
130
142
|
```
|
|
131
143
|
|
|
132
144
|
## Output Format
|
package/bin/t49bridge.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Bridge CLI meeting search skill for LLMs",
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
-
"access": "
|
|
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
|
}
|