divapply 0.4.2__py3-none-any.whl

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.
divapply/cli.py ADDED
@@ -0,0 +1,765 @@
1
+ """DivApply CLI — the main entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import logging
8
+ import os
9
+ from typing import Optional
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from divapply import __version__
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s - %(levelname)s - %(message)s",
21
+ datefmt="%H:%M:%S",
22
+ )
23
+
24
+ app = typer.Typer(
25
+ name="divapply",
26
+ help="DivApply, an AI-powered end-to-end job application pipeline.",
27
+ no_args_is_help=True,
28
+ )
29
+ console = Console()
30
+ log = logging.getLogger(__name__)
31
+
32
+ # Valid pipeline stages (in execution order)
33
+ VALID_STAGES = ("discover", "enrich", "score", "tailor", "cover", "pdf")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def _bootstrap() -> None:
41
+ """Common setup: load env, create dirs, init DB."""
42
+ from divapply.config import load_env, ensure_dirs
43
+ from divapply.database import init_db
44
+
45
+ load_env()
46
+ ensure_dirs()
47
+ init_db()
48
+
49
+
50
+ def _version_callback(value: bool) -> None:
51
+ if value:
52
+ console.print(f"[bold]DivApply[/bold] {__version__}")
53
+ raise typer.Exit()
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Commands
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @app.callback()
61
+ def main(
62
+ version: bool = typer.Option(
63
+ False, "--version", "-V",
64
+ help="Show version and exit.",
65
+ callback=_version_callback,
66
+ is_eager=True,
67
+ ),
68
+ ) -> None:
69
+ """DivApply — AI-powered end-to-end job application pipeline."""
70
+
71
+
72
+ @app.command()
73
+ def init() -> None:
74
+ """Run the first-time setup wizard (profile, resume, search config)."""
75
+ from divapply.wizard.init import run_wizard
76
+
77
+ run_wizard()
78
+
79
+
80
+ @app.command()
81
+ def migrate(
82
+ overwrite: bool = typer.Option(False, "--overwrite", help="Replace current files with legacy copies when both exist."),
83
+ ) -> None:
84
+ """Copy legacy user files into the current DivApply layout."""
85
+ from divapply.config import ensure_dirs, migrate_legacy_user_data
86
+
87
+ ensure_dirs()
88
+ results = migrate_legacy_user_data(overwrite=overwrite)
89
+
90
+ console.print("\n[bold]Migration summary[/bold]")
91
+ for key, status in results.items():
92
+ label = {
93
+ "profile": "profile.json",
94
+ "searches": "searches.yaml",
95
+ "env": ".env",
96
+ "resume_txt": "resume.txt",
97
+ "resume_pdf": "resume.pdf",
98
+ "database": "divapply.db",
99
+ }.get(key, key)
100
+ color = "green" if status == "copied" else "yellow" if status == "skipped" else "dim"
101
+ console.print(f" [{color}]{label}[/{color}] {status}")
102
+
103
+ copied = sum(1 for status in results.values() if status == "copied")
104
+ skipped = sum(1 for status in results.values() if status == "skipped")
105
+ if copied:
106
+ console.print("[green]Legacy data copied into the DivApply layout.[/green]")
107
+ if skipped:
108
+ console.print("[yellow]Some current files already existed and were left in place.[/yellow]")
109
+ if not copied and not skipped:
110
+ console.print("[dim]No legacy files were found to migrate.[/dim]")
111
+
112
+
113
+ @app.command("import-coursework")
114
+ def import_coursework(
115
+ path: Path = typer.Argument(..., exists=True, readable=True, resolve_path=True, help="Transcript or coursework file to import."),
116
+ ) -> None:
117
+ """Import coursework knowledge into the hidden SQLite coursework table."""
118
+ _bootstrap()
119
+
120
+ from divapply.database import replace_coursework
121
+
122
+ entries: list[dict] = []
123
+ suffix = path.suffix.lower()
124
+
125
+ if suffix in {".json", ".csv"}:
126
+ text = path.read_text(encoding="utf-8", errors="ignore")
127
+
128
+ if suffix == ".json":
129
+ payload = json.loads(text)
130
+ if isinstance(payload, dict):
131
+ payload = payload.get("coursework") or payload.get("courses") or payload.get("entries") or [payload]
132
+ if isinstance(payload, list):
133
+ for item in payload:
134
+ if isinstance(item, dict):
135
+ entries.append({
136
+ "school": item.get("school") or item.get("institution"),
137
+ "course_code": item.get("course_code") or item.get("code"),
138
+ "course_title": item.get("course_title") or item.get("title") or item.get("name"),
139
+ "subject_area": item.get("subject_area") or item.get("subject") or item.get("category"),
140
+ "term": item.get("term") or item.get("semester") or item.get("session"),
141
+ "credits": item.get("credits") or item.get("units"),
142
+ "grade": item.get("grade"),
143
+ "source": item.get("source") or path.name,
144
+ "notes": item.get("notes"),
145
+ "raw_text": item.get("raw_text") or item.get("text") or json.dumps(item, ensure_ascii=True),
146
+ })
147
+ else:
148
+ entries.append({"source": path.name, "raw_text": text})
149
+ elif suffix == ".csv":
150
+ reader = csv.DictReader(text.splitlines())
151
+ for row in reader:
152
+ entries.append({
153
+ "school": row.get("school") or row.get("institution"),
154
+ "course_code": row.get("course_code") or row.get("code"),
155
+ "course_title": row.get("course_title") or row.get("title") or row.get("name"),
156
+ "subject_area": row.get("subject_area") or row.get("subject") or row.get("category"),
157
+ "term": row.get("term") or row.get("semester") or row.get("session"),
158
+ "credits": row.get("credits") or row.get("units"),
159
+ "grade": row.get("grade"),
160
+ "source": path.name,
161
+ "notes": row.get("notes"),
162
+ "raw_text": json.dumps(row, ensure_ascii=True),
163
+ })
164
+ elif suffix == ".pdf":
165
+ try:
166
+ from pypdf import PdfReader
167
+ except Exception as exc:
168
+ console.print(
169
+ "[red]PDF import needs the optional 'pypdf' package.[/red]\n"
170
+ "Install it or convert the transcript to JSON, CSV, or plain text first."
171
+ )
172
+ raise typer.Exit(code=1) from exc
173
+
174
+ reader = PdfReader(str(path))
175
+ raw_text = "\n".join(page.extract_text() or "" for page in reader.pages).strip()
176
+ entries.append({"source": path.name, "raw_text": raw_text, "notes": "Imported from PDF transcript"})
177
+ else:
178
+ text = path.read_text(encoding="utf-8", errors="ignore")
179
+ entries.append({"source": path.name, "raw_text": text, "notes": "Imported plain text transcript"})
180
+
181
+ inserted = replace_coursework(entries)
182
+ console.print(f"[green]Imported coursework entries:[/green] {inserted}")
183
+
184
+
185
+ @app.command()
186
+ def run(
187
+ stages: Optional[list[str]] = typer.Argument(
188
+ None,
189
+ help=(
190
+ "Pipeline stages to run. "
191
+ f"Valid: {', '.join(VALID_STAGES)}, all. "
192
+ "Defaults to 'all' if omitted."
193
+ ),
194
+ ),
195
+ min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for tailor/cover stages."),
196
+ prune_score: int = typer.Option(0, "--prune-score", help="Auto-delete jobs scoring at or below this after scoring (0 = off)."),
197
+ workers: int = typer.Option(1, "--workers", "-w", help="Parallel threads for discovery/enrichment stages."),
198
+ stream: bool = typer.Option(False, "--stream", help="Run stages concurrently (streaming mode)."),
199
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview stages without executing."),
200
+ validation: str = typer.Option(
201
+ "normal",
202
+ "--validation",
203
+ help=(
204
+ "Validation strictness for tailor/cover stages. "
205
+ "strict: banned words = errors, judge must pass. "
206
+ "normal: banned words = warnings only (default). "
207
+ "lenient: banned words ignored, LLM judge skipped. "
208
+ "none: skip all validation entirely, accept whatever the LLM returns."
209
+ ),
210
+ ),
211
+ ) -> None:
212
+ """Run pipeline stages: discover, enrich, score, tailor, cover, pdf."""
213
+ _bootstrap()
214
+
215
+ from divapply.pipeline import run_pipeline
216
+
217
+ stage_list = stages if stages else ["all"]
218
+
219
+ # Validate stage names
220
+ for s in stage_list:
221
+ if s != "all" and s not in VALID_STAGES:
222
+ console.print(
223
+ f"[red]Unknown stage:[/red] '{s}'. "
224
+ f"Valid stages: {', '.join(VALID_STAGES)}, all"
225
+ )
226
+ raise typer.Exit(code=1)
227
+
228
+ # Gate AI stages behind Tier 2
229
+ llm_stages = {"score", "tailor", "cover"}
230
+ if any(s in stage_list for s in llm_stages) or "all" in stage_list:
231
+ from divapply.config import check_tier
232
+ check_tier(2, "AI scoring/tailoring")
233
+
234
+ # Validate the --validation flag value
235
+ valid_modes = ("strict", "normal", "lenient", "none")
236
+ if validation not in valid_modes:
237
+ console.print(
238
+ f"[red]Invalid --validation value:[/red] '{validation}'. "
239
+ f"Choose from: {', '.join(valid_modes)}"
240
+ )
241
+ raise typer.Exit(code=1)
242
+
243
+ result = run_pipeline(
244
+ stages=stage_list,
245
+ min_score=min_score,
246
+ dry_run=dry_run,
247
+ stream=stream,
248
+ workers=workers,
249
+ validation_mode=validation,
250
+ prune_below=prune_score,
251
+ )
252
+
253
+ if result.get("errors"):
254
+ raise typer.Exit(code=1)
255
+
256
+
257
+ @app.command()
258
+ def apply(
259
+ limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max applications to submit."),
260
+ workers: int = typer.Option(1, "--workers", "-w", help="Number of parallel browser workers."),
261
+ min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for job selection."),
262
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Apply agent model name."),
263
+ backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Apply agent backend: codex or claude."),
264
+ browser: str = typer.Option("firefox", "--browser", help="Playwright browser: firefox, chrome, msedge, webkit."),
265
+ continuous: bool = typer.Option(False, "--continuous", "-c", help="Run forever, polling for new jobs."),
266
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview actions without submitting."),
267
+ headless: bool = typer.Option(False, "--headless", help="Run browsers in headless mode."),
268
+ url: Optional[str] = typer.Option(None, "--url", help="Apply to a specific job URL."),
269
+ gen: bool = typer.Option(False, "--gen", help="Generate prompt file for manual debugging instead of running."),
270
+ mark_applied: Optional[str] = typer.Option(None, "--mark-applied", help="Manually mark a job URL as applied."),
271
+ mark_failed: Optional[str] = typer.Option(None, "--mark-failed", help="Manually mark a job URL as failed (provide URL)."),
272
+ fail_reason: Optional[str] = typer.Option(None, "--fail-reason", help="Reason for --mark-failed."),
273
+ reset_failed: bool = typer.Option(False, "--reset-failed", help="Reset all failed jobs for retry."),
274
+ ) -> None:
275
+ """Launch auto-apply to submit job applications."""
276
+ _bootstrap()
277
+
278
+ from divapply.config import (
279
+ PROFILE_PATH as _profile_path,
280
+ check_tier,
281
+ get_apply_backend,
282
+ get_apply_backend_label,
283
+ get_apply_browser,
284
+ get_apply_browser_label,
285
+ get_chrome_path,
286
+ )
287
+ from divapply.database import get_connection
288
+
289
+ # --- Utility modes (no browser agent needed) ---
290
+
291
+ if mark_applied:
292
+ from divapply.apply.launcher import mark_job
293
+ mark_job(mark_applied, "applied")
294
+ console.print(f"[green]Marked as applied:[/green] {mark_applied}")
295
+ return
296
+
297
+ if mark_failed:
298
+ from divapply.apply.launcher import mark_job
299
+ mark_job(mark_failed, "failed", reason=fail_reason)
300
+ console.print(f"[yellow]Marked as failed:[/yellow] {mark_failed} ({fail_reason or 'manual'})")
301
+ return
302
+
303
+ if reset_failed:
304
+ from divapply.apply.launcher import reset_failed as do_reset
305
+ count = do_reset()
306
+ console.print(f"[green]Reset {count} failed job(s) for retry.[/green]")
307
+ return
308
+
309
+ # --- Full apply mode ---
310
+
311
+ # Check 1: Tier 3 required (apply agent CLI + browser runtime + Node.js)
312
+ resolved_browser = get_apply_browser(browser)
313
+ resolved_backend = get_apply_backend(backend)
314
+ default_model = "sonnet" if resolved_backend == "claude" else "gpt-5.4-mini"
315
+ resolved_model = (
316
+ model
317
+ or os.environ.get("LLM_MODEL_APPLY")
318
+ or os.environ.get("LLM_MODEL")
319
+ or default_model
320
+ )
321
+ check_tier(3, "auto-apply", preferred_backend=backend, preferred_browser=browser)
322
+ if resolved_backend is None:
323
+ console.print(
324
+ "[red]No supported apply backend found.[/red]\n"
325
+ "Install Codex or Claude Code, or pass [bold]--backend[/bold] with an installed option."
326
+ )
327
+ raise typer.Exit(code=1)
328
+ if resolved_browser == "chrome":
329
+ try:
330
+ get_chrome_path()
331
+ except FileNotFoundError:
332
+ console.print("[red]Chrome/Chromium not found.[/red]\nInstall Chrome or set CHROME_PATH.")
333
+ raise typer.Exit(code=1)
334
+
335
+ # Check 2: Profile exists
336
+ if not _profile_path.exists():
337
+ console.print(
338
+ "[red]Profile not found.[/red]\n"
339
+ "Run [bold]divapply init[/bold] to create your profile first."
340
+ )
341
+ raise typer.Exit(code=1)
342
+
343
+ # Check 3: Tailored resumes exist (skip for --gen with --url)
344
+ if not (gen and url):
345
+ conn = get_connection()
346
+ ready = conn.execute(
347
+ "SELECT COUNT(*) FROM jobs WHERE tailored_resume_path IS NOT NULL AND applied_at IS NULL"
348
+ ).fetchone()[0]
349
+ if ready == 0:
350
+ console.print(
351
+ "[red]No tailored resumes ready.[/red]\n"
352
+ "Run [bold]divapply run score tailor[/bold] first to prepare applications."
353
+ )
354
+ raise typer.Exit(code=1)
355
+
356
+ if gen:
357
+ from divapply.apply.launcher import gen_prompt, get_manual_command
358
+ target = url or ""
359
+ if not target:
360
+ console.print("[red]--gen requires --url to specify which job.[/red]")
361
+ raise typer.Exit(code=1)
362
+ prompt_file = gen_prompt(
363
+ target,
364
+ min_score=min_score,
365
+ model=resolved_model,
366
+ backend=resolved_backend,
367
+ browser=resolved_browser,
368
+ )
369
+ if not prompt_file:
370
+ console.print("[red]No matching job found for that URL.[/red]")
371
+ raise typer.Exit(code=1)
372
+ mcp_path = _profile_path.parent / ".mcp-apply-0.json"
373
+ console.print(f"[green]Wrote prompt to:[/green] {prompt_file}")
374
+ console.print(f"\n[bold]Run manually:[/bold]")
375
+ console.print(f" {get_manual_command(resolved_backend, resolved_model, prompt_file, mcp_path)}")
376
+ return
377
+
378
+ from divapply.apply.launcher import main as apply_main
379
+
380
+ effective_limit = limit if limit is not None else (0 if continuous else 1)
381
+
382
+ console.print("\n[bold blue]Launching Auto-Apply[/bold blue]")
383
+ console.print(f" Limit: {'unlimited' if continuous else effective_limit}")
384
+ console.print(f" Workers: {workers}")
385
+ console.print(f" Backend: {get_apply_backend_label(resolved_backend)}")
386
+ console.print(f" Browser: {get_apply_browser_label(resolved_browser)}")
387
+ console.print(f" Model: {resolved_model}")
388
+ console.print(f" Headless: {headless}")
389
+ console.print(f" Dry run: {dry_run}")
390
+ if url:
391
+ console.print(f" Target: {url}")
392
+ console.print()
393
+
394
+ apply_main(
395
+ limit=effective_limit,
396
+ target_url=url,
397
+ min_score=min_score,
398
+ headless=headless,
399
+ model=resolved_model,
400
+ backend=resolved_backend,
401
+ browser=resolved_browser,
402
+ dry_run=dry_run,
403
+ continuous=continuous,
404
+ workers=workers,
405
+ )
406
+
407
+
408
+ @app.command()
409
+ def status() -> None:
410
+ """Show pipeline statistics from the database."""
411
+ _bootstrap()
412
+
413
+ from divapply.database import get_stats
414
+
415
+ stats = get_stats()
416
+
417
+ console.print("\n[bold]DivApply Pipeline Status[/bold]\n")
418
+
419
+ # Summary table
420
+ summary = Table(title="Pipeline Overview", show_header=True, header_style="bold cyan")
421
+ summary.add_column("Metric", style="bold")
422
+ summary.add_column("Count", justify="right")
423
+
424
+ summary.add_row("Total jobs discovered", str(stats["total"]))
425
+ summary.add_row("With full description", str(stats["with_description"]))
426
+ summary.add_row("Pending enrichment", str(stats["pending_detail"]))
427
+ summary.add_row("Enrichment errors", str(stats["detail_errors"]))
428
+ summary.add_row("Scored by LLM", str(stats["scored"]))
429
+ summary.add_row("Pending scoring", str(stats["unscored"]))
430
+ summary.add_row("Tailored resumes", str(stats["tailored"]))
431
+ summary.add_row("Pending tailoring (7+)", str(stats["untailored_eligible"]))
432
+ summary.add_row("Cover letters", str(stats["with_cover_letter"]))
433
+ summary.add_row("Ready to apply", str(stats["ready_to_apply"]))
434
+ summary.add_row("Applied", str(stats["applied"]))
435
+ summary.add_row("Apply errors", str(stats["apply_errors"]))
436
+
437
+ console.print(summary)
438
+
439
+ # Score distribution
440
+ if stats["score_distribution"]:
441
+ dist_table = Table(title="\nScore Distribution", show_header=True, header_style="bold yellow")
442
+ dist_table.add_column("Score", justify="center")
443
+ dist_table.add_column("Count", justify="right")
444
+ dist_table.add_column("Bar")
445
+
446
+ max_count = max(count for _, count in stats["score_distribution"]) or 1
447
+ for score, count in stats["score_distribution"]:
448
+ bar_len = int(count / max_count * 30)
449
+ if score >= 7:
450
+ color = "green"
451
+ elif score >= 5:
452
+ color = "yellow"
453
+ else:
454
+ color = "red"
455
+ bar = f"[{color}]{'=' * bar_len}[/{color}]"
456
+ dist_table.add_row(str(score), str(count), bar)
457
+
458
+ console.print(dist_table)
459
+
460
+ # By site
461
+ if stats["by_site"]:
462
+ site_table = Table(title="\nJobs by Source", show_header=True, header_style="bold magenta")
463
+ site_table.add_column("Site")
464
+ site_table.add_column("Count", justify="right")
465
+
466
+ for site, count in stats["by_site"]:
467
+ site_table.add_row(site or "Unknown", str(count))
468
+
469
+ console.print(site_table)
470
+
471
+ console.print()
472
+
473
+
474
+ @app.command()
475
+ def dashboard() -> None:
476
+ """Generate and open the HTML dashboard in your browser."""
477
+ _bootstrap()
478
+
479
+ from divapply.view import open_dashboard
480
+
481
+ open_dashboard()
482
+
483
+
484
+ @app.command()
485
+ def doctor() -> None:
486
+ """Check your setup and diagnose missing requirements."""
487
+ import shutil
488
+ from divapply.config import (
489
+ load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH,
490
+ SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path,
491
+ get_apply_browser, get_apply_browser_label,
492
+ )
493
+
494
+ load_env()
495
+
496
+ ok_mark = "[green]OK[/green]"
497
+ fail_mark = "[red]MISSING[/red]"
498
+ warn_mark = "[yellow]WARN[/yellow]"
499
+
500
+ results: list[tuple[str, str, str]] = [] # (check, status, note)
501
+
502
+ # --- Tier 1 checks ---
503
+ # Profile
504
+ if PROFILE_PATH.exists():
505
+ results.append(("profile.json", ok_mark, str(PROFILE_PATH)))
506
+ else:
507
+ results.append(("profile.json", fail_mark, "Run 'divapply init' to create"))
508
+
509
+ # Resume
510
+ if RESUME_PATH.exists():
511
+ results.append(("resume.txt", ok_mark, str(RESUME_PATH)))
512
+ elif RESUME_PDF_PATH.exists():
513
+ results.append(("resume.txt", warn_mark, "Only PDF found — plain-text needed for AI stages"))
514
+ else:
515
+ results.append(("resume.txt", fail_mark, "Run 'divapply init' to add your resume"))
516
+
517
+ # Search config
518
+ if SEARCH_CONFIG_PATH.exists():
519
+ results.append(("searches.yaml", ok_mark, str(SEARCH_CONFIG_PATH)))
520
+ else:
521
+ results.append(("searches.yaml", warn_mark, "Will use example config — run 'divapply init'"))
522
+
523
+ # jobspy (discovery dep installed separately)
524
+ try:
525
+ import jobspy # noqa: F401
526
+ results.append(("python-jobspy", ok_mark, "Job board scraping available"))
527
+ except ImportError:
528
+ results.append(("python-jobspy", warn_mark,
529
+ "pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex"))
530
+
531
+ # --- Tier 2 checks ---
532
+ import os
533
+ has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
534
+ has_openai = bool(os.environ.get("OPENAI_API_KEY"))
535
+ has_local = bool(os.environ.get("LLM_URL"))
536
+ if has_gemini:
537
+ model = os.environ.get("LLM_MODEL", "gemini-2.0-flash")
538
+ results.append(("LLM API key", ok_mark, f"Gemini ({model})"))
539
+ elif has_openai:
540
+ model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
541
+ results.append(("LLM API key", ok_mark, f"OpenAI ({model})"))
542
+ elif has_local:
543
+ results.append(("LLM API key", ok_mark, f"Local: {os.environ.get('LLM_URL')}"))
544
+ else:
545
+ results.append(("LLM API key", fail_mark,
546
+ "Set GEMINI_API_KEY in ~/.divapply/.env (run 'divapply init')"))
547
+
548
+ # --- Tier 3 checks ---
549
+ from divapply.config import get_apply_backend, get_apply_backend_label, get_available_apply_backends
550
+ detected_backends = get_available_apply_backends()
551
+ selected_backend = get_apply_backend()
552
+ if detected_backends:
553
+ note = ", ".join(
554
+ f"{get_apply_backend_label(name)}: {path}" for name, path in detected_backends.items()
555
+ )
556
+ results.append(("Apply agent CLI", ok_mark, note))
557
+ else:
558
+ results.append(("Apply agent CLI", fail_mark,
559
+ "Install Codex or Claude Code (needed for auto-apply)"))
560
+
561
+ # Browser runtime
562
+ selected_browser = get_apply_browser()
563
+ if selected_browser == "chrome":
564
+ try:
565
+ chrome_path = get_chrome_path()
566
+ results.append(("Browser", ok_mark, chrome_path))
567
+ except FileNotFoundError:
568
+ results.append(("Browser", fail_mark,
569
+ "Install Chrome or set CHROME_PATH env var (needed for Chrome mode)"))
570
+ else:
571
+ results.append(("Browser", ok_mark,
572
+ f"Playwright channel: {get_apply_browser_label(selected_browser)}"))
573
+
574
+ # Node.js / npx (for Playwright MCP)
575
+ npx_bin = shutil.which("npx")
576
+ if npx_bin:
577
+ results.append(("Node.js (npx)", ok_mark, npx_bin))
578
+ else:
579
+ results.append(("Node.js (npx)", fail_mark,
580
+ "Install Node.js 18+ from nodejs.org (needed for auto-apply)"))
581
+
582
+ # CapSolver (optional)
583
+ capsolver = os.environ.get("CAPSOLVER_API_KEY")
584
+ if capsolver:
585
+ results.append(("CapSolver API key", ok_mark, "CAPTCHA solving enabled"))
586
+ else:
587
+ results.append(("CapSolver API key", "[dim]optional[/dim]",
588
+ "Set CAPSOLVER_API_KEY in .env for CAPTCHA solving"))
589
+
590
+ # --- Render results ---
591
+ console.print()
592
+ console.print("[bold]DivApply Doctor[/bold]\n")
593
+
594
+ col_w = max(len(r[0]) for r in results) + 2
595
+ for check, status, note in results:
596
+ pad = " " * (col_w - len(check))
597
+ console.print(f" {check}{pad}{status} [dim]{note}[/dim]")
598
+
599
+ console.print()
600
+
601
+ # Tier summary
602
+ from divapply.config import get_tier, TIER_LABELS
603
+ tier = get_tier()
604
+ if selected_backend:
605
+ console.print(f"[dim] Auto-apply backend: {get_apply_backend_label(selected_backend)}[/dim]")
606
+ console.print(f"[bold]Current tier: Tier {tier} - {TIER_LABELS[tier]}[/bold]")
607
+
608
+ if tier == 1:
609
+ console.print("[dim] -> Tier 2 unlocks: scoring, tailoring, cover letters (needs LLM API key)[/dim]")
610
+ console.print("[dim] -> Tier 3 unlocks: auto-apply (needs an apply backend CLI + Node.js + browser runtime)[/dim]")
611
+ elif tier == 2:
612
+ console.print("[dim] -> Tier 3 unlocks: auto-apply (needs an apply backend CLI + Node.js + browser runtime)[/dim]")
613
+
614
+ console.print()
615
+
616
+
617
+ @app.command()
618
+ def prune(
619
+ max_score: int = typer.Option(4, "--max-score", help="Delete scored jobs at or below this score (default: 4)."),
620
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview without deleting."),
621
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
622
+ ) -> None:
623
+ """Remove low-scoring jobs from the database to reduce clutter."""
624
+ _bootstrap()
625
+
626
+ from divapply.database import get_connection
627
+
628
+ conn = get_connection()
629
+
630
+ # Show breakdown by score before deleting
631
+ rows = conn.execute(
632
+ "SELECT fit_score, COUNT(*) FROM jobs "
633
+ "WHERE fit_score IS NOT NULL AND fit_score <= ? "
634
+ "GROUP BY fit_score ORDER BY fit_score",
635
+ (max_score,),
636
+ ).fetchall()
637
+
638
+ if not rows:
639
+ console.print(f"[green]No scored jobs with fit_score <= {max_score} found.[/green]")
640
+ return
641
+
642
+ total = sum(r[1] for r in rows)
643
+ console.print(f"\n[yellow]Jobs to remove (fit_score <= {max_score}):[/yellow]")
644
+ for score, count in rows:
645
+ bar = "=" * min(count, 40)
646
+ console.print(f" Score {score}: {count:>4} [{bar}]")
647
+ console.print(f" [bold]Total: {total}[/bold]\n")
648
+
649
+ if dry_run:
650
+ console.print("[dim]Dry run — no changes made.[/dim]")
651
+ return
652
+
653
+ if not yes:
654
+ confirmed = typer.confirm(f"Delete {total} jobs permanently?")
655
+ if not confirmed:
656
+ console.print("[dim]Cancelled.[/dim]")
657
+ return
658
+
659
+ conn.execute(
660
+ "DELETE FROM jobs WHERE fit_score IS NOT NULL AND fit_score <= ?",
661
+ (max_score,),
662
+ )
663
+ conn.commit()
664
+ console.print(f"[green]Deleted {total} low-scoring jobs (score <= {max_score}).[/green]")
665
+
666
+
667
+ @app.command()
668
+ def ultimate(
669
+ top: int = typer.Option(10, "--top", "-n", help="Number of top-scoring jobs to draw from."),
670
+ min_score: int = typer.Option(7, "--min-score", help="Minimum fit score to include."),
671
+ out: Optional[str] = typer.Option(None, "--out", "-o", help="Output directory (default: ~/.divapply/)."),
672
+ ) -> None:
673
+ """Generate an ultimate general-purpose resume from your top-scoring jobs."""
674
+ _bootstrap()
675
+
676
+ from pathlib import Path
677
+ from divapply.scoring.ultimate import generate_ultimate_resume
678
+
679
+ output_dir = Path(out) if out else None
680
+
681
+ try:
682
+ result = generate_ultimate_resume(
683
+ top_n=top,
684
+ min_score=min_score,
685
+ output_dir=output_dir,
686
+ )
687
+ except RuntimeError as e:
688
+ console.print(f"[red]{e}[/red]")
689
+ raise typer.Exit(code=1)
690
+
691
+ console.print(f"\n[bold green]Ultimate resume generated![/bold green]")
692
+ console.print(f" Jobs used: {result['jobs_used']}")
693
+ console.print(f" Text: {result['text_path']}")
694
+ if result.get("pdf_path"):
695
+ console.print(f" PDF: {result['pdf_path']}")
696
+ console.print(f" Time: {result['elapsed']:.1f}s")
697
+ console.print()
698
+
699
+
700
+ @app.command()
701
+ def sync(
702
+ platform: Optional[list[str]] = typer.Argument(
703
+ None,
704
+ help="Platforms to sync: github, linkedin, facebook. Defaults to all.",
705
+ ),
706
+ dry_run: bool = typer.Option(False, "--dry-run", help="Generate content without pushing or automating."),
707
+ headless: bool = typer.Option(False, "--headless", help="Run browser automation in headless mode."),
708
+ ) -> None:
709
+ """Sync your profile across social platforms (GitHub, LinkedIn, Facebook).
710
+
711
+ Uses your Firefox login cookies to automate profile updates.
712
+ GitHub is updated via API (needs GITHUB_TOKEN in .env).
713
+ LinkedIn and Facebook are updated via browser automation.
714
+ """
715
+ _bootstrap()
716
+
717
+ from divapply.social import sync_profiles
718
+
719
+ targets = platform if platform else None
720
+
721
+ # Validate platform names
722
+ valid = {"github", "linkedin", "facebook"}
723
+ if targets:
724
+ for t in targets:
725
+ if t.lower() not in valid:
726
+ console.print(f"[red]Unknown platform:[/red] '{t}'. Valid: {', '.join(sorted(valid))}")
727
+ raise typer.Exit(code=1)
728
+
729
+ console.print("\n[bold blue]Social Profile Sync[/bold blue]")
730
+ if dry_run:
731
+ console.print("[dim]Dry run — generating content only, no automation.[/dim]")
732
+ else:
733
+ console.print("[dim]Extracting Firefox cookies for login, launching browser...[/dim]")
734
+ console.print()
735
+
736
+ results = sync_profiles(platforms=targets, dry_run=dry_run, headless=headless)
737
+
738
+ for r in results:
739
+ header = f"[bold]{r.platform}[/bold]"
740
+ if r.auto_updated:
741
+ updated = ", ".join(r.sections_updated) if r.sections_updated else "all"
742
+ console.print(f" {header} [green]UPDATED[/green] ({updated})")
743
+ elif r.error:
744
+ console.print(f" {header} [yellow]{r.error}[/yellow]")
745
+ else:
746
+ console.print(f" {header} [dim]done[/dim]")
747
+
748
+ if r.sections_failed:
749
+ console.print(f" [red]failed:[/red] {', '.join(r.sections_failed)}")
750
+
751
+ # Show generated content
752
+ for key, val in r.content.items():
753
+ display = val if len(val) <= 120 else val[:117] + "..."
754
+ console.print(f" {key}: {display}")
755
+ console.print()
756
+
757
+ # Mention the saved snapshot
758
+ from divapply.config import APP_DIR as _app_dir
759
+ console.print(f"[dim]Full content saved to {_app_dir / 'social_sync.json'}[/dim]")
760
+ console.print(f"[dim]Debug screenshots in {_app_dir / 'social_screenshots/'}[/dim]\n")
761
+
762
+
763
+ if __name__ == "__main__":
764
+ app()
765
+