podcast-guest-crm-cli 0.1.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/bin/podcast-guest-crm-cli.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +524 -0
- package/package.json +59 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { dirname, join as join2 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/lib/config.ts
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var CONFIG_DIR_NAME = "podcast-guest-crm-cli";
|
|
14
|
+
function getConfigDir() {
|
|
15
|
+
return join(homedir(), ".config", CONFIG_DIR_NAME);
|
|
16
|
+
}
|
|
17
|
+
function getCredentialsPath() {
|
|
18
|
+
return join(getConfigDir(), "credentials.json");
|
|
19
|
+
}
|
|
20
|
+
function saveCredentials(creds) {
|
|
21
|
+
const dir = getConfigDir();
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
24
|
+
}
|
|
25
|
+
const path = getCredentialsPath();
|
|
26
|
+
writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
27
|
+
chmodSync(path, 384);
|
|
28
|
+
}
|
|
29
|
+
function loadCredentials() {
|
|
30
|
+
const path = getCredentialsPath();
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(path, "utf-8");
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function loadCliConfig() {
|
|
40
|
+
return {
|
|
41
|
+
apiUrl: process.env.PODCAST_GUEST_CRM_API_URL ?? "http://localhost:3001/api/v1",
|
|
42
|
+
supabaseUrl: process.env.PODCAST_GUEST_CRM_SUPABASE_URL,
|
|
43
|
+
supabaseAnonKey: process.env.PODCAST_GUEST_CRM_SUPABASE_ANON_KEY
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/lib/supabase-auth.ts
|
|
48
|
+
var SupabaseAuthError = class extends Error {
|
|
49
|
+
};
|
|
50
|
+
function tokenUrl(supabaseUrl) {
|
|
51
|
+
return `${supabaseUrl.replace(/\/+$/, "")}/auth/v1/token`;
|
|
52
|
+
}
|
|
53
|
+
async function requestToken(supabaseUrl, anonKey, grantType, body) {
|
|
54
|
+
const res = await fetch(`${tokenUrl(supabaseUrl)}?grant_type=${grantType}`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
apikey: anonKey
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify(body)
|
|
61
|
+
});
|
|
62
|
+
const json = await res.json().catch(() => ({}));
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const message = json.error_description ?? json.msg ?? json.error ?? `HTTP ${res.status}`;
|
|
65
|
+
throw new SupabaseAuthError(message);
|
|
66
|
+
}
|
|
67
|
+
return json;
|
|
68
|
+
}
|
|
69
|
+
async function signInWithPassword(supabaseUrl, anonKey, email, password) {
|
|
70
|
+
const json = await requestToken(supabaseUrl, anonKey, "password", { email, password });
|
|
71
|
+
if (!json.access_token || !json.refresh_token) {
|
|
72
|
+
throw new SupabaseAuthError("Supabase did not return an access token.");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
accessToken: json.access_token,
|
|
76
|
+
refreshToken: json.refresh_token,
|
|
77
|
+
expiresAt: Math.floor(Date.now() / 1e3) + json.expires_in,
|
|
78
|
+
email: json.user?.email ?? email
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function refreshSession(supabaseUrl, anonKey, refreshToken, fallbackEmail) {
|
|
82
|
+
const json = await requestToken(supabaseUrl, anonKey, "refresh_token", {
|
|
83
|
+
refresh_token: refreshToken
|
|
84
|
+
});
|
|
85
|
+
if (!json.access_token || !json.refresh_token) {
|
|
86
|
+
throw new SupabaseAuthError("Supabase did not return a refreshed access token.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
accessToken: json.access_token,
|
|
90
|
+
refreshToken: json.refresh_token,
|
|
91
|
+
expiresAt: Math.floor(Date.now() / 1e3) + json.expires_in,
|
|
92
|
+
email: json.user?.email ?? fallbackEmail
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/lib/prompt.ts
|
|
97
|
+
import { createInterface } from "readline";
|
|
98
|
+
function promptText(question) {
|
|
99
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
rl.question(question, (answer) => {
|
|
102
|
+
rl.close();
|
|
103
|
+
resolve(answer.trim());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
var CTRL_C = "";
|
|
108
|
+
var CTRL_D = "";
|
|
109
|
+
var BACKSPACE = "\x7F";
|
|
110
|
+
function promptPassword(question) {
|
|
111
|
+
if (!process.stdin.isTTY) {
|
|
112
|
+
return promptText(question);
|
|
113
|
+
}
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
process.stdout.write(question);
|
|
116
|
+
const stdin = process.stdin;
|
|
117
|
+
let password = "";
|
|
118
|
+
stdin.setRawMode(true);
|
|
119
|
+
stdin.resume();
|
|
120
|
+
stdin.setEncoding("utf8");
|
|
121
|
+
const onData = (char) => {
|
|
122
|
+
if (char === "\n" || char === "\r" || char === CTRL_D) {
|
|
123
|
+
stdin.setRawMode(false);
|
|
124
|
+
stdin.pause();
|
|
125
|
+
stdin.removeListener("data", onData);
|
|
126
|
+
process.stdout.write("\n");
|
|
127
|
+
resolve(password);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (char === CTRL_C) {
|
|
131
|
+
process.stdout.write("\n");
|
|
132
|
+
process.exit(130);
|
|
133
|
+
}
|
|
134
|
+
if (char === BACKSPACE || char === "\b") {
|
|
135
|
+
password = password.slice(0, -1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
password += char;
|
|
139
|
+
};
|
|
140
|
+
stdin.on("data", onData);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/lib/api-client.ts
|
|
145
|
+
var CliError = class extends Error {
|
|
146
|
+
constructor(message, statusCode, details) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.statusCode = statusCode;
|
|
149
|
+
this.details = details;
|
|
150
|
+
this.name = "CliError";
|
|
151
|
+
}
|
|
152
|
+
statusCode;
|
|
153
|
+
details;
|
|
154
|
+
};
|
|
155
|
+
var NotLoggedInError = class extends CliError {
|
|
156
|
+
constructor() {
|
|
157
|
+
super("Not logged in. Run `podcast-guest-crm-cli login` first.");
|
|
158
|
+
this.name = "NotLoggedInError";
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var EXPIRY_BUFFER_SECONDS = 30;
|
|
162
|
+
async function getValidAccessToken() {
|
|
163
|
+
const creds = loadCredentials();
|
|
164
|
+
if (!creds || !creds.accessToken) {
|
|
165
|
+
throw new NotLoggedInError();
|
|
166
|
+
}
|
|
167
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
168
|
+
if (creds.expiresAt - EXPIRY_BUFFER_SECONDS > now) {
|
|
169
|
+
return creds.accessToken;
|
|
170
|
+
}
|
|
171
|
+
const refreshed = await refreshSession(
|
|
172
|
+
creds.supabaseUrl,
|
|
173
|
+
creds.supabaseAnonKey,
|
|
174
|
+
creds.refreshToken,
|
|
175
|
+
creds.email
|
|
176
|
+
);
|
|
177
|
+
saveCredentials({
|
|
178
|
+
accessToken: refreshed.accessToken,
|
|
179
|
+
refreshToken: refreshed.refreshToken,
|
|
180
|
+
expiresAt: refreshed.expiresAt,
|
|
181
|
+
email: refreshed.email,
|
|
182
|
+
supabaseUrl: creds.supabaseUrl,
|
|
183
|
+
supabaseAnonKey: creds.supabaseAnonKey
|
|
184
|
+
});
|
|
185
|
+
return refreshed.accessToken;
|
|
186
|
+
}
|
|
187
|
+
async function apiRequest(path, options = {}) {
|
|
188
|
+
const { apiUrl } = loadCliConfig();
|
|
189
|
+
const accessToken = await getValidAccessToken();
|
|
190
|
+
const url = new URL(`${apiUrl.replace(/\/+$/, "")}${path}`);
|
|
191
|
+
if (options.query) {
|
|
192
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
193
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const init = {
|
|
197
|
+
method: options.method ?? "GET",
|
|
198
|
+
headers: {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
Authorization: `Bearer ${accessToken}`
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
if (options.body !== void 0) {
|
|
204
|
+
init.body = JSON.stringify(options.body);
|
|
205
|
+
}
|
|
206
|
+
const res = await fetch(url, init);
|
|
207
|
+
if (res.status === 204) {
|
|
208
|
+
return void 0;
|
|
209
|
+
}
|
|
210
|
+
const json = await res.json().catch(() => ({}));
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const errBody = json;
|
|
213
|
+
throw new CliError(
|
|
214
|
+
errBody.message ?? `Request failed with status ${res.status}`,
|
|
215
|
+
res.status,
|
|
216
|
+
json
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return json;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/lib/output.ts
|
|
223
|
+
function printJson(data) {
|
|
224
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
function printError(err, json) {
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
const statusCode = err instanceof CliError ? err.statusCode : void 0;
|
|
230
|
+
if (json) {
|
|
231
|
+
printJson({ error: err instanceof Error ? err.name : "Error", message, statusCode });
|
|
232
|
+
} else {
|
|
233
|
+
process.stderr.write(`Error: ${message}
|
|
234
|
+
`);
|
|
235
|
+
}
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return void 0;
|
|
238
|
+
}
|
|
239
|
+
function printTable(rows, columns) {
|
|
240
|
+
if (rows.length === 0) {
|
|
241
|
+
process.stdout.write("No results.\n");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const widths = columns.map(
|
|
245
|
+
(col) => Math.max(col.length, ...rows.map((row) => (row[col] ?? "").length))
|
|
246
|
+
);
|
|
247
|
+
const renderRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ");
|
|
248
|
+
process.stdout.write(`${renderRow(columns)}
|
|
249
|
+
`);
|
|
250
|
+
process.stdout.write(`${widths.map((w) => "-".repeat(w)).join(" ")}
|
|
251
|
+
`);
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
process.stdout.write(`${renderRow(columns.map((col) => row[col] ?? ""))}
|
|
254
|
+
`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/commands/login.ts
|
|
259
|
+
function registerLoginCommand(program2) {
|
|
260
|
+
program2.command("login").description(
|
|
261
|
+
"Log in with your Podcast Guest CRM email and password. Authenticates directly against Supabase (the same identity provider the web app uses) and caches the session to ~/.config/podcast-guest-crm-cli/credentials.json."
|
|
262
|
+
).option("-e, --email <email>", "Account email (prompted if omitted)").option("-p, --password <password>", "Account password (prompted if omitted; prefer the prompt over this flag on shared machines)").option(
|
|
263
|
+
"--supabase-url <url>",
|
|
264
|
+
"Supabase project URL (or set PODCAST_GUEST_CRM_SUPABASE_URL)"
|
|
265
|
+
).option(
|
|
266
|
+
"--supabase-anon-key <key>",
|
|
267
|
+
"Supabase anon/public API key (or set PODCAST_GUEST_CRM_SUPABASE_ANON_KEY)"
|
|
268
|
+
).action(async (opts, cmd) => {
|
|
269
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
270
|
+
try {
|
|
271
|
+
const cliConfig = loadCliConfig();
|
|
272
|
+
const supabaseUrl = opts.supabaseUrl ?? cliConfig.supabaseUrl;
|
|
273
|
+
const supabaseAnonKey = opts.supabaseAnonKey ?? cliConfig.supabaseAnonKey;
|
|
274
|
+
if (!supabaseUrl || !supabaseAnonKey) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"Missing Supabase project config. Pass --supabase-url and --supabase-anon-key, or set PODCAST_GUEST_CRM_SUPABASE_URL / PODCAST_GUEST_CRM_SUPABASE_ANON_KEY. These match the NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY values your Podcast Guest CRM deployment already uses."
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const email = opts.email ?? await promptText("Email: ");
|
|
280
|
+
const password = opts.password ?? await promptPassword("Password: ");
|
|
281
|
+
const session = await signInWithPassword(supabaseUrl, supabaseAnonKey, email, password);
|
|
282
|
+
saveCredentials({
|
|
283
|
+
accessToken: session.accessToken,
|
|
284
|
+
refreshToken: session.refreshToken,
|
|
285
|
+
expiresAt: session.expiresAt,
|
|
286
|
+
email: session.email,
|
|
287
|
+
supabaseUrl,
|
|
288
|
+
supabaseAnonKey
|
|
289
|
+
});
|
|
290
|
+
if (json) {
|
|
291
|
+
printJson({ loggedIn: true, email: session.email });
|
|
292
|
+
} else {
|
|
293
|
+
process.stdout.write(`Logged in as ${session.email}
|
|
294
|
+
`);
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (err instanceof SupabaseAuthError) {
|
|
298
|
+
printError(new Error(`Login failed: ${err.message}`), json);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
printError(err, json);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/commands/guest.ts
|
|
307
|
+
var STAGES = ["discover", "outreach", "scheduled", "recorded", "published", "follow_up"];
|
|
308
|
+
var PRIORITIES = ["low", "medium", "high"];
|
|
309
|
+
function registerGuestCommands(program2) {
|
|
310
|
+
const guest = program2.command("guest").description("Manage guests in the pipeline (GET/POST/PATCH /api/v1/guests)");
|
|
311
|
+
guest.command("list").description("List guests, paginated and filterable. Wraps GET /api/v1/guests").option("--page <page>", "Page number (default 1)").option("--limit <limit>", "Results per page, max 100 (default 20)").option("--stage <stage>", `Filter by stage: ${STAGES.join(", ")}`).option("--priority <priority>", `Filter by priority: ${PRIORITIES.join(", ")}`).option("--search <search>", "Free-text search").action(async (opts, cmd) => {
|
|
312
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
313
|
+
try {
|
|
314
|
+
const res = await apiRequest("/guests", {
|
|
315
|
+
query: {
|
|
316
|
+
page: opts.page,
|
|
317
|
+
limit: opts.limit,
|
|
318
|
+
stage: opts.stage,
|
|
319
|
+
priority: opts.priority,
|
|
320
|
+
search: opts.search
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
if (json) {
|
|
324
|
+
printJson(res);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
printTable(
|
|
328
|
+
res.data.map((g) => ({
|
|
329
|
+
id: g.id,
|
|
330
|
+
name: g.name,
|
|
331
|
+
company: g.company,
|
|
332
|
+
stage: g.stage,
|
|
333
|
+
priority: g.priority,
|
|
334
|
+
fitScore: String(g.fitScore)
|
|
335
|
+
})),
|
|
336
|
+
["id", "name", "company", "stage", "priority", "fitScore"]
|
|
337
|
+
);
|
|
338
|
+
process.stdout.write(`
|
|
339
|
+
${res.meta.total} total \xB7 page ${res.meta.page} \xB7 limit ${res.meta.limit}
|
|
340
|
+
`);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
printError(err, json);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
guest.command("show <id>").description("Get full detail for a single guest, wraps GET /api/v1/guests/:id").action(async (id, _opts, cmd) => {
|
|
346
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
347
|
+
try {
|
|
348
|
+
const res = await apiRequest(`/guests/${encodeURIComponent(id)}`);
|
|
349
|
+
if (json) {
|
|
350
|
+
printJson(res);
|
|
351
|
+
} else {
|
|
352
|
+
printJson(res.data);
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
printError(err, json);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
guest.command("add").description(
|
|
359
|
+
"Create a new guest, wraps POST /api/v1/guests. Fit scoring runs asynchronously after creation. Fetch the guest again shortly after to see fitScore populate."
|
|
360
|
+
).requiredOption("--name <name>", "Guest full name").requiredOption("--email <email>", "Guest email").requiredOption("--title <title>", "Guest job title").requiredOption("--company <company>", "Guest company").option("--bio <bio>", "Short bio (max 2000 chars)").option("--topics <topics>", 'Comma-separated topics, e.g. "AI,startups"').option("--priority <priority>", `One of: ${PRIORITIES.join(", ")}`).action(async (opts, cmd) => {
|
|
361
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
362
|
+
try {
|
|
363
|
+
const res = await apiRequest("/guests", {
|
|
364
|
+
method: "POST",
|
|
365
|
+
body: {
|
|
366
|
+
name: opts.name,
|
|
367
|
+
email: opts.email,
|
|
368
|
+
title: opts.title,
|
|
369
|
+
company: opts.company,
|
|
370
|
+
bio: opts.bio,
|
|
371
|
+
topics: opts.topics ? opts.topics.split(",").map((t) => t.trim()).filter(Boolean) : void 0,
|
|
372
|
+
priority: opts.priority
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
if (json) {
|
|
376
|
+
printJson(res);
|
|
377
|
+
} else if (res.data?.id) {
|
|
378
|
+
process.stdout.write(`Created guest ${res.data.id} (${res.data.name})
|
|
379
|
+
`);
|
|
380
|
+
} else {
|
|
381
|
+
printJson(res);
|
|
382
|
+
}
|
|
383
|
+
} catch (err) {
|
|
384
|
+
printError(err, json);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
guest.command("stage <id> <newStage>").description(
|
|
388
|
+
"Transition a guest to a new lifecycle stage, wraps PATCH /api/v1/guests/:id/stage. Valid paths: discover -> outreach -> scheduled -> recorded -> published -> follow_up, with back-transitions outreach -> discover (declined), scheduled -> outreach (reschedule), recorded -> scheduled (re-record), follow_up -> outreach (invite back) or follow_up -> published. Invalid transitions are rejected by the API, not this CLI."
|
|
389
|
+
).option("--reason <reason>", "Optional reason recorded with the transition").action(async (id, newStage, opts, cmd) => {
|
|
390
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
391
|
+
try {
|
|
392
|
+
const res = await apiRequest(`/guests/${encodeURIComponent(id)}/stage`, {
|
|
393
|
+
method: "PATCH",
|
|
394
|
+
body: { stage: newStage, reason: opts.reason }
|
|
395
|
+
});
|
|
396
|
+
if (json) {
|
|
397
|
+
printJson(res);
|
|
398
|
+
} else if (res.data?.stage) {
|
|
399
|
+
process.stdout.write(`${res.data.name} is now in stage: ${res.data.stage}
|
|
400
|
+
`);
|
|
401
|
+
} else {
|
|
402
|
+
printJson(res);
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
printError(err, json);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/commands/outreach.ts
|
|
411
|
+
function registerOutreachCommands(program2) {
|
|
412
|
+
const outreach = program2.command("outreach").description("AI-assisted outreach email drafting (/api/v1/outreach)");
|
|
413
|
+
outreach.command("draft <guestId>").description(
|
|
414
|
+
"Generate an AI outreach email draft for a guest, wraps POST /api/v1/outreach/draft. Uses claude-sonnet-4-6 server-side (packages/ai). Returns subject, body, a confidence score, and the reasoning behind the draft."
|
|
415
|
+
).option("--episode-angle <angle>", "Suggested angle for the episode").option("--recent-work <work>", "Reference to the guest's recent work").action(async (guestId, opts, cmd) => {
|
|
416
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
417
|
+
try {
|
|
418
|
+
const res = await apiRequest("/outreach/draft", {
|
|
419
|
+
method: "POST",
|
|
420
|
+
body: {
|
|
421
|
+
guestId,
|
|
422
|
+
episodeAngle: opts.episodeAngle,
|
|
423
|
+
recentWork: opts.recentWork
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
if (json || !res.data?.subject) {
|
|
427
|
+
printJson(res);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const { subject, body, confidenceScore, reasoning } = res.data;
|
|
431
|
+
process.stdout.write(`Subject: ${subject}
|
|
432
|
+
|
|
433
|
+
${body}
|
|
434
|
+
|
|
435
|
+
`);
|
|
436
|
+
process.stdout.write(`Confidence: ${confidenceScore}
|
|
437
|
+
Reasoning: ${reasoning}
|
|
438
|
+
`);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
printError(err, json);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/commands/analytics.ts
|
|
446
|
+
function registerAnalyticsCommands(program2) {
|
|
447
|
+
const analytics = program2.command("analytics").description("Pipeline metrics (/api/v1/analytics)");
|
|
448
|
+
analytics.command("summary").description(
|
|
449
|
+
"Dashboard overview: total guests, stage breakdown, average fit score, reply and booking conversion rates, top topics, recent activity. Wraps GET /api/v1/analytics/overview."
|
|
450
|
+
).action(async (_opts, cmd) => {
|
|
451
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
452
|
+
try {
|
|
453
|
+
const res = await apiRequest("/analytics/overview");
|
|
454
|
+
if (json) {
|
|
455
|
+
printJson(res);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const d = res.data;
|
|
459
|
+
process.stdout.write(`Total guests: ${d.totalGuests}
|
|
460
|
+
`);
|
|
461
|
+
process.stdout.write(`Average fit score: ${d.avgFitScore}
|
|
462
|
+
`);
|
|
463
|
+
process.stdout.write(`Outreach reply rate: ${(d.outreachReplyRate * 100).toFixed(1)}%
|
|
464
|
+
`);
|
|
465
|
+
process.stdout.write(`Booking conversion rate: ${(d.bookingConversionRate * 100).toFixed(1)}%
|
|
466
|
+
|
|
467
|
+
`);
|
|
468
|
+
process.stdout.write("By stage:\n");
|
|
469
|
+
const stageEntries = Object.entries(d.byStage ?? {});
|
|
470
|
+
if (stageEntries.length === 0) {
|
|
471
|
+
process.stdout.write(" (none returned)\n");
|
|
472
|
+
}
|
|
473
|
+
for (const [stage, count] of stageEntries) {
|
|
474
|
+
process.stdout.write(` ${stage.padEnd(12)} ${count}
|
|
475
|
+
`);
|
|
476
|
+
}
|
|
477
|
+
if (d.topTopics.length > 0) {
|
|
478
|
+
process.stdout.write("\nTop topics:\n");
|
|
479
|
+
for (const t of d.topTopics) {
|
|
480
|
+
process.stdout.write(` ${t.topic}: ${t.count}
|
|
481
|
+
`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
printError(err, json);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
analytics.command("pipeline").description(
|
|
489
|
+
"Stage-by-stage conversion funnel, outreach activity timeline, and topic breakdown. Wraps GET /api/v1/analytics/pipeline."
|
|
490
|
+
).action(async (_opts, cmd) => {
|
|
491
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
492
|
+
try {
|
|
493
|
+
const res = await apiRequest("/analytics/pipeline");
|
|
494
|
+
printJson(res);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
printError(err, json);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/index.ts
|
|
502
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
503
|
+
function readVersion() {
|
|
504
|
+
try {
|
|
505
|
+
const pkg = JSON.parse(readFileSync2(join2(__dirname, "..", "package.json"), "utf-8"));
|
|
506
|
+
return pkg.version;
|
|
507
|
+
} catch {
|
|
508
|
+
return "0.0.0";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
var program = new Command();
|
|
512
|
+
program.name("podcast-guest-crm-cli").description(
|
|
513
|
+
"Command-line client for Podcast Guest CRM. Manage the guest lifecycle (discover -> outreach -> scheduled -> recorded -> published -> follow_up), draft AI outreach emails, and pull pipeline analytics, from a terminal or an agent."
|
|
514
|
+
).version(readVersion()).option("--json", "Output machine-readable JSON instead of human-formatted text (every data-returning command supports this)");
|
|
515
|
+
registerLoginCommand(program);
|
|
516
|
+
registerGuestCommands(program);
|
|
517
|
+
registerOutreachCommands(program);
|
|
518
|
+
registerAnalyticsCommands(program);
|
|
519
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
520
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
521
|
+
process.stderr.write(`Error: ${message}
|
|
522
|
+
`);
|
|
523
|
+
process.exitCode = 1;
|
|
524
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "podcast-guest-crm-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for Podcast Guest CRM: manage the guest lifecycle (discover, outreach, scheduled, recorded, published, follow_up), draft AI outreach emails, and pull pipeline analytics from your terminal or an agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"podcast-guest-crm-cli": "./bin/podcast-guest-crm-cli.js",
|
|
10
|
+
"pgcrm": "./bin/podcast-guest-crm-cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup src/index.ts --format esm --dts --clean --target node20",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "eslint src --ext .ts"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"podcast",
|
|
25
|
+
"guest-crm",
|
|
26
|
+
"cli",
|
|
27
|
+
"outreach",
|
|
28
|
+
"supabase",
|
|
29
|
+
"fastify",
|
|
30
|
+
"agent-native"
|
|
31
|
+
],
|
|
32
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
33
|
+
"author": "Rudrendu Paul",
|
|
34
|
+
"contributors": [
|
|
35
|
+
"Sourav Nandy"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/RudrenduPaul/podcast-guest-crm.git",
|
|
40
|
+
"directory": "packages/cli"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/RudrenduPaul/podcast-guest-crm#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/RudrenduPaul/podcast-guest-crm/issues"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"commander": "^12.1.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"tsup": "^8.0.2",
|
|
54
|
+
"tsx": "^4.7.0",
|
|
55
|
+
"typescript": "^5.4.0",
|
|
56
|
+
"vitest": "^1.4.0",
|
|
57
|
+
"@types/node": "^20.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|