careervivid 2.1.36 → 2.1.39

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.
Files changed (36) hide show
  1. package/dist/agent/Tool.d.ts +1 -1
  2. package/dist/agent/Tool.d.ts.map +1 -1
  3. package/dist/agent/tools/interview.d.ts.map +1 -1
  4. package/dist/agent/tools/interview.js +5 -2
  5. package/dist/agent/tools/urlVerifier.d.ts +1 -0
  6. package/dist/agent/tools/urlVerifier.d.ts.map +1 -1
  7. package/dist/agent/tools/urlVerifier.js +294 -86
  8. package/dist/api.d.ts +7 -0
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +4 -0
  11. package/dist/apply/browser.d.ts +5 -0
  12. package/dist/apply/browser.d.ts.map +1 -1
  13. package/dist/apply/browser.js +1 -1
  14. package/dist/commands/agent/configurator.d.ts.map +1 -1
  15. package/dist/commands/agent/configurator.js +14 -0
  16. package/dist/commands/agent/index.d.ts.map +1 -1
  17. package/dist/commands/agent/index.js +3 -0
  18. package/dist/commands/agent/repl/engineLoop.d.ts.map +1 -1
  19. package/dist/commands/agent/repl/engineLoop.js +121 -116
  20. package/dist/commands/agent/repl/input.d.ts +2 -7
  21. package/dist/commands/agent/repl/input.d.ts.map +1 -1
  22. package/dist/commands/agent/repl/input.js +226 -17
  23. package/dist/commands/agent/repl.d.ts +3 -0
  24. package/dist/commands/agent/repl.d.ts.map +1 -1
  25. package/dist/commands/agent/repl.js +28 -1
  26. package/dist/commands/apply.d.ts.map +1 -1
  27. package/dist/commands/apply.js +24 -5
  28. package/dist/commands/auth.d.ts.map +1 -1
  29. package/dist/commands/auth.js +5 -4
  30. package/dist/commands/interview.d.ts.map +1 -1
  31. package/dist/commands/interview.js +101 -31
  32. package/dist/commands/jobs.d.ts.map +1 -1
  33. package/dist/commands/jobs.js +147 -4
  34. package/dist/commands/resumes.js +6 -6
  35. package/dist/index.js +4 -2
  36. package/package.json +1 -1
@@ -28,6 +28,7 @@ import { detectPlatform, getAdapter } from "../apply/index.js";
28
28
  import { loadProfile, saveProfile } from "../apply/gemini-agent.js";
29
29
  import { resolveResumePdf } from "../apply/resume-pdf.js";
30
30
  import { getOrGenerateAdapter, loadGeneratedAdapter } from "../apply/adapter-generator.js";
31
+ import { verifyUrl } from "../agent/tools/urlVerifier.js";
31
32
  const { prompt } = pkg;
32
33
  // ── Config helpers ────────────────────────────────────────────────────────────
33
34
  function resolveModel(modelFlag) {
@@ -266,9 +267,27 @@ export function registerApplyCommand(program) {
266
267
  console.log(chalk.dim(" Example: cv jobs apply https://boards.greenhouse.io/stripe/jobs/7788088"));
267
268
  process.exit(1);
268
269
  }
269
- // ── 3. Resolve resume PDF ────────────────────────────────────────────
270
+ // ── 3. Verify direct apply URL before any browser automation ──────────
271
+ console.log(chalk.dim("\n Verifying apply link..."));
272
+ const verification = await verifyUrl(resolvedUrl);
273
+ if (!verification.ok) {
274
+ console.error(chalk.red(`\n❌ Apply link rejected: ${verification.reason}`));
275
+ if (verification.finalUrl && verification.finalUrl !== resolvedUrl) {
276
+ console.error(chalk.dim(` Final URL: ${verification.finalUrl}`));
277
+ }
278
+ console.error(chalk.dim(" Search for a current direct apply page before running the harness.\n"));
279
+ process.exit(1);
280
+ }
281
+ if (verification.warning) {
282
+ console.log(chalk.yellow(` ⚠️ ${verification.warning}`));
283
+ }
284
+ if (verification.finalUrl && verification.finalUrl !== resolvedUrl) {
285
+ console.log(chalk.dim(` Redirect resolved: ${verification.finalUrl}`));
286
+ resolvedUrl = verification.finalUrl;
287
+ }
288
+ // ── 4. Resolve resume PDF ────────────────────────────────────────────
270
289
  const resumePdf = await resolveResumePdf();
271
- // ── 4. Detect platform ───────────────────────────────────────────────
290
+ // ── 5. Detect platform ───────────────────────────────────────────────
272
291
  const platform = detectPlatform(resolvedUrl);
273
292
  const platformLabel = {
274
293
  greenhouse: "Greenhouse 🌱", lever: "Lever", ashby: "Ashby",
@@ -279,7 +298,7 @@ export function registerApplyCommand(program) {
279
298
  if (platform === "linkedin") {
280
299
  console.log(chalk.yellow("\n⚠️ LinkedIn requires manual apply — opening browser-use agent.\n"));
281
300
  }
282
- // ── 5. Dry run ───────────────────────────────────────────────────────
301
+ // ── 6. Dry run ───────────────────────────────────────────────────────
283
302
  if (options.dryRun) {
284
303
  console.log(chalk.yellow("\n🔍 DRY RUN — nothing will open or be filled.\n"));
285
304
  const profileKeys = Object.keys(profile).filter((k) => profile[k]);
@@ -290,13 +309,13 @@ export function registerApplyCommand(program) {
290
309
  console.log(chalk.dim(" Re-run without --dry-run to execute.\n"));
291
310
  process.exit(0);
292
311
  }
293
- // ── 6. Verification ──────────────────────────────────────────────────
312
+ // ── 7. Verification ──────────────────────────────────────────────────
294
313
  if (!llmConfig.apiKey && llmConfig.provider !== "careervivid") {
295
314
  console.log(chalk.red(`\n❌ No API key found for provider: ${llmConfig.provider}`));
296
315
  console.log(chalk.dim(" Set it via: cv agent config\n"));
297
316
  process.exit(1);
298
317
  }
299
- // ── 7. HARNESS ROUTING ───────────────────────────────────────────────
318
+ // ── 8. HARNESS ROUTING ───────────────────────────────────────────────
300
319
  //
301
320
  // Route A: Known platform → TypeScript adapter (fast, no LLM)
302
321
  // Route B: Unknown platform → AI-generate new adapter
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqM1D"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAsM1D"}
@@ -57,7 +57,7 @@ export function registerAuthCommand(program) {
57
57
  spinner?.stop();
58
58
  if (isApiError(result)) {
59
59
  if (jsonMode) {
60
- console.log(JSON.stringify({ success: false, error: result.message }));
60
+ console.log(JSON.stringify({ success: false, ok: false, error: result.message }));
61
61
  }
62
62
  else {
63
63
  console.error(`\n ${chalk.red("✖")} ${chalk.bold.red("Invalid key:")} ${result.message}`);
@@ -81,6 +81,7 @@ export function registerAuthCommand(program) {
81
81
  else {
82
82
  console.log(JSON.stringify({
83
83
  success: true,
84
+ ok: true,
84
85
  name: result.name,
85
86
  email: result.email,
86
87
  role: result.role,
@@ -110,7 +111,7 @@ export function registerAuthCommand(program) {
110
111
  `\n ${chalk.dim("Your key may have been revoked. Run")} ${chalk.cyan("cv login")} ${chalk.dim("to re-authenticate.\n")}`);
111
112
  }
112
113
  else {
113
- console.log(JSON.stringify({ success: false, error: result.message }));
114
+ console.log(JSON.stringify({ success: false, ok: false, error: result.message }));
114
115
  }
115
116
  process.exit(1);
116
117
  }
@@ -127,7 +128,7 @@ export function registerAuthCommand(program) {
127
128
  }));
128
129
  }
129
130
  else {
130
- console.log(JSON.stringify({ success: true, ...result }));
131
+ console.log(JSON.stringify({ success: true, ok: true, ...result }));
131
132
  }
132
133
  });
133
134
  // ── whoami ─────────────────────────────────────────────────────────────────
@@ -179,7 +180,7 @@ export function registerAuthCommand(program) {
179
180
  const { saveConfig } = await import("../config.js");
180
181
  saveConfig(config);
181
182
  if (jsonMode) {
182
- console.log(JSON.stringify({ success: true }));
183
+ console.log(JSON.stringify({ success: true, ok: true }));
183
184
  }
184
185
  else {
185
186
  console.log();
@@ -1 +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;AAi1BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmG/D"}
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;AAg6BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoG/D"}
@@ -51,7 +51,7 @@ const CLI_BILL_URL = process.env.CV_FUNCTIONS_URL
51
51
  const CLI_CONTEXT_URL = process.env.CV_FUNCTIONS_URL
52
52
  ? `${process.env.CV_FUNCTIONS_URL}/cliGetInterviewContext`
53
53
  : "https://us-west1-jastalk-firebase.cloudfunctions.net/cliGetInterviewContext";
54
- const LIVE_MODEL = "gemini-3.1-flash-live-preview";
54
+ const LIVE_MODEL = "gemini-live-2.5-flash-native-audio";
55
55
  const FEEDBACK_MODEL = "gemini-2.5-flash";
56
56
  const END_TOKEN = "<END_INTERVIEW>";
57
57
  const WRAP_WIDTH = 80;
@@ -252,9 +252,14 @@ async function callAgentProxy(opts) {
252
252
  const apiKey = getApiKey();
253
253
  if (!apiKey)
254
254
  throw new Error("No API key. Run: cv login");
255
+ // agentProxy backend only supports Gemini models (e.g., gemini-2.5-flash, gemini-3.5-flash)
256
+ // If a BYO model is passed that is not Gemini (e.g., openai/gpt-oss-120b:free), we fall back to FEEDBACK_MODEL.
257
+ const resolvedModel = opts.model && opts.model.toLowerCase().includes("gemini")
258
+ ? opts.model
259
+ : FEEDBACK_MODEL;
255
260
  const body = {
256
261
  apiKey,
257
- model: FEEDBACK_MODEL,
262
+ model: resolvedModel,
258
263
  contents: opts.contents,
259
264
  };
260
265
  if (opts.systemInstruction)
@@ -306,7 +311,7 @@ ${questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}
306
311
  return prompt;
307
312
  }
308
313
  // ─── Generate Questions (via agentProxy) ─────────────────────────────────────
309
- async function generateQuestions(role, numQuestions) {
314
+ async function generateQuestions(role, numQuestions, model) {
310
315
  const spinner = ora(chalk.dim("Generating interview questions...")).start();
311
316
  try {
312
317
  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}"`;
@@ -314,6 +319,7 @@ async function generateQuestions(role, numQuestions) {
314
319
  contents: [{ role: "user", parts: [{ text: prompt }] }],
315
320
  responseSchema: { type: "ARRAY", items: { type: "STRING" } },
316
321
  responseMimeType: "application/json",
322
+ model,
317
323
  });
318
324
  let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
319
325
  const questions = JSON.parse(clean);
@@ -326,7 +332,7 @@ async function generateQuestions(role, numQuestions) {
326
332
  }
327
333
  }
328
334
  // ─── Analyze Transcript (via agentProxy) ─────────────────────────────────────
329
- async function analyzeTranscript(transcript, role) {
335
+ async function analyzeTranscript(transcript, role, model) {
330
336
  const spinner = ora(chalk.dim("Generating feedback report...")).start();
331
337
  try {
332
338
  const formatted = transcript
@@ -347,6 +353,7 @@ async function analyzeTranscript(transcript, role) {
347
353
  },
348
354
  required: ["overallScore", "communicationScore", "confidenceScore", "relevanceScore", "strengths", "areasForImprovement"],
349
355
  },
356
+ model,
350
357
  });
351
358
  let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
352
359
  const report = JSON.parse(clean);
@@ -360,7 +367,7 @@ async function analyzeTranscript(transcript, role) {
360
367
  }
361
368
  // ─── VOICE SESSION ────────────────────────────────────────────────────────────
362
369
  async function runVoiceSession(opts) {
363
- const { role, questions, resumeContext, soxPath } = opts;
370
+ const { role, questions, resumeContext, soxPath, model } = opts;
364
371
  printBanner(role, "voice");
365
372
  // Create logger (sessionId not yet known — will be set after token vend)
366
373
  const log = createLogger("interview", {
@@ -410,6 +417,10 @@ async function runVoiceSession(opts) {
410
417
  // (outputBuf never contains END_TOKEN because chunkClean strips it, so the
411
418
  // turnComplete check on outputBuf would never fire without this flag.)
412
419
  let endTokenSeen = false;
420
+ // Track audio bytes sent to speaker this turn to calculate mute duration
421
+ // 24kHz, 16-bit mono = 48000 bytes/second of playback
422
+ const SPEAKER_BYTES_PER_SEC = RECV_SAMPLE_RATE * 2; // 48000
423
+ let pendingAudioBytes = 0;
413
424
  // ── Audio processes ──────────────────────────────────────────────────
414
425
  const micProc = startMic(soxPath);
415
426
  const speakerProc = startSpeaker(soxPath);
@@ -454,6 +465,7 @@ async function runVoiceSession(opts) {
454
465
  muteTimer = null;
455
466
  }
456
467
  const pcmBuf = Buffer.from(audioPart, "base64");
468
+ pendingAudioBytes += pcmBuf.length;
457
469
  speakerProc.stdin.write(pcmBuf);
458
470
  }
459
471
  // ── Output transcription (Vivid's words) — stream in real-time ──
@@ -535,15 +547,22 @@ async function runVoiceSession(opts) {
535
547
  inputBuf = "";
536
548
  }
537
549
  if (!ended) {
550
+ // Calculate exactly how long the buffered audio will take to play.
551
+ // Show "Listening..." and unmute the mic together when playback ends.
552
+ const playbackMs = Math.ceil((pendingAudioBytes / SPEAKER_BYTES_PER_SEC) * 1000);
553
+ const muteMs = Math.max(playbackMs, 800); // minimum 800ms floor
554
+ pendingAudioBytes = 0;
538
555
  muteTimer = setTimeout(() => {
539
556
  vividSpeaking = false;
540
557
  muteTimer = null;
558
+ // Show "Listening..." right as audio finishes playing
541
559
  if (!userSpeechLineActive) {
542
560
  process.stdout.write(chalk.green("\n ● Listening...\r"));
543
561
  }
544
- }, 800);
562
+ }, muteMs);
545
563
  }
546
564
  else {
565
+ pendingAudioBytes = 0;
547
566
  // Interview ended via END_TOKEN — cancel pending mute timer
548
567
  // so '● Listening...' never appears after the interview concludes
549
568
  if (muteTimer) {
@@ -554,10 +573,19 @@ async function runVoiceSession(opts) {
554
573
  }
555
574
  },
556
575
  onerror: (e) => {
557
- console.log(chalk.red(`\n Connection error: ${e.message ?? e}`));
576
+ const msg = e?.message ?? JSON.stringify(e) ?? "Unknown error";
577
+ console.log(chalk.red(`\n ❌ Live API connection error: ${msg}`));
578
+ console.log(chalk.dim(` Model: ${LIVE_MODEL} | Location: ${location}`));
579
+ ended = true;
580
+ },
581
+ onclose: (e) => {
582
+ if (!ended) {
583
+ const reason = e?.reason ?? e?.code ?? "unexpected close";
584
+ console.log(chalk.yellow(`\n ⚠️ Live API session closed unexpectedly: ${reason}`));
585
+ console.log(chalk.dim(` Model: ${LIVE_MODEL} | Location: ${location}`));
586
+ }
558
587
  ended = true;
559
588
  },
560
- onclose: () => { ended = true; },
561
589
  },
562
590
  config: {
563
591
  responseModalities: [Modality.AUDIO],
@@ -641,7 +669,7 @@ async function runVoiceSession(opts) {
641
669
  console.log(chalk.dim("\n Generating your personalized feedback report..."));
642
670
  let report = null;
643
671
  try {
644
- report = await analyzeTranscript(transcript, role);
672
+ report = await analyzeTranscript(transcript, role, model);
645
673
  printReport(report);
646
674
  log.info("feedback_complete", { sessionId, overallScore: report.overallScore });
647
675
  }
@@ -680,42 +708,79 @@ async function runVoiceSession(opts) {
680
708
  }
681
709
  // ─── TEXT SESSION (fallback) ──────────────────────────────────────────────────
682
710
  async function runTextSession(opts) {
683
- const { role, questions, resumeContext } = opts;
711
+ const { role, questions, resumeContext, model } = opts;
684
712
  const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
685
713
  printBanner(role, "text");
686
714
  const history = [];
687
715
  const transcript = [];
688
716
  let ended = false;
689
717
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
718
+ let sigintReceived = false;
719
+ let resolvePending = null;
720
+ const sigintHandler = () => {
721
+ if (sigintReceived)
722
+ return;
723
+ sigintReceived = true;
724
+ printSystem("Interview ended by user.");
725
+ rl.close();
726
+ if (resolvePending) {
727
+ resolvePending(null);
728
+ }
729
+ };
730
+ process.on("SIGINT", sigintHandler);
731
+ rl.on("SIGINT", sigintHandler);
690
732
  const askUser = () => new Promise(resolve => {
733
+ resolvePending = resolve;
691
734
  if (process.stdin.isTTY)
692
735
  process.stdout.write(chalk.white.bold("\n you ❯ "));
693
- rl.once("line", line => resolve(line.trim()));
694
- rl.once("close", () => resolve(null));
736
+ rl.once("line", line => {
737
+ resolvePending = null;
738
+ resolve(line.trim());
739
+ });
740
+ rl.once("close", () => {
741
+ resolvePending = null;
742
+ resolve(null);
743
+ });
695
744
  });
745
+ const runAgentCall = async (contents) => {
746
+ return new Promise((resolve, reject) => {
747
+ resolvePending = (val) => resolve(null);
748
+ callAgentProxy({ contents, systemInstruction, model }).then(res => {
749
+ resolvePending = null;
750
+ resolve(res);
751
+ }, err => {
752
+ resolvePending = null;
753
+ reject(err);
754
+ });
755
+ });
756
+ };
696
757
  const spinner = ora(chalk.dim("Vivid is connecting...")).start();
697
758
  try {
698
- const greeting = await callAgentProxy({
699
- contents: [{ role: "user", parts: [{ text: "Hello" }] }],
700
- systemInstruction,
701
- });
759
+ const greeting = await runAgentCall([{ role: "user", parts: [{ text: "Hello" }] }]);
702
760
  spinner.stop();
703
- history.push({ role: "user", parts: [{ text: "Hello" }] });
704
- history.push({ role: "model", parts: [{ text: greeting }] });
705
- transcript.push({ speaker: "user", text: "Hello" });
706
- transcript.push({ speaker: "ai", text: greeting });
707
- if (greeting.includes(END_TOKEN))
761
+ if (sigintReceived || greeting === null) {
708
762
  ended = true;
709
- printAI(greeting);
763
+ }
764
+ else {
765
+ history.push({ role: "user", parts: [{ text: "Hello" }] });
766
+ history.push({ role: "model", parts: [{ text: greeting }] });
767
+ transcript.push({ speaker: "user", text: "Hello" });
768
+ transcript.push({ speaker: "ai", text: greeting });
769
+ if (greeting.includes(END_TOKEN))
770
+ ended = true;
771
+ printAI(greeting);
772
+ }
710
773
  }
711
774
  catch (err) {
712
775
  spinner.fail(chalk.red("Failed to connect to AI interviewer."));
713
776
  throw err;
714
777
  }
715
- while (!ended) {
778
+ while (!ended && !sigintReceived) {
716
779
  const input = await askUser();
717
- if (input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
718
- printSystem("Interview ended early.");
780
+ if (sigintReceived || input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
781
+ if (!sigintReceived) {
782
+ printSystem("Interview ended early.");
783
+ }
719
784
  break;
720
785
  }
721
786
  if (input === "")
@@ -724,8 +789,11 @@ async function runTextSession(opts) {
724
789
  transcript.push({ speaker: "user", text: input });
725
790
  const aiSpinner = ora({ text: "" }).start();
726
791
  try {
727
- const aiResponse = await callAgentProxy({ contents: history, systemInstruction });
792
+ const aiResponse = await runAgentCall(history);
728
793
  aiSpinner.stop();
794
+ if (sigintReceived || aiResponse === null) {
795
+ break;
796
+ }
729
797
  history.push({ role: "model", parts: [{ text: aiResponse }] });
730
798
  transcript.push({ speaker: "ai", text: aiResponse.replace(END_TOKEN, "").trim() });
731
799
  if (aiResponse.includes(END_TOKEN))
@@ -737,6 +805,7 @@ async function runTextSession(opts) {
737
805
  console.log(chalk.red(`\n Error: ${err.message}\n`));
738
806
  }
739
807
  }
808
+ process.off("SIGINT", sigintHandler);
740
809
  rl.close();
741
810
  const userTurns = transcript.filter(t => t.speaker === "user").length;
742
811
  if (userTurns < 2) {
@@ -746,7 +815,7 @@ async function runTextSession(opts) {
746
815
  console.log(chalk.dim("\n Generating your personalized feedback report..."));
747
816
  let textReport = null;
748
817
  try {
749
- textReport = await analyzeTranscript(transcript, role);
818
+ textReport = await analyzeTranscript(transcript, role, model);
750
819
  printReport(textReport);
751
820
  }
752
821
  catch (err) {
@@ -781,6 +850,7 @@ export function registerInterviewCommand(program) {
781
850
  .option("-q, --questions <n>", "Number of interview questions to generate", "5")
782
851
  .option("--resume <id>", "Load a specific resume ID for context (from cv resumes list)")
783
852
  .option("--text", "Use text-only mode (no audio required)")
853
+ .option("--model <model>", "Specify a custom model to use for the interview session")
784
854
  .addHelpText("after", `
785
855
  Examples:
786
856
  cv interview
@@ -837,7 +907,7 @@ Voice mode setup (one-time):
837
907
  // ── Generate questions ───────────────────────────────────────────
838
908
  let questions;
839
909
  try {
840
- questions = await generateQuestions(role, numQuestions);
910
+ questions = await generateQuestions(role, numQuestions, opts.model);
841
911
  }
842
912
  catch (err) {
843
913
  console.error(chalk.red(`\n Failed to generate questions: ${err.message}\n`));
@@ -845,7 +915,7 @@ Voice mode setup (one-time):
845
915
  }
846
916
  // ── Determine mode ───────────────────────────────────────────────
847
917
  if (opts.text) {
848
- await runTextSession({ role, questions, resumeContext });
918
+ await runTextSession({ role, questions, resumeContext, model: opts.model });
849
919
  return;
850
920
  }
851
921
  // Probe for sox
@@ -856,12 +926,12 @@ Voice mode setup (one-time):
856
926
  " macOS: brew install sox\n" +
857
927
  " Linux: sudo apt install sox\n" +
858
928
  "\n Or run in text mode: cv interview --text\n"));
859
- await runTextSession({ role, questions, resumeContext });
929
+ await runTextSession({ role, questions, resumeContext, model: opts.model });
860
930
  return;
861
931
  }
862
932
  // Voice mode
863
933
  try {
864
- await runVoiceSession({ role, questions, resumeContext, soxPath });
934
+ await runVoiceSession({ role, questions, resumeContext, soxPath, model: opts.model });
865
935
  }
866
936
  catch (err) {
867
937
  console.error(chalk.red(`\n Interview error: ${err.message}\n`));
@@ -1 +1 @@
1
- {"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../src/commands/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkFpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,QAgjBnD"}
1
+ {"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../src/commands/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmFpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,QAmtBnD"}
@@ -11,7 +11,7 @@ import { resolve } from "path";
11
11
  import chalk from "chalk";
12
12
  import ora from "ora";
13
13
  import boxen from "boxen";
14
- import { isApiError, resumeGet, jobsHunt, jobsCreate, jobsUpdate, jobsList, resumesList, generateCoverLetter, listCoverLetters, } from "../api.js";
14
+ import { isApiError, resumeGet, jobsHunt, jobsCreate, jobsUpdate, jobsList, jobsDelete, resumesList, generateCoverLetter, listCoverLetters, } from "../api.js";
15
15
  import { checkGwsReady, runGwsCommand } from "../utils/gws-runner.js";
16
16
  import { printError } from "../output.js";
17
17
  import { registerApplyCommand } from "./apply.js";
@@ -323,15 +323,15 @@ export function registerJobsCommand(program) {
323
323
  .option("--json", "Output raw JSON")
324
324
  .action(async (opts) => {
325
325
  const isJson = opts.json ?? process.argv.includes("--json");
326
- const spinner = ora("Fetching your job tracker…").start();
326
+ const spinner = isJson ? undefined : ora("Fetching your job tracker…").start();
327
327
  const result = await jobsList(opts.status);
328
328
  if (isApiError(result)) {
329
- spinner.fail("Failed to fetch tracker.");
329
+ spinner?.fail("Failed to fetch tracker.");
330
330
  printError(result.message, undefined, isJson);
331
331
  process.exit(1);
332
332
  }
333
333
  const { jobs, total } = result;
334
- spinner.succeed(`${total} job(s)${opts.status ? ` with status "${opts.status}"` : ""}`);
334
+ spinner?.succeed(`${total} job(s)${opts.status ? ` with status "${opts.status}"` : ""}`);
335
335
  if (isJson) {
336
336
  console.log(JSON.stringify(result));
337
337
  return;
@@ -354,6 +354,149 @@ export function registerJobsCommand(program) {
354
354
  console.log(`\n ${statusSummary}\n`);
355
355
  console.log(chalk.dim(` View on web: https://careervivid.app/job-tracker\n`));
356
356
  });
357
+ // ── cv jobs validate-links ───────────────────────────────────────────────
358
+ jobsCmd
359
+ .command("validate-links")
360
+ .description("Scan job tracker for duplicates, missing, generic, or broken links")
361
+ .option("--remove-stale", "Delete duplicates (keep oldest) and broken/missing/generic URL entries")
362
+ .option("--json", "Output raw JSON")
363
+ .action(async (opts) => {
364
+ const isJson = opts.json ?? process.argv.includes("--json");
365
+ const spinner = isJson ? undefined : ora("Fetching your job tracker…").start();
366
+ const listResult = await jobsList();
367
+ if (isApiError(listResult)) {
368
+ spinner?.fail("Failed to fetch tracker.");
369
+ printError(listResult.message, undefined, isJson);
370
+ process.exit(1);
371
+ }
372
+ const { jobs } = listResult;
373
+ spinner?.succeed(`Retrieved ${jobs.length} job(s) from tracker`);
374
+ if (jobs.length === 0) {
375
+ if (isJson) {
376
+ console.log(JSON.stringify({ jobs: [], duplicates: [], missing: [], generic: [], broken: [], removed: [], failed: [] }));
377
+ }
378
+ else {
379
+ console.log(chalk.yellow("\n Your tracker is empty.\n"));
380
+ }
381
+ return;
382
+ }
383
+ const duplicates = [];
384
+ const missing = [];
385
+ const generic = [];
386
+ const broken = [];
387
+ const normalizeString = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
388
+ const isFuzzyRoleMatch = (t1, t2) => {
389
+ const n1 = normalizeString(t1);
390
+ const n2 = normalizeString(t2);
391
+ if (n1 === n2)
392
+ return true;
393
+ if (n1.includes(n2) || n2.includes(n1))
394
+ return true;
395
+ // Fuzzy role check based on words of length >= 4
396
+ const w1 = t1.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
397
+ const w2 = t2.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
398
+ const intersection = w1.filter(w => w2.includes(w));
399
+ return intersection.length >= 2;
400
+ };
401
+ // Keep oldest entry: sort by updatedAt/createdAt asc (earliest first)
402
+ const sortedJobs = [...jobs].sort((a, b) => {
403
+ const t1 = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
404
+ const t2 = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
405
+ return t1 - t2;
406
+ });
407
+ const uniqueJobs = [];
408
+ for (const job of sortedJobs) {
409
+ const isDup = uniqueJobs.find(u => normalizeString(u.companyName) === normalizeString(job.companyName) &&
410
+ isFuzzyRoleMatch(u.jobTitle, job.jobTitle));
411
+ if (isDup) {
412
+ duplicates.push(job);
413
+ }
414
+ else {
415
+ uniqueJobs.push(job);
416
+ }
417
+ }
418
+ const { verifyUrl, isGenericJobLanding } = await import("../agent/tools/urlVerifier.js");
419
+ const checkSpinner = isJson ? undefined : ora("Verifying links…").start();
420
+ for (const job of uniqueJobs) {
421
+ const url = job.jobPostURL;
422
+ if (!url || url.trim() === "") {
423
+ missing.push(job);
424
+ continue;
425
+ }
426
+ if (isGenericJobLanding(url)) {
427
+ generic.push(job);
428
+ continue;
429
+ }
430
+ // Verify reachability
431
+ const ver = await verifyUrl(url);
432
+ if (!ver.ok) {
433
+ broken.push(job);
434
+ }
435
+ }
436
+ checkSpinner?.stop();
437
+ const toDelete = new Set();
438
+ duplicates.forEach(j => toDelete.add(j.id));
439
+ missing.forEach(j => toDelete.add(j.id));
440
+ generic.forEach(j => toDelete.add(j.id));
441
+ broken.forEach(j => toDelete.add(j.id));
442
+ const removed = [];
443
+ const failed = [];
444
+ if (opts.removeStale && toDelete.size > 0) {
445
+ const delSpinner = isJson ? undefined : ora(`Removing ${toDelete.size} stale/duplicate entries…`).start();
446
+ for (const id of toDelete) {
447
+ const res = await jobsDelete({ jobId: id });
448
+ if (isApiError(res)) {
449
+ failed.push(`${id}: ${res.message}`);
450
+ }
451
+ else {
452
+ removed.push(id);
453
+ }
454
+ }
455
+ delSpinner?.succeed(`Removed ${removed.length} entries (${failed.length} failed)`);
456
+ }
457
+ if (isJson) {
458
+ console.log(JSON.stringify({
459
+ totalFound: jobs.length,
460
+ duplicates: duplicates.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName })),
461
+ missing: missing.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName })),
462
+ generic: generic.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName, url: j.jobPostURL })),
463
+ broken: broken.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName, url: j.jobPostURL })),
464
+ removed,
465
+ failed
466
+ }));
467
+ return;
468
+ }
469
+ console.log(`\n${chalk.bold(" Job Tracker Audit Summary")}\n`);
470
+ if (duplicates.length > 0) {
471
+ console.log(chalk.yellow(` 👯 Duplicates (${duplicates.length}):`));
472
+ duplicates.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName} (ID: ${j.id})`)));
473
+ }
474
+ if (missing.length > 0) {
475
+ console.log(chalk.yellow(` 🔗 Missing Apply Links (${missing.length}):`));
476
+ missing.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName} (ID: ${j.id})`)));
477
+ }
478
+ if (generic.length > 0) {
479
+ console.log(chalk.yellow(` 🌐 Generic/Careers Landing URLs (${generic.length}):`));
480
+ generic.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName}: ${j.jobPostURL} (ID: ${j.id})`)));
481
+ }
482
+ if (broken.length > 0) {
483
+ console.log(chalk.yellow(` ❌ Broken/Expired URLs (${broken.length}):`));
484
+ broken.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName}: ${j.jobPostURL} (ID: ${j.id})`)));
485
+ }
486
+ const totalStale = toDelete.size;
487
+ if (totalStale === 0) {
488
+ console.log(chalk.green(" ✔ No stale or duplicate job listings found in your tracker!\n"));
489
+ }
490
+ else {
491
+ console.log(`\n Total stale entries found: ${chalk.bold.red(totalStale)}`);
492
+ if (opts.removeStale) {
493
+ console.log(chalk.green(` ✔ Cleaned ${removed.length} entries successfully.\n`));
494
+ }
495
+ else {
496
+ console.log(chalk.cyan(` 💡 Run with ${chalk.bold("--remove-stale")} to automatically delete these entries.\n`));
497
+ }
498
+ }
499
+ });
357
500
  // ── cv jobs sync-gmail (legacy) ───────────────────────────────────────────
358
501
  jobsCmd
359
502
  .command("sync-gmail")
@@ -59,15 +59,15 @@ export function registerResumesCommand(program) {
59
59
  .option("--json", "Output raw JSON")
60
60
  .action(async (opts) => {
61
61
  const isJson = opts.json ?? process.argv.includes("--json");
62
- const spinner = ora("Fetching resumes…").start();
62
+ const spinner = isJson ? undefined : ora("Fetching resumes…").start();
63
63
  const result = await resumesList();
64
64
  if (isApiError(result)) {
65
- spinner.fail("Failed to list resumes.");
65
+ spinner?.fail("Failed to list resumes.");
66
66
  printError(result.message, undefined, isJson);
67
67
  process.exit(1);
68
68
  }
69
69
  const { resumes, total } = result;
70
- spinner.succeed(`Found ${total} resume(s)`);
70
+ spinner?.succeed(`Found ${total} resume(s)`);
71
71
  if (isJson) {
72
72
  console.log(JSON.stringify(result));
73
73
  return;
@@ -90,14 +90,14 @@ export function registerResumesCommand(program) {
90
90
  .option("--json", "Output raw JSON")
91
91
  .action(async (id, opts) => {
92
92
  const isJson = opts.json ?? process.argv.includes("--json");
93
- const spinner = ora("Fetching resume…").start();
93
+ const spinner = isJson ? undefined : ora("Fetching resume…").start();
94
94
  const result = await resumeGet(id);
95
95
  if (isApiError(result)) {
96
- spinner.fail("Failed to get resume.");
96
+ spinner?.fail("Failed to get resume.");
97
97
  printError(result.message, undefined, isJson);
98
98
  process.exit(1);
99
99
  }
100
- spinner.succeed(`Resume loaded: ${result.title}`);
100
+ spinner?.succeed(`Resume loaded: ${result.title}`);
101
101
  if (isJson) {
102
102
  console.log(JSON.stringify(result));
103
103
  return;
package/dist/index.js CHANGED
@@ -98,10 +98,12 @@ async function main() {
98
98
  return;
99
99
  }
100
100
  // ── Session expiry check (firebase-cli style) ──────────────────────────────
101
- // Skip for auth/login/logout/config/help commands — they don’t need a live session
101
+ // Skip for auth/login/logout/config/help commands — they don’t need a live session.
102
+ // Subcommand help must work before login, e.g. `cv agent --help`.
102
103
  const SESSION_EXEMPT = ["login", "logout", "auth", "config", "upgrade", "-v", "--version", "-h", "--help", "help"];
103
104
  const subcommand = process.argv[2] ?? "";
104
- const isExempt = SESSION_EXEMPT.some((c) => subcommand === c || subcommand.startsWith(c));
105
+ const wantsHelp = process.argv.includes("-h") || process.argv.includes("--help") || subcommand === "help";
106
+ const isExempt = wantsHelp || SESSION_EXEMPT.includes(subcommand);
105
107
  if (!isExempt && !process.env.CV_API_KEY && !isSessionValid()) {
106
108
  const { loadConfig } = await import("./config.js");
107
109
  const hasKey = !!loadConfig().apiKey;