careervivid 1.12.46 → 1.12.50
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/dist/branding.d.ts.map +1 -1
- package/dist/branding.js +14 -6
- package/dist/commands/interview.d.ts +21 -0
- package/dist/commands/interview.d.ts.map +1 -0
- package/dist/commands/interview.js +690 -0
- package/dist/commands/referral.d.ts.map +1 -1
- package/dist/commands/referral.js +53 -43
- package/dist/index.js +3 -1
- package/package.json +1 -1
package/dist/branding.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding.d.ts","sourceRoot":"","sources":["../src/branding.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"branding.d.ts","sourceRoot":"","sources":["../src/branding.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,MAAM;;;;;;CAMlB,CAAC;AAMF,eAAO,MAAM,UAAU,sXAMtB,CAAC;AAEF,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAID,wBAAgB,YAAY,IAAI,IAAI,CA6BnC;AAID,wBAAgB,aAAa,IAAI,MAAM,CAOtC"}
|
package/dist/branding.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import boxen from "boxen";
|
|
3
3
|
import gradient from "gradient-string";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
4
9
|
// ── Colors ───────────────────────────────────────────────────────────────────
|
|
5
10
|
export const COLORS = {
|
|
6
11
|
primary: "#3b82f6", // Blue-500
|
|
@@ -26,22 +31,25 @@ export function printWelcome() {
|
|
|
26
31
|
const logo = getBrandedLogo();
|
|
27
32
|
const content = `
|
|
28
33
|
${chalk.bold("Welcome to the CareerVivid CLI!")}
|
|
29
|
-
${chalk.dim("Your
|
|
34
|
+
${chalk.dim("Your AI-powered career management command-center.")}
|
|
30
35
|
|
|
31
36
|
${chalk.white("To get started, run:")}
|
|
32
|
-
${chalk.cyan(" cv
|
|
37
|
+
${chalk.cyan(" cv login")}
|
|
33
38
|
|
|
34
39
|
${chalk.dim("Quick Commands:")}
|
|
35
|
-
${chalk.white("• cv
|
|
36
|
-
${chalk.white("• cv
|
|
37
|
-
${chalk.white("• cv
|
|
40
|
+
${chalk.white("• cv agent")} Start the AI career agent
|
|
41
|
+
${chalk.white("• cv jobs hunt")} Search & score job openings
|
|
42
|
+
${chalk.white("• cv resumes list")} View your AI-parsed resumes
|
|
43
|
+
${chalk.white("• cv referral")} View your referral dashboard
|
|
44
|
+
${chalk.white("• cv publish <file>")} Publish to CareerVivid
|
|
45
|
+
${chalk.white("• cv help")} Show all commands
|
|
38
46
|
`;
|
|
39
47
|
console.log(boxen(logo + "\n" + content, {
|
|
40
48
|
padding: 1,
|
|
41
49
|
margin: 1,
|
|
42
50
|
borderStyle: "round",
|
|
43
51
|
borderColor: COLORS.primary,
|
|
44
|
-
title: chalk.bold.blue(
|
|
52
|
+
title: chalk.bold.blue(` v${pkg?.version ?? "latest"} `),
|
|
45
53
|
titleAlignment: "right",
|
|
46
54
|
}));
|
|
47
55
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cv interview — interactive AI mock interview with real-time voice audio.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* cv interview Prompt for role interactively (voice mode)
|
|
6
|
+
* cv interview --role "Sr SWE" Specify role directly (voice mode)
|
|
7
|
+
* cv interview --role "PM" --text Text-only fallback (no audio required)
|
|
8
|
+
* cv interview --role "SDE" --resume <id> Load resume for context
|
|
9
|
+
*
|
|
10
|
+
* Voice mode requires sox (handles both mic input and speaker output):
|
|
11
|
+
* macOS: brew install sox
|
|
12
|
+
* Linux: sudo apt install sox
|
|
13
|
+
*
|
|
14
|
+
* AI calls:
|
|
15
|
+
* - Token vend: cliGetInterviewToken Cloud Function (validates cv_live_ key, deducts credits)
|
|
16
|
+
* - Voice session: gemini-3.1-flash-live-preview via @google/genai Live API (direct WebSocket)
|
|
17
|
+
* - Feedback: agentProxy Cloud Function (standard HTTP, existing pattern)
|
|
18
|
+
*/
|
|
19
|
+
import { Command } from "commander";
|
|
20
|
+
export declare function registerInterviewCommand(program: Command): void;
|
|
21
|
+
//# sourceMappingURL=interview.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interview.d.ts","sourceRoot":"","sources":["../../src/commands/interview.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA4oBpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmG/D"}
|
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cv interview — interactive AI mock interview with real-time voice audio.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* cv interview Prompt for role interactively (voice mode)
|
|
6
|
+
* cv interview --role "Sr SWE" Specify role directly (voice mode)
|
|
7
|
+
* cv interview --role "PM" --text Text-only fallback (no audio required)
|
|
8
|
+
* cv interview --role "SDE" --resume <id> Load resume for context
|
|
9
|
+
*
|
|
10
|
+
* Voice mode requires sox (handles both mic input and speaker output):
|
|
11
|
+
* macOS: brew install sox
|
|
12
|
+
* Linux: sudo apt install sox
|
|
13
|
+
*
|
|
14
|
+
* AI calls:
|
|
15
|
+
* - Token vend: cliGetInterviewToken Cloud Function (validates cv_live_ key, deducts credits)
|
|
16
|
+
* - Voice session: gemini-3.1-flash-live-preview via @google/genai Live API (direct WebSocket)
|
|
17
|
+
* - Feedback: agentProxy Cloud Function (standard HTTP, existing pattern)
|
|
18
|
+
*/
|
|
19
|
+
import chalk from "chalk";
|
|
20
|
+
import readline from "readline";
|
|
21
|
+
import ora from "ora";
|
|
22
|
+
import { spawn } from "child_process";
|
|
23
|
+
import { GoogleGenAI, Modality } from "@google/genai";
|
|
24
|
+
import { getApiKey } from "../config.js";
|
|
25
|
+
import { isApiError, resumeGet } from "../api.js";
|
|
26
|
+
/** Strip ANSI escape codes for accurate string length measurement */
|
|
27
|
+
const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, "");
|
|
28
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
29
|
+
const AGENT_PROXY_URL = process.env.CV_FUNCTIONS_URL
|
|
30
|
+
? `${process.env.CV_FUNCTIONS_URL}/agentProxy`
|
|
31
|
+
: "https://us-west1-jastalk-firebase.cloudfunctions.net/agentProxy";
|
|
32
|
+
const CLI_TOKEN_URL = process.env.CV_FUNCTIONS_URL
|
|
33
|
+
? `${process.env.CV_FUNCTIONS_URL}/cliGetInterviewToken`
|
|
34
|
+
: "https://us-west1-jastalk-firebase.cloudfunctions.net/cliGetInterviewToken";
|
|
35
|
+
const CLI_BILL_URL = process.env.CV_FUNCTIONS_URL
|
|
36
|
+
? `${process.env.CV_FUNCTIONS_URL}/cliInterviewBill`
|
|
37
|
+
: "https://us-west1-jastalk-firebase.cloudfunctions.net/cliInterviewBill";
|
|
38
|
+
const LIVE_MODEL = "gemini-3.1-flash-live-preview";
|
|
39
|
+
const FEEDBACK_MODEL = "gemini-2.5-flash";
|
|
40
|
+
const END_TOKEN = "<END_INTERVIEW>";
|
|
41
|
+
const WRAP_WIDTH = 80;
|
|
42
|
+
// Audio constants (matching tts.py)
|
|
43
|
+
const SEND_SAMPLE_RATE = 16000; // mic → Gemini (16kHz PCM)
|
|
44
|
+
const RECV_SAMPLE_RATE = 24000; // Gemini → speaker (24kHz PCM)
|
|
45
|
+
const CHUNK_MS = 100; // send audio in 100ms chunks
|
|
46
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
47
|
+
function wordWrap(text, width = WRAP_WIDTH) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
for (const paragraph of text.split("\n")) {
|
|
50
|
+
const words = paragraph.split(" ");
|
|
51
|
+
let current = "";
|
|
52
|
+
for (const word of words) {
|
|
53
|
+
if (stripAnsi(current + " " + word).length > width && current.length > 0) {
|
|
54
|
+
lines.push(current);
|
|
55
|
+
current = word;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
current = current.length === 0 ? word : current + " " + word;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (current.length > 0)
|
|
62
|
+
lines.push(current);
|
|
63
|
+
if (paragraph === "")
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
function printAI(text) {
|
|
69
|
+
const clean = text.replace(END_TOKEN, "").trim();
|
|
70
|
+
if (!clean)
|
|
71
|
+
return;
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(chalk.cyan.bold(" Vivid ❯"));
|
|
74
|
+
wordWrap(clean).split("\n").forEach(l => console.log(` ${chalk.cyan(l)}`));
|
|
75
|
+
console.log("");
|
|
76
|
+
}
|
|
77
|
+
function printUser(text) {
|
|
78
|
+
if (!text.trim())
|
|
79
|
+
return;
|
|
80
|
+
console.log(chalk.dim("\n [You said] ") + chalk.white(text.trim()));
|
|
81
|
+
}
|
|
82
|
+
function printSystem(msg) {
|
|
83
|
+
console.log(chalk.dim(`\n ${msg}\n`));
|
|
84
|
+
}
|
|
85
|
+
function printBanner(role, mode) {
|
|
86
|
+
const modeLabel = mode === "voice"
|
|
87
|
+
? chalk.green("🎙 Voice Mode")
|
|
88
|
+
: chalk.yellow("⌨ Text Mode");
|
|
89
|
+
console.log("\n" + chalk.bold.bgHex("#4f46e5").white(" CareerVivid — Interview Studio "));
|
|
90
|
+
console.log(chalk.dim(` Role: ${role}`));
|
|
91
|
+
console.log(chalk.dim(` ${modeLabel}`));
|
|
92
|
+
console.log(chalk.dim(" ─────────────────────────────────────────────────"));
|
|
93
|
+
if (mode === "voice") {
|
|
94
|
+
console.log(chalk.dim(" Speak your answers naturally."));
|
|
95
|
+
console.log(chalk.dim(" Press Ctrl+C to end and generate your feedback report."));
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.log(chalk.dim(` Type your answers. Type ${chalk.white("exit")} or press Ctrl+C to end.`));
|
|
99
|
+
}
|
|
100
|
+
console.log("");
|
|
101
|
+
}
|
|
102
|
+
function printReport(report) {
|
|
103
|
+
const header = chalk.bgHex("#4f46e5").white.bold;
|
|
104
|
+
console.log("\n" + header(" ═══════════════════════════════════════ "));
|
|
105
|
+
console.log(header(" 📋 Interview Feedback Report "));
|
|
106
|
+
console.log(header(" ═══════════════════════════════════════ ") + "\n");
|
|
107
|
+
const score = (val) => {
|
|
108
|
+
const color = val >= 80 ? chalk.green : val >= 60 ? chalk.yellow : chalk.red;
|
|
109
|
+
return color.bold(`${val}/100`);
|
|
110
|
+
};
|
|
111
|
+
console.log(chalk.bold(" Scores"));
|
|
112
|
+
console.log(` Overall ${score(report.overallScore)}`);
|
|
113
|
+
console.log(` Communication ${score(report.communicationScore)}`);
|
|
114
|
+
console.log(` Confidence ${score(report.confidenceScore)}`);
|
|
115
|
+
console.log(` Relevance ${score(report.relevanceScore)}`);
|
|
116
|
+
console.log("\n" + chalk.green.bold(" ✅ Strengths"));
|
|
117
|
+
wordWrap(report.strengths, 72).split("\n").forEach(l => console.log(` ${chalk.green(l)}`));
|
|
118
|
+
console.log("\n" + chalk.yellow.bold(" 💡 Areas for Improvement"));
|
|
119
|
+
wordWrap(report.areasForImprovement, 72).split("\n").forEach(l => console.log(` ${chalk.yellow(l)}`));
|
|
120
|
+
console.log("\n" + chalk.dim(" ─────────────────────────────────────────────────────────────"));
|
|
121
|
+
console.log(chalk.dim(" View full history at: https://careervivid.app/interview"));
|
|
122
|
+
console.log("");
|
|
123
|
+
}
|
|
124
|
+
// ─── sox audio check ──────────────────────────────────────────────────────────
|
|
125
|
+
/** Check if sox is available on PATH. Returns its path or null. */
|
|
126
|
+
async function findSox() {
|
|
127
|
+
const candidates = [
|
|
128
|
+
"/opt/homebrew/bin/sox", // Apple Silicon brew
|
|
129
|
+
"/usr/local/bin/sox", // Intel brew
|
|
130
|
+
"/usr/bin/sox", // Linux
|
|
131
|
+
"sox", // if on $PATH
|
|
132
|
+
];
|
|
133
|
+
for (const p of candidates) {
|
|
134
|
+
try {
|
|
135
|
+
await new Promise((resolve, reject) => {
|
|
136
|
+
const probe = spawn(p, ["--version"]);
|
|
137
|
+
probe.on("close", (code) => (code === 0 ? resolve() : reject()));
|
|
138
|
+
probe.on("error", reject);
|
|
139
|
+
});
|
|
140
|
+
return p;
|
|
141
|
+
}
|
|
142
|
+
catch { /* try next */ }
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
// ─── Sox audio I/O ────────────────────────────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* Start microphone recording via sox.
|
|
149
|
+
* Returns a ChildProcess whose stdout emits raw 16kHz/16-bit/mono PCM.
|
|
150
|
+
*/
|
|
151
|
+
function startMic(soxPath) {
|
|
152
|
+
// sox -t coreaudio default → raw PCM 16kHz 16-bit signed mono
|
|
153
|
+
// Falls back to -t alsa on Linux
|
|
154
|
+
const inputType = process.platform === "darwin" ? "coreaudio" : "alsa";
|
|
155
|
+
const inputDevice = process.platform === "darwin" ? "default" : "default";
|
|
156
|
+
return spawn(soxPath, [
|
|
157
|
+
"-q",
|
|
158
|
+
"-t", inputType, inputDevice,
|
|
159
|
+
"-r", String(SEND_SAMPLE_RATE),
|
|
160
|
+
"-b", "16",
|
|
161
|
+
"-e", "signed",
|
|
162
|
+
"-c", "1",
|
|
163
|
+
"-t", "raw", "-",
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Start a sox speaker subprocess.
|
|
168
|
+
* Returns a ChildProcess whose stdin accepts raw 24kHz/16-bit/mono PCM.
|
|
169
|
+
*/
|
|
170
|
+
function startSpeaker(soxPath) {
|
|
171
|
+
const outputType = process.platform === "darwin" ? "coreaudio" : "alsa";
|
|
172
|
+
const outputDevice = process.platform === "darwin" ? "default" : "default";
|
|
173
|
+
return spawn(soxPath, [
|
|
174
|
+
"-q",
|
|
175
|
+
"-t", "raw",
|
|
176
|
+
"-r", String(RECV_SAMPLE_RATE),
|
|
177
|
+
"-b", "16",
|
|
178
|
+
"-e", "signed",
|
|
179
|
+
"-c", "1", "-",
|
|
180
|
+
"-t", outputType, outputDevice,
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
// ─── Token Vend ───────────────────────────────────────────────────────────────
|
|
184
|
+
async function getGeminiToken(role) {
|
|
185
|
+
const apiKey = getApiKey();
|
|
186
|
+
if (!apiKey)
|
|
187
|
+
throw new Error("No API key. Run: cv login");
|
|
188
|
+
const res = await fetch(CLI_TOKEN_URL, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ apiKey, role }),
|
|
192
|
+
});
|
|
193
|
+
const data = await res.json();
|
|
194
|
+
if (res.status === 402) {
|
|
195
|
+
throw new Error("AI credit limit reached. Upgrade at https://careervivid.app/pricing");
|
|
196
|
+
}
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
throw new Error(data?.error || `Token vend failed (${res.status})`);
|
|
199
|
+
}
|
|
200
|
+
return { geminiKey: data.geminiKey, sessionId: data.sessionId };
|
|
201
|
+
}
|
|
202
|
+
// ─── Duration Billing ─────────────────────────────────────────────────────────
|
|
203
|
+
/** Call cliInterviewBill at session end. Returns credit summary for display. */
|
|
204
|
+
async function billSession(sessionId) {
|
|
205
|
+
const apiKey = getApiKey();
|
|
206
|
+
if (!apiKey || !sessionId)
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(CLI_BILL_URL, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
body: JSON.stringify({ apiKey, sessionId }),
|
|
213
|
+
});
|
|
214
|
+
if (!res.ok)
|
|
215
|
+
return null;
|
|
216
|
+
return await res.json();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null; // billing failure is non-fatal for UX
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ─── agentProxy (question gen + feedback) ────────────────────────────────────
|
|
223
|
+
async function callAgentProxy(opts) {
|
|
224
|
+
const apiKey = getApiKey();
|
|
225
|
+
if (!apiKey)
|
|
226
|
+
throw new Error("No API key. Run: cv login");
|
|
227
|
+
const body = {
|
|
228
|
+
apiKey,
|
|
229
|
+
model: FEEDBACK_MODEL,
|
|
230
|
+
contents: opts.contents,
|
|
231
|
+
};
|
|
232
|
+
if (opts.systemInstruction)
|
|
233
|
+
body.systemInstruction = opts.systemInstruction;
|
|
234
|
+
if (opts.responseSchema) {
|
|
235
|
+
body.generationConfig = {
|
|
236
|
+
responseMimeType: opts.responseMimeType ?? "application/json",
|
|
237
|
+
responseSchema: opts.responseSchema,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const res = await fetch(AGENT_PROXY_URL, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify(body),
|
|
244
|
+
});
|
|
245
|
+
const data = await res.json();
|
|
246
|
+
if (!res.ok)
|
|
247
|
+
throw new Error(data?.error || `agentProxy error (${res.status})`);
|
|
248
|
+
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
|
249
|
+
return parts.map((p) => p.text ?? "").join("").trim();
|
|
250
|
+
}
|
|
251
|
+
// ─── Build system prompt ──────────────────────────────────────────────────────
|
|
252
|
+
function buildSystemPrompt(role, questions, resumeContext) {
|
|
253
|
+
let prompt = `You are an expert AI interviewer. Your name is Vivid. If the candidate asks your name, say "My name is Vivid." Do not say Gemini or any other name.
|
|
254
|
+
|
|
255
|
+
You are conducting a real-time voice interview for the position of: "${role}".
|
|
256
|
+
|
|
257
|
+
Start with a warm, polished introduction that outlines the role and key responsibilities. Then ask: "Do you have any questions before we begin the interview?" Wait briefly before proceeding.
|
|
258
|
+
|
|
259
|
+
Ask the questions below one at a time. You may ask one or two follow-up questions when a candidate's answer invites it.
|
|
260
|
+
|
|
261
|
+
After the final question and the candidate's response:
|
|
262
|
+
1. Give a 2–3 sentence summary of overall performance.
|
|
263
|
+
2. Provide 2–3 short, personalized improvement tips.
|
|
264
|
+
3. End with: "Thank you for your time today! Your feedback report is being generated."
|
|
265
|
+
4. Append the exact token ${END_TOKEN} at the very end (do not narrate this token).
|
|
266
|
+
|
|
267
|
+
Interview Questions:
|
|
268
|
+
${questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}
|
|
269
|
+
|
|
270
|
+
**Policies:**
|
|
271
|
+
- Never fabricate company details; suggest candidates verify with their recruiter.
|
|
272
|
+
- Maintain a polite, professional, encouraging tone.
|
|
273
|
+
- Keep responses concise — this is a voice interview, so avoid long monologues.
|
|
274
|
+
- Do not use markdown formatting. Speak naturally.`;
|
|
275
|
+
if (resumeContext) {
|
|
276
|
+
prompt += `\n\nCandidate resume (use for targeted follow-ups):\n--- RESUME ---\n${resumeContext}`;
|
|
277
|
+
}
|
|
278
|
+
return prompt;
|
|
279
|
+
}
|
|
280
|
+
// ─── Generate Questions (via agentProxy) ─────────────────────────────────────
|
|
281
|
+
async function generateQuestions(role, numQuestions) {
|
|
282
|
+
const spinner = ora(chalk.dim("Generating interview questions...")).start();
|
|
283
|
+
try {
|
|
284
|
+
const prompt = `Based on the following role, generate ${numQuestions} insightful interview questions covering technical skills, behavioral competencies, and role-specific scenarios. Return ONLY a valid JSON array of strings.\n\nRole: "${role}"`;
|
|
285
|
+
const text = await callAgentProxy({
|
|
286
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
287
|
+
responseSchema: { type: "ARRAY", items: { type: "STRING" } },
|
|
288
|
+
responseMimeType: "application/json",
|
|
289
|
+
});
|
|
290
|
+
let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
291
|
+
const questions = JSON.parse(clean);
|
|
292
|
+
spinner.succeed(chalk.dim(`Generated ${questions.length} questions`));
|
|
293
|
+
return questions;
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
spinner.fail("Failed to generate questions.");
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ─── Analyze Transcript (via agentProxy) ─────────────────────────────────────
|
|
301
|
+
async function analyzeTranscript(transcript, role) {
|
|
302
|
+
const spinner = ora(chalk.dim("Generating feedback report...")).start();
|
|
303
|
+
try {
|
|
304
|
+
const formatted = transcript
|
|
305
|
+
.map(e => `${e.speaker === "ai" ? "Interviewer" : "Candidate"}: ${e.text}`)
|
|
306
|
+
.join("\n\n");
|
|
307
|
+
const prompt = `You are an expert interview coach. Analyze this interview transcript for the role "${role}" and return a JSON object with: overallScore, communicationScore, confidenceScore, relevanceScore (numbers 0-100), strengths (string), areasForImprovement (string).\n\nTranscript:\n---\n${formatted}\n---`;
|
|
308
|
+
const text = await callAgentProxy({
|
|
309
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
310
|
+
responseSchema: {
|
|
311
|
+
type: "OBJECT",
|
|
312
|
+
properties: {
|
|
313
|
+
overallScore: { type: "NUMBER" },
|
|
314
|
+
communicationScore: { type: "NUMBER" },
|
|
315
|
+
confidenceScore: { type: "NUMBER" },
|
|
316
|
+
relevanceScore: { type: "NUMBER" },
|
|
317
|
+
strengths: { type: "STRING" },
|
|
318
|
+
areasForImprovement: { type: "STRING" },
|
|
319
|
+
},
|
|
320
|
+
required: ["overallScore", "communicationScore", "confidenceScore", "relevanceScore", "strengths", "areasForImprovement"],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
324
|
+
const report = JSON.parse(clean);
|
|
325
|
+
spinner.succeed(chalk.dim("Feedback report ready"));
|
|
326
|
+
return report;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
spinner.fail("Failed to generate feedback.");
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ─── VOICE SESSION ────────────────────────────────────────────────────────────
|
|
334
|
+
async function runVoiceSession(opts) {
|
|
335
|
+
const { role, questions, resumeContext, soxPath } = opts;
|
|
336
|
+
printBanner(role, "voice");
|
|
337
|
+
// Get Gemini token from Cloud Function
|
|
338
|
+
const connectSpinner = ora(chalk.dim("Connecting to Vivid...")).start();
|
|
339
|
+
let geminiKey;
|
|
340
|
+
let sessionId;
|
|
341
|
+
try {
|
|
342
|
+
({ geminiKey, sessionId } = await getGeminiToken(role));
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
connectSpinner.fail(chalk.red(err.message));
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
|
349
|
+
const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
|
|
350
|
+
const transcript = [];
|
|
351
|
+
let ended = false;
|
|
352
|
+
let outputBuf = ""; // accumulates AI transcription
|
|
353
|
+
let inputBuf = ""; // accumulates user transcription
|
|
354
|
+
// Half-duplex mute: stop sending mic audio while Vivid is speaking
|
|
355
|
+
// to prevent the mic picking up speaker output (echo loop).
|
|
356
|
+
let vividSpeaking = false;
|
|
357
|
+
let muteTimer = null;
|
|
358
|
+
// ── Audio processes ──────────────────────────────────────────────────
|
|
359
|
+
const micProc = startMic(soxPath);
|
|
360
|
+
const speakerProc = startSpeaker(soxPath);
|
|
361
|
+
micProc.stderr.on("data", () => { });
|
|
362
|
+
speakerProc.stderr.on("data", () => { });
|
|
363
|
+
// ── Connect to Live API ──────────────────────────────────────────────
|
|
364
|
+
let session;
|
|
365
|
+
try {
|
|
366
|
+
session = await ai.live.connect({
|
|
367
|
+
model: LIVE_MODEL,
|
|
368
|
+
callbacks: {
|
|
369
|
+
onopen: () => {
|
|
370
|
+
connectSpinner.succeed(chalk.green("✅ Vivid is live — start speaking!"));
|
|
371
|
+
process.stdout.write(chalk.green("\n ● Listening...\r"));
|
|
372
|
+
// Pipe mic PCM → Gemini, muted while Vivid is speaking
|
|
373
|
+
micProc.stdout.on("data", (chunk) => {
|
|
374
|
+
if (ended || chunk.length === 0 || vividSpeaking)
|
|
375
|
+
return;
|
|
376
|
+
try {
|
|
377
|
+
session.sendRealtimeInput({
|
|
378
|
+
audio: {
|
|
379
|
+
data: chunk.toString("base64"),
|
|
380
|
+
mimeType: `audio/pcm;rate=${SEND_SAMPLE_RATE}`,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
catch { /* session may be closing */ }
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
onmessage: (msg) => {
|
|
388
|
+
// ── Audio output (Vivid speaking) → sox speaker ───
|
|
389
|
+
const audioPart = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
|
|
390
|
+
if (audioPart) {
|
|
391
|
+
// Mute mic: cancel any pending unmute and stay muted
|
|
392
|
+
vividSpeaking = true;
|
|
393
|
+
if (muteTimer) {
|
|
394
|
+
clearTimeout(muteTimer);
|
|
395
|
+
muteTimer = null;
|
|
396
|
+
}
|
|
397
|
+
process.stdout.write(chalk.blue(" ◈ Vivid speaking...\r"));
|
|
398
|
+
const pcmBuf = Buffer.from(audioPart, "base64");
|
|
399
|
+
speakerProc.stdin.write(pcmBuf);
|
|
400
|
+
}
|
|
401
|
+
// ── Output transcription (what Vivid said) ────────
|
|
402
|
+
const outText = msg.serverContent?.outputTranscription?.text;
|
|
403
|
+
if (outText)
|
|
404
|
+
outputBuf += outText;
|
|
405
|
+
// ── Input transcription (what user said) ──────────
|
|
406
|
+
const inText = msg.serverContent?.inputTranscription?.text;
|
|
407
|
+
if (inText)
|
|
408
|
+
inputBuf += inText;
|
|
409
|
+
// ── Turn complete ─────────────────────────────────
|
|
410
|
+
if (msg.serverContent?.turnComplete) {
|
|
411
|
+
if (outputBuf.trim()) {
|
|
412
|
+
const aiText = outputBuf.trim();
|
|
413
|
+
printAI(aiText);
|
|
414
|
+
transcript.push({ speaker: "ai", text: aiText.replace(END_TOKEN, "").trim() });
|
|
415
|
+
if (aiText.includes(END_TOKEN))
|
|
416
|
+
ended = true;
|
|
417
|
+
outputBuf = "";
|
|
418
|
+
}
|
|
419
|
+
if (inputBuf.trim()) {
|
|
420
|
+
printUser(inputBuf.trim());
|
|
421
|
+
transcript.push({ speaker: "user", text: inputBuf.trim() });
|
|
422
|
+
inputBuf = "";
|
|
423
|
+
}
|
|
424
|
+
if (!ended) {
|
|
425
|
+
// Unmute mic after a short delay so the speaker
|
|
426
|
+
// tail doesn't get captured (echo suppression buffer)
|
|
427
|
+
muteTimer = setTimeout(() => {
|
|
428
|
+
vividSpeaking = false;
|
|
429
|
+
muteTimer = null;
|
|
430
|
+
process.stdout.write(chalk.green(" ● Listening...\r"));
|
|
431
|
+
}, 800);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
onerror: (e) => {
|
|
436
|
+
console.log(chalk.red(`\n Connection error: ${e.message ?? e}`));
|
|
437
|
+
ended = true;
|
|
438
|
+
},
|
|
439
|
+
onclose: () => { ended = true; },
|
|
440
|
+
},
|
|
441
|
+
config: {
|
|
442
|
+
responseModalities: [Modality.AUDIO],
|
|
443
|
+
inputAudioTranscription: {},
|
|
444
|
+
outputAudioTranscription: {},
|
|
445
|
+
speechConfig: {
|
|
446
|
+
voiceConfig: {
|
|
447
|
+
prebuiltVoiceConfig: { voiceName: "Zephyr" },
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
systemInstruction: { parts: [{ text: systemInstruction }] },
|
|
451
|
+
contextWindowCompression: {
|
|
452
|
+
triggerTokens: "104857",
|
|
453
|
+
slidingWindow: { targetTokens: "52428" },
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
connectSpinner.fail(chalk.red(`Failed to connect: ${err.message}`));
|
|
460
|
+
micProc.kill();
|
|
461
|
+
speakerProc.kill();
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
// ── Wait until interview ends or Ctrl+C ─────────────────────────────
|
|
465
|
+
await new Promise((resolve) => {
|
|
466
|
+
const check = setInterval(() => {
|
|
467
|
+
if (ended) {
|
|
468
|
+
clearInterval(check);
|
|
469
|
+
resolve();
|
|
470
|
+
}
|
|
471
|
+
}, 200);
|
|
472
|
+
process.once("SIGINT", () => {
|
|
473
|
+
clearInterval(check);
|
|
474
|
+
printSystem("Interview ended by user.");
|
|
475
|
+
ended = true;
|
|
476
|
+
resolve();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
480
|
+
try {
|
|
481
|
+
micProc.kill("SIGTERM");
|
|
482
|
+
}
|
|
483
|
+
catch { /* ignore */ }
|
|
484
|
+
try {
|
|
485
|
+
speakerProc.stdin.end();
|
|
486
|
+
}
|
|
487
|
+
catch { /* ignore */ }
|
|
488
|
+
try {
|
|
489
|
+
session.close();
|
|
490
|
+
}
|
|
491
|
+
catch { /* ignore */ }
|
|
492
|
+
// Wait a moment for final audio to drain
|
|
493
|
+
await new Promise(r => setTimeout(r, 800));
|
|
494
|
+
// ── Bill session (duration-based) ───────────────────────────────────
|
|
495
|
+
const billSpinner = ora(chalk.dim("Calculating session cost...")).start();
|
|
496
|
+
const bill = await billSession(sessionId);
|
|
497
|
+
if (bill) {
|
|
498
|
+
billSpinner.succeed(chalk.dim(`Session: ${bill.durationMinutes}min · `) +
|
|
499
|
+
chalk.hex("#4f46e5").bold(`${bill.creditsCharged} credits used`) +
|
|
500
|
+
chalk.dim(` · ${bill.creditsRemaining} remaining`));
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
billSpinner.stop();
|
|
504
|
+
}
|
|
505
|
+
// ── Feedback report ──────────────────────────────────────────────────
|
|
506
|
+
const userTurns = transcript.filter(t => t.speaker === "user").length;
|
|
507
|
+
if (userTurns < 1) {
|
|
508
|
+
printSystem("Not enough conversation to generate a feedback report.");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
console.log(chalk.dim("\n Generating your personalized feedback report..."));
|
|
512
|
+
try {
|
|
513
|
+
const report = await analyzeTranscript(transcript, role);
|
|
514
|
+
printReport(report);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
console.log(chalk.red(` Failed to generate feedback: ${err.message}`));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ─── TEXT SESSION (fallback) ──────────────────────────────────────────────────
|
|
521
|
+
async function runTextSession(opts) {
|
|
522
|
+
const { role, questions, resumeContext } = opts;
|
|
523
|
+
const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
|
|
524
|
+
printBanner(role, "text");
|
|
525
|
+
const history = [];
|
|
526
|
+
const transcript = [];
|
|
527
|
+
let ended = false;
|
|
528
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
529
|
+
const askUser = () => new Promise(resolve => {
|
|
530
|
+
if (process.stdin.isTTY)
|
|
531
|
+
process.stdout.write(chalk.white.bold("\n you ❯ "));
|
|
532
|
+
rl.once("line", line => resolve(line.trim()));
|
|
533
|
+
rl.once("close", () => resolve(null));
|
|
534
|
+
});
|
|
535
|
+
const spinner = ora(chalk.dim("Vivid is connecting...")).start();
|
|
536
|
+
try {
|
|
537
|
+
const greeting = await callAgentProxy({
|
|
538
|
+
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
|
539
|
+
systemInstruction,
|
|
540
|
+
});
|
|
541
|
+
spinner.stop();
|
|
542
|
+
history.push({ role: "user", parts: [{ text: "Hello" }] });
|
|
543
|
+
history.push({ role: "model", parts: [{ text: greeting }] });
|
|
544
|
+
transcript.push({ speaker: "user", text: "Hello" });
|
|
545
|
+
transcript.push({ speaker: "ai", text: greeting });
|
|
546
|
+
if (greeting.includes(END_TOKEN))
|
|
547
|
+
ended = true;
|
|
548
|
+
printAI(greeting);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
spinner.fail(chalk.red("Failed to connect to AI interviewer."));
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
while (!ended) {
|
|
555
|
+
const input = await askUser();
|
|
556
|
+
if (input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
|
|
557
|
+
printSystem("Interview ended early.");
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
if (input === "")
|
|
561
|
+
continue;
|
|
562
|
+
history.push({ role: "user", parts: [{ text: input }] });
|
|
563
|
+
transcript.push({ speaker: "user", text: input });
|
|
564
|
+
const aiSpinner = ora({ text: "" }).start();
|
|
565
|
+
try {
|
|
566
|
+
const aiResponse = await callAgentProxy({ contents: history, systemInstruction });
|
|
567
|
+
aiSpinner.stop();
|
|
568
|
+
history.push({ role: "model", parts: [{ text: aiResponse }] });
|
|
569
|
+
transcript.push({ speaker: "ai", text: aiResponse.replace(END_TOKEN, "").trim() });
|
|
570
|
+
if (aiResponse.includes(END_TOKEN))
|
|
571
|
+
ended = true;
|
|
572
|
+
printAI(aiResponse);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
aiSpinner.stop();
|
|
576
|
+
console.log(chalk.red(`\n Error: ${err.message}\n`));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
rl.close();
|
|
580
|
+
const userTurns = transcript.filter(t => t.speaker === "user").length;
|
|
581
|
+
if (userTurns < 2) {
|
|
582
|
+
printSystem("Not enough conversation to generate a feedback report.");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
console.log(chalk.dim("\n Generating your personalized feedback report..."));
|
|
586
|
+
try {
|
|
587
|
+
const report = await analyzeTranscript(transcript, role);
|
|
588
|
+
printReport(report);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
console.log(chalk.red(` Failed to generate feedback: ${err.message}`));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ─── Command Registration ─────────────────────────────────────────────────────
|
|
595
|
+
export function registerInterviewCommand(program) {
|
|
596
|
+
program
|
|
597
|
+
.command("interview")
|
|
598
|
+
.description("Start an interactive AI voice interview session in the terminal")
|
|
599
|
+
.option("-r, --role <role>", "Role or job description to practice for")
|
|
600
|
+
.option("-q, --questions <n>", "Number of interview questions to generate", "5")
|
|
601
|
+
.option("--resume <id>", "Load a specific resume ID for context (from cv resumes list)")
|
|
602
|
+
.option("--text", "Use text-only mode (no audio required)")
|
|
603
|
+
.addHelpText("after", `
|
|
604
|
+
Examples:
|
|
605
|
+
cv interview
|
|
606
|
+
cv interview --role "Senior Software Engineer at Stripe"
|
|
607
|
+
cv interview --role "Product Manager" --questions 7
|
|
608
|
+
cv interview --role "Data Scientist" --resume my-resume-id
|
|
609
|
+
cv interview --role "SWE" --text (text-only, no sox needed)
|
|
610
|
+
|
|
611
|
+
Voice mode setup (one-time):
|
|
612
|
+
macOS: brew install sox
|
|
613
|
+
Linux: sudo apt install sox
|
|
614
|
+
`)
|
|
615
|
+
.action(async (opts) => {
|
|
616
|
+
if (!getApiKey()) {
|
|
617
|
+
console.error(chalk.red("\nNo API key configured.\n\n" +
|
|
618
|
+
" Run: cv login (browser login)\n" +
|
|
619
|
+
" cv auth set-key <key> (API key)\n"));
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
// ── Role prompt ──────────────────────────────────────────────────
|
|
623
|
+
let role = opts.role?.trim();
|
|
624
|
+
if (!role) {
|
|
625
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
626
|
+
role = await new Promise(resolve => {
|
|
627
|
+
rl.question(chalk.bold("\n What role are you interviewing for?\n ❯ "), answer => {
|
|
628
|
+
rl.close();
|
|
629
|
+
resolve(answer.trim());
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (!role) {
|
|
634
|
+
console.error(chalk.red(" Role is required."));
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
const numQuestions = Math.min(Math.max(parseInt(opts.questions, 10) || 5, 1), 12);
|
|
638
|
+
// ── Optional resume context ──────────────────────────────────────
|
|
639
|
+
let resumeContext;
|
|
640
|
+
if (opts.resume) {
|
|
641
|
+
const spinner = ora(chalk.dim("Loading resume...")).start();
|
|
642
|
+
try {
|
|
643
|
+
const result = await resumeGet(opts.resume);
|
|
644
|
+
if (isApiError(result)) {
|
|
645
|
+
spinner.warn(chalk.yellow(`Could not load resume: ${result.message}. Continuing without it.`));
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
resumeContext = result.cvMarkdown;
|
|
649
|
+
spinner.succeed(chalk.dim(`Resume loaded: ${result.title}`));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
spinner.warn(chalk.yellow("Could not load resume. Continuing without it."));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// ── Generate questions ───────────────────────────────────────────
|
|
657
|
+
let questions;
|
|
658
|
+
try {
|
|
659
|
+
questions = await generateQuestions(role, numQuestions);
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
console.error(chalk.red(`\n Failed to generate questions: ${err.message}\n`));
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
// ── Determine mode ───────────────────────────────────────────────
|
|
666
|
+
if (opts.text) {
|
|
667
|
+
await runTextSession({ role, questions, resumeContext });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Probe for sox
|
|
671
|
+
const soxPath = await findSox();
|
|
672
|
+
if (!soxPath) {
|
|
673
|
+
console.log(chalk.yellow("\n ⚠ sox not found — falling back to text mode.\n" +
|
|
674
|
+
"\n To enable voice, install sox:\n" +
|
|
675
|
+
" macOS: brew install sox\n" +
|
|
676
|
+
" Linux: sudo apt install sox\n" +
|
|
677
|
+
"\n Or run in text mode: cv interview --text\n"));
|
|
678
|
+
await runTextSession({ role, questions, resumeContext });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
// Voice mode
|
|
682
|
+
try {
|
|
683
|
+
await runVoiceSession({ role, questions, resumeContext, soxPath });
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
console.error(chalk.red(`\n Interview error: ${err.message}\n`));
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"referral.d.ts","sourceRoot":"","sources":["../../src/commands/referral.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,
|
|
1
|
+
{"version":3,"file":"referral.d.ts","sourceRoot":"","sources":["../../src/commands/referral.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAsGvD"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import boxen from "boxen";
|
|
1
2
|
import chalk from "chalk";
|
|
2
3
|
import ora from "ora";
|
|
3
4
|
import { getReferralStats, isApiError } from "../api.js";
|
|
@@ -8,75 +9,84 @@ function getProgressBar(current, total, width = 30) {
|
|
|
8
9
|
const emptyLength = width - filledLength;
|
|
9
10
|
const filledBar = '█'.repeat(filledLength);
|
|
10
11
|
const emptyBar = '░'.repeat(emptyLength);
|
|
11
|
-
return `[${chalk.
|
|
12
|
+
return `[${chalk.cyan(filledBar)}${chalk.dim(emptyBar)}]`;
|
|
12
13
|
}
|
|
13
14
|
export function registerReferralCommand(program) {
|
|
14
15
|
const referralCmd = program
|
|
15
16
|
.command("referral")
|
|
16
17
|
.description("Manage your CareerVivid referral code, track progress, and view rewards")
|
|
17
|
-
.option("--code", "
|
|
18
|
-
.option("--link", "
|
|
19
|
-
.option("--draft-message", "
|
|
20
|
-
.option("--status", "
|
|
21
|
-
.option("--list", "
|
|
18
|
+
.option("-c, --code", "Retrieve and display your unique referral code")
|
|
19
|
+
.option("-l, --link", "Generate and display your full shareable referral URL")
|
|
20
|
+
.option("-d, --draft-message", "Generate a highly-converting draft message for your network")
|
|
21
|
+
.option("-s, --status", "Display current state of active referrals (clicks, signups, progress)")
|
|
22
|
+
.option("-i, --list", "Output a detailed list of your historical referral data")
|
|
22
23
|
.option("--json", "Output raw JSON data")
|
|
23
24
|
.action(async (opts) => {
|
|
24
25
|
const isJson = opts.json ?? process.argv.includes("--json");
|
|
25
|
-
const spinner = ora(
|
|
26
|
+
const spinner = ora({
|
|
27
|
+
text: chalk.dim("Fetching referral ecosystem data..."),
|
|
28
|
+
color: "cyan"
|
|
29
|
+
}).start();
|
|
26
30
|
const result = await getReferralStats();
|
|
27
31
|
if (isApiError(result)) {
|
|
28
|
-
spinner.fail("Failed to
|
|
32
|
+
spinner.fail(chalk.red("Failed to sync referral data."));
|
|
29
33
|
printError(result.message, undefined, isJson);
|
|
30
34
|
process.exit(1);
|
|
31
35
|
}
|
|
32
|
-
spinner.
|
|
33
|
-
const baseUrl = "https://careervivid.app/
|
|
36
|
+
spinner.succeed(chalk.dim("Referral data synchronized successfully."));
|
|
37
|
+
const baseUrl = "https://careervivid.app/referral?ref=";
|
|
34
38
|
const fullLink = `${baseUrl}${result.code}`;
|
|
35
39
|
if (isJson) {
|
|
36
|
-
console.log(JSON.stringify({ ...result, fullLink }));
|
|
40
|
+
console.log(JSON.stringify({ ...result, fullLink }, null, 2));
|
|
37
41
|
return;
|
|
38
42
|
}
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
// If no specific flag is provided, show the summary dashboard
|
|
44
|
+
const showSummary = !opts.code && !opts.link && !opts.draftMessage && !opts.status && !opts.list;
|
|
45
|
+
console.log(""); // Spacer
|
|
46
|
+
// ── Flag: --code ──────────────────────────────────────────────────
|
|
47
|
+
if (opts.code) {
|
|
48
|
+
console.log(boxen(`${chalk.cyan.bold("REFERRAL CODE")}\n\n${chalk.white.bold.bgCyan(" " + result.code + " ")}`, { padding: 1, margin: 0, borderStyle: "round", borderColor: "cyan" }));
|
|
43
49
|
}
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// ── Flag: --link ──────────────────────────────────────────────────
|
|
51
|
+
if (opts.link) {
|
|
52
|
+
console.log(boxen(`${chalk.cyan.bold("SHAREABLE LINK")}\n\n${chalk.underline.blue(fullLink)}`, { padding: 1, margin: 0, borderStyle: "round", borderColor: "blue" }));
|
|
46
53
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
console.log(
|
|
51
|
-
console.log();
|
|
52
|
-
console.log(` Use my exclusive code ${chalk.bold(result.code)} to get 2 Months of Premium for free.`);
|
|
53
|
-
console.log();
|
|
54
|
-
console.log(` Sign up here: ${fullLink}`);
|
|
55
|
-
console.log(chalk.dim(` --------------------------------------------------`));
|
|
54
|
+
// ── Flag: --draft-message ─────────────────────────────────────────
|
|
55
|
+
if (opts.draftMessage) {
|
|
56
|
+
const message = `Accelerate Your Career Path with CareerVivid! 🚀\n\nI've been using CareerVivid to automate my job tracker and resume building. It's a game-changer.\n\nUse my exclusive code ${result.code} to get 2 MONTHS of PRO for FREE.\n\nClaim it here: ${fullLink}`;
|
|
57
|
+
console.log(boxen(`${chalk.magenta.bold("HIGH-CONVERTING DRAFT")}\n\n${chalk.white(message)}`, { padding: 1, margin: 0, borderStyle: "round", borderColor: "magenta", title: "Copy & Paste" }));
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
// ── Flag: --status ───────────────────────────────────────────────
|
|
60
|
+
if (opts.status || showSummary) {
|
|
61
|
+
const remaining = Math.max(0, result.maxReferrals - result.totalReferred);
|
|
62
|
+
const statusContent = [
|
|
63
|
+
`${chalk.yellow.bold("REDEMPTION PROGRESS")}`,
|
|
64
|
+
`${getProgressBar(result.totalReferred, result.maxReferrals)} ${chalk.bold(`${result.totalReferred}/${result.maxReferrals}`)}`,
|
|
65
|
+
`${chalk.dim(`${remaining} referrals remaining to maximize rewards`)}`,
|
|
66
|
+
"",
|
|
67
|
+
`${chalk.cyan.bold("ACTIVE REWARDS")}`,
|
|
68
|
+
`${chalk.green("✔")} ${chalk.white("THEY GET:")} 2 Months Pro + 1000 AI Credits`,
|
|
69
|
+
`${chalk.green("✔")} ${chalk.white("YOU GET:")} 1 Month Pro extension per signup`
|
|
70
|
+
].join("\n");
|
|
71
|
+
console.log(boxen(statusContent, { padding: 1, borderStyle: "double", borderColor: "yellow" }));
|
|
65
72
|
}
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
// ── Flag: --list ──────────────────────────────────────────────────
|
|
74
|
+
if (opts.list) {
|
|
75
|
+
const listTitle = chalk.blue.bold("REFERRAL HISTORY");
|
|
68
76
|
if (result.referredUsers.length === 0) {
|
|
69
|
-
console.log(
|
|
77
|
+
console.log(boxen(`${listTitle}\n\n${chalk.dim("No successful referrals recorded yet.\nStart sharing to earn Pro extensions!")}`, { padding: 1, borderStyle: "round", borderColor: "blue" }));
|
|
70
78
|
}
|
|
71
79
|
else {
|
|
72
|
-
result.referredUsers.
|
|
73
|
-
const date = u.signupDate
|
|
74
|
-
|
|
75
|
-
: "Recently";
|
|
76
|
-
console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.white(u.email)} ${chalk.dim(`(Joined: ${date})`)} ${chalk.green('✓ active')}`);
|
|
80
|
+
const rows = result.referredUsers.map((u, i) => {
|
|
81
|
+
const date = u.signupDate ? new Date(u.signupDate).toLocaleDateString() : "Recently";
|
|
82
|
+
return `${chalk.dim(`${i + 1}.`)} ${chalk.white(u.email.padEnd(25))} ${chalk.dim(`[${date}]`)} ${chalk.green("● active")}`;
|
|
77
83
|
});
|
|
84
|
+
console.log(boxen(`${listTitle}\n\n${rows.join("\n")}`, { padding: 1, borderStyle: "round", borderColor: "blue" }));
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
|
-
|
|
87
|
+
if (showSummary) {
|
|
88
|
+
console.log(chalk.dim(` Use ${chalk.cyan('cv referral --help')} to see all available flags.`));
|
|
89
|
+
}
|
|
90
|
+
console.log(""); // Final spacer
|
|
81
91
|
});
|
|
82
92
|
}
|
package/dist/index.js
CHANGED
|
@@ -53,12 +53,13 @@ import { registerJobsCommand } from "./commands/jobs.js";
|
|
|
53
53
|
import { registerResumesCommand } from "./commands/resumes.js";
|
|
54
54
|
import { registerReferralCommand } from "./commands/referral.js";
|
|
55
55
|
import { registerEvalCommand } from "./commands/eval.js";
|
|
56
|
+
import { registerInterviewCommand } from "./commands/interview.js";
|
|
56
57
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
57
58
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
58
59
|
const program = new Command();
|
|
59
60
|
program
|
|
60
61
|
.name("cv")
|
|
61
|
-
.description("CareerVivid CLI —
|
|
62
|
+
.description("CareerVivid CLI — AI-powered career management: job hunting, resume building, referrals, and portfolio publishing.")
|
|
62
63
|
.version(pkg.version, "-v, --version", "Print CLI version")
|
|
63
64
|
.addHelpText("before", getHelpHeader())
|
|
64
65
|
.helpOption("-h, --help", "Show help");
|
|
@@ -77,6 +78,7 @@ registerJobsCommand(program);
|
|
|
77
78
|
registerResumesCommand(program);
|
|
78
79
|
registerReferralCommand(program);
|
|
79
80
|
registerEvalCommand(program);
|
|
81
|
+
registerInterviewCommand(program);
|
|
80
82
|
// Shortcuts for whiteboard creation
|
|
81
83
|
registerNewCommand(program);
|
|
82
84
|
registerListTemplatesCommand(program);
|