careervivid 2.1.38 → 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 +76 -27
  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;AAu2BpC,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"}
@@ -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", {
@@ -662,7 +669,7 @@ async function runVoiceSession(opts) {
662
669
  console.log(chalk.dim("\n Generating your personalized feedback report..."));
663
670
  let report = null;
664
671
  try {
665
- report = await analyzeTranscript(transcript, role);
672
+ report = await analyzeTranscript(transcript, role, model);
666
673
  printReport(report);
667
674
  log.info("feedback_complete", { sessionId, overallScore: report.overallScore });
668
675
  }
@@ -701,42 +708,79 @@ async function runVoiceSession(opts) {
701
708
  }
702
709
  // ─── TEXT SESSION (fallback) ──────────────────────────────────────────────────
703
710
  async function runTextSession(opts) {
704
- const { role, questions, resumeContext } = opts;
711
+ const { role, questions, resumeContext, model } = opts;
705
712
  const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
706
713
  printBanner(role, "text");
707
714
  const history = [];
708
715
  const transcript = [];
709
716
  let ended = false;
710
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);
711
732
  const askUser = () => new Promise(resolve => {
733
+ resolvePending = resolve;
712
734
  if (process.stdin.isTTY)
713
735
  process.stdout.write(chalk.white.bold("\n you ❯ "));
714
- rl.once("line", line => resolve(line.trim()));
715
- 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
+ });
716
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
+ };
717
757
  const spinner = ora(chalk.dim("Vivid is connecting...")).start();
718
758
  try {
719
- const greeting = await callAgentProxy({
720
- contents: [{ role: "user", parts: [{ text: "Hello" }] }],
721
- systemInstruction,
722
- });
759
+ const greeting = await runAgentCall([{ role: "user", parts: [{ text: "Hello" }] }]);
723
760
  spinner.stop();
724
- history.push({ role: "user", parts: [{ text: "Hello" }] });
725
- history.push({ role: "model", parts: [{ text: greeting }] });
726
- transcript.push({ speaker: "user", text: "Hello" });
727
- transcript.push({ speaker: "ai", text: greeting });
728
- if (greeting.includes(END_TOKEN))
761
+ if (sigintReceived || greeting === null) {
729
762
  ended = true;
730
- 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
+ }
731
773
  }
732
774
  catch (err) {
733
775
  spinner.fail(chalk.red("Failed to connect to AI interviewer."));
734
776
  throw err;
735
777
  }
736
- while (!ended) {
778
+ while (!ended && !sigintReceived) {
737
779
  const input = await askUser();
738
- if (input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
739
- printSystem("Interview ended early.");
780
+ if (sigintReceived || input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
781
+ if (!sigintReceived) {
782
+ printSystem("Interview ended early.");
783
+ }
740
784
  break;
741
785
  }
742
786
  if (input === "")
@@ -745,8 +789,11 @@ async function runTextSession(opts) {
745
789
  transcript.push({ speaker: "user", text: input });
746
790
  const aiSpinner = ora({ text: "" }).start();
747
791
  try {
748
- const aiResponse = await callAgentProxy({ contents: history, systemInstruction });
792
+ const aiResponse = await runAgentCall(history);
749
793
  aiSpinner.stop();
794
+ if (sigintReceived || aiResponse === null) {
795
+ break;
796
+ }
750
797
  history.push({ role: "model", parts: [{ text: aiResponse }] });
751
798
  transcript.push({ speaker: "ai", text: aiResponse.replace(END_TOKEN, "").trim() });
752
799
  if (aiResponse.includes(END_TOKEN))
@@ -758,6 +805,7 @@ async function runTextSession(opts) {
758
805
  console.log(chalk.red(`\n Error: ${err.message}\n`));
759
806
  }
760
807
  }
808
+ process.off("SIGINT", sigintHandler);
761
809
  rl.close();
762
810
  const userTurns = transcript.filter(t => t.speaker === "user").length;
763
811
  if (userTurns < 2) {
@@ -767,7 +815,7 @@ async function runTextSession(opts) {
767
815
  console.log(chalk.dim("\n Generating your personalized feedback report..."));
768
816
  let textReport = null;
769
817
  try {
770
- textReport = await analyzeTranscript(transcript, role);
818
+ textReport = await analyzeTranscript(transcript, role, model);
771
819
  printReport(textReport);
772
820
  }
773
821
  catch (err) {
@@ -802,6 +850,7 @@ export function registerInterviewCommand(program) {
802
850
  .option("-q, --questions <n>", "Number of interview questions to generate", "5")
803
851
  .option("--resume <id>", "Load a specific resume ID for context (from cv resumes list)")
804
852
  .option("--text", "Use text-only mode (no audio required)")
853
+ .option("--model <model>", "Specify a custom model to use for the interview session")
805
854
  .addHelpText("after", `
806
855
  Examples:
807
856
  cv interview
@@ -858,7 +907,7 @@ Voice mode setup (one-time):
858
907
  // ── Generate questions ───────────────────────────────────────────
859
908
  let questions;
860
909
  try {
861
- questions = await generateQuestions(role, numQuestions);
910
+ questions = await generateQuestions(role, numQuestions, opts.model);
862
911
  }
863
912
  catch (err) {
864
913
  console.error(chalk.red(`\n Failed to generate questions: ${err.message}\n`));
@@ -866,7 +915,7 @@ Voice mode setup (one-time):
866
915
  }
867
916
  // ── Determine mode ───────────────────────────────────────────────
868
917
  if (opts.text) {
869
- await runTextSession({ role, questions, resumeContext });
918
+ await runTextSession({ role, questions, resumeContext, model: opts.model });
870
919
  return;
871
920
  }
872
921
  // Probe for sox
@@ -877,12 +926,12 @@ Voice mode setup (one-time):
877
926
  " macOS: brew install sox\n" +
878
927
  " Linux: sudo apt install sox\n" +
879
928
  "\n Or run in text mode: cv interview --text\n"));
880
- await runTextSession({ role, questions, resumeContext });
929
+ await runTextSession({ role, questions, resumeContext, model: opts.model });
881
930
  return;
882
931
  }
883
932
  // Voice mode
884
933
  try {
885
- await runVoiceSession({ role, questions, resumeContext, soxPath });
934
+ await runVoiceSession({ role, questions, resumeContext, soxPath, model: opts.model });
886
935
  }
887
936
  catch (err) {
888
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "2.1.38",
3
+ "version": "2.1.39",
4
4
  "description": "Official CLI for CareerVivid — AI voice interviews, autonomous job applications, resume editing, and portfolio publishing from your terminal",
5
5
  "type": "module",
6
6
  "bin": {