jobhunter-cli 1.0__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.

Potentially problematic release.


This version of jobhunter-cli might be problematic. Click here for more details.

jobhunter/__init__.py ADDED
File without changes
jobhunter/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
@@ -0,0 +1,110 @@
1
+ import datetime
2
+ import re
3
+ from functools import lru_cache
4
+
5
+ from jobhunter.core.settings import settings
6
+ from jobhunter.models import Job
7
+
8
+
9
+ @lru_cache(maxsize=512)
10
+ def _get_word_pattern(keyword: str) -> re.Pattern:
11
+ """Compiles and caches word boundary regex patterns for tech keywords."""
12
+ return re.compile(rf"(?<![a-z0-9]){re.escape(keyword.lower())}(?![a-z0-9])")
13
+
14
+
15
+ def has_word(keyword: str, text: str) -> bool:
16
+ """Checks if a keyword exists in the text as a whole word, ignoring case."""
17
+ if not text or not keyword:
18
+ return False
19
+ return bool(_get_word_pattern(keyword).search(text.lower()))
20
+
21
+
22
+ def is_valid_title(job: Job) -> bool:
23
+ """Checks if the job title passes the include/exclude filters."""
24
+ title = (job.title or "").lower()
25
+ filters = settings.filters
26
+
27
+ is_excluded = bool(filters.title_exclude and any(has_word(ex, title) for ex in filters.title_exclude))
28
+
29
+ is_included = not filters.title_include or any(has_word(inc, title) for inc in filters.title_include)
30
+
31
+ return is_included and not is_excluded
32
+
33
+
34
+ def is_valid_company(job: Job) -> bool:
35
+ """Checks if the job company passes the exclude filters."""
36
+ company = (job.company or "").lower()
37
+ filters = settings.filters
38
+
39
+ is_excluded = bool(filters.company_exclude and any(has_word(ex, company) for ex in filters.company_exclude))
40
+
41
+ return not is_excluded
42
+
43
+
44
+ def is_valid_location(job: Job) -> bool:
45
+ """Checks location filters, giving absolute priority to location_allow_terms."""
46
+ location = (job.location or "").lower()
47
+ filters = settings.filters
48
+
49
+ is_allowed = bool(filters.location_allow_terms and any(has_word(allow, location) for allow in filters.location_allow_terms))
50
+
51
+ is_excluded = bool(filters.location_exclude_terms and any(has_word(ex, location) for ex in filters.location_exclude_terms))
52
+
53
+ is_missing_required = bool(filters.location_require_terms and not any(has_word(req, location) for req in filters.location_require_terms))
54
+
55
+ return is_allowed or (not is_excluded and not is_missing_required)
56
+
57
+
58
+ def is_job_remote(job: Job) -> bool:
59
+ """Checks if the job is remote based on attribute or description keywords."""
60
+ is_flagged_remote = job.remote is True
61
+
62
+ search_text = f"{job.title or ''} {job.location or ''} {job.description or ''}"
63
+
64
+ has_remote_keyword = bool(settings.filters.remote_keywords and any(has_word(kw, search_text) for kw in settings.filters.remote_keywords))
65
+
66
+ return is_flagged_remote or has_remote_keyword
67
+
68
+
69
+ def is_recent_enough(job: Job) -> bool:
70
+ """Checks if the job posting is within the max age days filter."""
71
+ has_no_date = not job.posted
72
+ if has_no_date:
73
+ return True
74
+
75
+ try:
76
+ posted_date = datetime.datetime.strptime(str(job.posted)[:10], "%Y-%m-%d").date()
77
+ age_days = (datetime.datetime.now(datetime.UTC).date() - posted_date).days
78
+ is_within_age_limit = age_days <= settings.filters.max_age_days
79
+
80
+ return is_within_age_limit
81
+ except ValueError, TypeError:
82
+ return True
83
+
84
+
85
+ def count_stack_hits(job: Job) -> int:
86
+ """Counts how many stack keywords are present in the job title and description."""
87
+ filters = settings.filters
88
+ if not filters.stack_keywords:
89
+ return 0
90
+
91
+ search_text = f"{job.title or ''} {job.description or ''}"
92
+ return sum(1 for kw in filters.stack_keywords if has_word(kw, search_text))
93
+
94
+
95
+ def has_required_stack(job: Job) -> bool:
96
+ """Checks if the job description contains the required stack keywords."""
97
+ filters = settings.filters
98
+
99
+ no_stack_required = filters.min_stack_hits <= 0 or not filters.stack_keywords
100
+
101
+ has_enough_hits = count_stack_hits(job) >= filters.min_stack_hits
102
+
103
+ return no_stack_required or has_enough_hits
104
+
105
+
106
+ def passes_all_filters(job: Job) -> bool:
107
+ """Master function to run a job through all active filters."""
108
+ is_remote_valid = not settings.filters.require_remote or is_job_remote(job)
109
+
110
+ return is_remote_valid and is_valid_title(job) and is_valid_company(job) and is_valid_location(job) and is_recent_enough(job) and has_required_stack(job)
jobhunter/core/http.py ADDED
@@ -0,0 +1,57 @@
1
+ import atexit
2
+ import logging
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from jobhunter.core.logger import console
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ RETRYABLE_STATUS = {408, 429}
13
+ CLIENT = httpx.Client(headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
14
+
15
+ atexit.register(CLIENT.close)
16
+
17
+
18
+ def _should_retry(e: httpx.HTTPError) -> bool:
19
+ if isinstance(e, httpx.HTTPStatusError):
20
+ code = e.response.status_code
21
+ return code in RETRYABLE_STATUS or code >= 500
22
+ return isinstance(e, httpx.RequestError)
23
+
24
+
25
+ def fetch_url(url: str, headers: dict | None = None, timeout: float = 15.0, retries: int = 3, backoff: float = 1.5, parse_json: bool = False, expected_type: type | None = None, **kwargs) -> Any:
26
+ """Fetches data from a URL. Returns parsed JSON if `parse_json=True` or `expected_type` is provided, else returns `httpx.Response`."""
27
+ if retries <= 0:
28
+ raise ValueError("retries must be at least 1")
29
+
30
+ if expected_type is not None:
31
+ parse_json = True
32
+
33
+ for attempt in range(retries):
34
+ try:
35
+ response = CLIENT.get(url, headers=headers, timeout=timeout, **kwargs)
36
+ response.raise_for_status()
37
+
38
+ if parse_json:
39
+ data = response.json()
40
+ if expected_type and not isinstance(data, expected_type):
41
+ raise ValueError(f"Expected {expected_type.__name__} from {url}, got {type(data).__name__}")
42
+ return data
43
+
44
+ return response
45
+
46
+ except httpx.HTTPError as e:
47
+ if _should_retry(e) and attempt < retries - 1:
48
+ time.sleep(backoff * (2**attempt))
49
+ else:
50
+ is_client_not_found = isinstance(e, httpx.HTTPStatusError) and e.response.status_code in {404, 410}
51
+ if not is_client_not_found:
52
+ logger.error(f"HTTP request failed permanently for {url}: {e}")
53
+ console.print(f" [red]✖ Network error fetching {url}: {e}[/red]")
54
+ raise
55
+
56
+ except ValueError as e:
57
+ raise RuntimeError(f"JSON decoding or type error for {url}: {e}") from e
@@ -0,0 +1,54 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from rich.console import Console
5
+ from rich.logging import RichHandler
6
+
7
+ console = Console()
8
+
9
+
10
+ def setup_logging(level: str = "INFO", log_file: str = "logs/jobhunter.log") -> None:
11
+ """Configures global application logging to output to both the console (Rich) and a log file."""
12
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
13
+
14
+ log_path = Path(log_file)
15
+ log_path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ # 1. Rich Console Handler (Terminal UI & Tracebacks)
18
+ console_handler = RichHandler(
19
+ console=console,
20
+ rich_tracebacks=True,
21
+ tracebacks_show_locals=False,
22
+ markup=True,
23
+ )
24
+ # hide normal logger.info() from the terminal and only show errors!
25
+ console_handler.setLevel(logging.CRITICAL)
26
+
27
+ # 2. Standard File Handler (For debugging history)
28
+ file_handler = logging.FileHandler(log_path, encoding="utf-8")
29
+ file_handler.setLevel(numeric_level)
30
+ file_formatter = logging.Formatter(
31
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
32
+ datefmt="%Y-%m-%d %H:%M:%S",
33
+ )
34
+ file_handler.setFormatter(file_formatter)
35
+
36
+ logging.basicConfig(
37
+ level=numeric_level,
38
+ format="%(message)s",
39
+ datefmt="[%X]",
40
+ handlers=[console_handler, file_handler],
41
+ force=True,
42
+ )
43
+
44
+ # 3. Suppress Noisy Third-Party Loggers
45
+ noisy_prefixes = ("openai", "httpx", "httpcore", "urllib3", "ddgs", "primp")
46
+
47
+ # Direct suppression
48
+ for noisy_logger in noisy_prefixes:
49
+ logging.getLogger(noisy_logger).setLevel(logging.WARNING)
50
+
51
+ # Aggressive sub-logger suppression loop
52
+ for name, logger in logging.root.manager.loggerDict.items():
53
+ if any(name.startswith(prefix) for prefix in noisy_prefixes) and isinstance(logger, logging.Logger):
54
+ logger.setLevel(logging.WARNING)
@@ -0,0 +1,124 @@
1
+ import contextlib
2
+ import hashlib
3
+ import json
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from filelock import FileLock
11
+
12
+ from jobhunter.core.logger import console
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_project_root() -> Path:
18
+ """Returns the root directory of the project by searching for 'pyproject.toml'."""
19
+ current_dir = Path(__file__).resolve().parent
20
+
21
+ for directory in [current_dir, *current_dir.parents]:
22
+ if (directory / "pyproject.toml").exists():
23
+ return directory
24
+
25
+ return Path.cwd()
26
+
27
+
28
+ ROOT_PATH = get_project_root()
29
+ LOCKS_PATH = ROOT_PATH / ".locks"
30
+ DATA_PATH = ROOT_PATH / "data"
31
+ CONFIG_PATH = ROOT_PATH / "config"
32
+
33
+
34
+ def _resolve_path(path_in: str | Path) -> Path:
35
+ """
36
+ Safely resolves a path against the project root.
37
+ If path_in is absolute, it returns it as-is.
38
+ If path_in is relative, it prepends ROOT_PATH.
39
+ """
40
+ return ROOT_PATH / path_in
41
+
42
+
43
+ def _json_default(obj: Any) -> Any:
44
+ """Fallback handler for non-standard JSON types like Pydantic models."""
45
+ if hasattr(obj, "model_dump"):
46
+ return obj.model_dump(mode="json")
47
+ return str(obj)
48
+
49
+
50
+ def write(path_from_root: str | Path, data: Any, timeout: int = 10):
51
+ """Atomically writes data to disk using a persistent lockfile for process safety."""
52
+ path = _resolve_path(path_from_root)
53
+
54
+ directory = path.parent
55
+ directory.mkdir(parents=True, exist_ok=True)
56
+ LOCKS_PATH.mkdir(exist_ok=True)
57
+
58
+ if isinstance(data, bytes):
59
+ raw_bytes = data
60
+ elif isinstance(data, str):
61
+ raw_bytes = data.encode("utf-8")
62
+ elif isinstance(data, (dict, list)):
63
+ raw_bytes = json.dumps(data, indent=2, default=_json_default).encode("utf-8")
64
+ elif hasattr(data, "model_dump"):
65
+ raw_bytes = json.dumps(data.model_dump(mode="json"), indent=2).encode("utf-8")
66
+ else:
67
+ raw_bytes = str(data).encode("utf-8")
68
+
69
+ path_hash = hashlib.md5(str(path.resolve()).encode()).hexdigest()
70
+ lock_path = LOCKS_PATH / f"{path.name}_{path_hash}.lock"
71
+
72
+ with FileLock(str(lock_path), timeout=timeout):
73
+ fd, temp_path = tempfile.mkstemp(dir=directory, prefix=".tmp_")
74
+
75
+ try:
76
+ with os.fdopen(fd, "wb") as f:
77
+ f.write(raw_bytes)
78
+ f.flush()
79
+ if hasattr(os, "fdatasync"):
80
+ os.fdatasync(f.fileno())
81
+ else:
82
+ os.fsync(f.fileno())
83
+
84
+ os.replace(temp_path, path)
85
+
86
+ except Exception:
87
+ with contextlib.suppress(OSError):
88
+ if os.path.exists(temp_path):
89
+ os.remove(temp_path)
90
+ raise
91
+
92
+
93
+ def read[T: (dict, list, str)](
94
+ path_from_root: str | Path,
95
+ expected_type: type[T] = str,
96
+ ) -> T:
97
+ """Reads and parses a file. Returns a default empty instance ({}, [], "") if missing."""
98
+ path = _resolve_path(path_from_root)
99
+
100
+ try:
101
+ text = path.read_text(encoding="utf-8")
102
+ except FileNotFoundError:
103
+ return expected_type()
104
+
105
+ if expected_type in (dict, list):
106
+ try:
107
+ data = json.loads(text)
108
+
109
+ if not isinstance(data, expected_type):
110
+ raise TypeError(f"Type mismatch in {path}: Expected {expected_type.__name__}, got {type(data).__name__}")
111
+
112
+ except (json.JSONDecodeError, TypeError) as e:
113
+ logger.warning(f"Error reading or parsing {path}: {e}")
114
+ console.print(f" [yellow]⚠ Warning: Could not parse {path.name} (corrupted). Using default empty state.[/yellow]")
115
+ return expected_type()
116
+
117
+ return data
118
+
119
+ return text # type: ignore
120
+
121
+
122
+ def delete(path_from_root: str | Path):
123
+ """Safely unlinks a file if it exists."""
124
+ _resolve_path(path_from_root).unlink(missing_ok=True)
@@ -0,0 +1,123 @@
1
+ import json
2
+ import os
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+ from pydantic.aliases import AliasChoices
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from .paths import CONFIG_PATH, read
10
+
11
+
12
+ class ProfileConfig(BaseModel):
13
+ target_roles: str = ""
14
+
15
+
16
+ class FiltersConfig(BaseModel):
17
+ title_include: list[str] = []
18
+ title_exclude: list[str] = []
19
+ stack_keywords: list[str] = []
20
+ min_stack_hits: int = 0
21
+ company_exclude: list[str] = []
22
+ company_priority: list[str] = []
23
+ require_remote: bool = True
24
+ remote_keywords: list[str] = ["remote", "wfh", "work from home", "anywhere", "telecommute", "fully remote", "100% remote"]
25
+ location_exclude_terms: list[str] = ["us only", "us-only", "united states only", "canada only", "uk only", "must reside in"]
26
+ location_allow_terms: list[str] = ["worldwide", "emea", "anywhere", "global"]
27
+ location_require_terms: list[str] = []
28
+ max_age_days: int = 14
29
+
30
+
31
+ class LimitsConfig(BaseModel):
32
+ max_candidates_per_run: int = 30
33
+ description_max_chars: int = 7000
34
+ seen_retention_days: int = 90
35
+
36
+
37
+ class ScoringWeight(BaseModel):
38
+ name: str
39
+ weight: int
40
+ what: str
41
+
42
+
43
+ class EvaluatingConfig(BaseModel):
44
+ score_threshold: int = 65
45
+ scoring_weights: list[ScoringWeight] = Field(default_factory=list)
46
+
47
+
48
+ class LLMConfig(BaseModel):
49
+ provider: str = "openai"
50
+ base_url: str | None = None
51
+ model: str | None = None
52
+ request_delay: int = 5
53
+ temperature: float = 0.0
54
+ timeout: int = 600
55
+
56
+
57
+ class TelegramConfig(BaseModel):
58
+ link_preview: bool = False
59
+ notify_on_empty: bool = True
60
+
61
+
62
+ class WorkersConfig(BaseModel):
63
+ evaluation_workers: int = 1
64
+ fetch_workers: int = 8
65
+ discovery_workers: int = 8
66
+ pruning_workers: int = 8
67
+
68
+
69
+ class Settings(BaseSettings):
70
+ telegram_bot_token: str = Field(default="", validation_alias=AliasChoices("TELEGRAM_BOT_TOKEN", "telegram_bot_token"))
71
+ telegram_chat_id: str = Field(default="", validation_alias=AliasChoices("TELEGRAM_CHAT_ID", "telegram_chat_id"))
72
+ llm_api_key: str = Field(default="", validation_alias=AliasChoices("LLM_API_KEY", "llm_api_key"))
73
+
74
+ profile: ProfileConfig = Field(default_factory=ProfileConfig)
75
+ boards: dict[str, Any] = Field(default_factory=dict)
76
+ companies: dict[str, list[str]] = Field(default_factory=dict)
77
+ filters: FiltersConfig = Field(default_factory=FiltersConfig)
78
+ limits: LimitsConfig = Field(default_factory=LimitsConfig)
79
+ evaluating: EvaluatingConfig = Field(default_factory=EvaluatingConfig)
80
+ llm: LLMConfig = Field(default_factory=LLMConfig)
81
+ telegram: TelegramConfig = Field(default_factory=TelegramConfig)
82
+ workers: WorkersConfig = Field(default_factory=WorkersConfig)
83
+
84
+ cv_text: str = ""
85
+ preferences_text: str = ""
86
+
87
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
88
+
89
+
90
+ def load_settings() -> Settings:
91
+ """Loads settings from files or environment variable fallbacks, returning a Settings object."""
92
+ config_path = CONFIG_PATH / "config.json"
93
+
94
+ if config_path.exists():
95
+ config_data = read(config_path, dict)
96
+ else:
97
+ config_str = os.getenv("CONFIG_JSON")
98
+ if config_str:
99
+ try:
100
+ config_data = json.loads(config_str)
101
+ except Exception:
102
+ config_data = {}
103
+ else:
104
+ config_data = {}
105
+
106
+ # Remove "_help"
107
+ config_data.pop("_help", None)
108
+
109
+ # Load Markdown with env fallbacks
110
+ cv_path = CONFIG_PATH / "cv.md"
111
+ cv_text = read(cv_path) if cv_path.exists() else os.getenv("CV") or ""
112
+
113
+ pref_path = CONFIG_PATH / "preferences.md"
114
+ preferences_text = read(pref_path) if pref_path.exists() else os.getenv("PREFERENCES") or ""
115
+
116
+ return Settings(
117
+ cv_text=cv_text,
118
+ preferences_text=preferences_text,
119
+ **config_data,
120
+ )
121
+
122
+
123
+ settings = load_settings()
@@ -0,0 +1,33 @@
1
+ import html
2
+ import re
3
+ from datetime import datetime
4
+
5
+
6
+ def clean_html(text: str | None) -> str:
7
+ """Cleans HTML content by removing tags, unescaping entities, and normalizing whitespace."""
8
+ if not text:
9
+ return ""
10
+
11
+ # 1. Remove script and style blocks with their contents
12
+ text = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", text, flags=re.DOTALL | re.IGNORECASE)
13
+
14
+ # 2. Strip HTML tags
15
+ text = re.sub(r"<[^>]+>", " ", text)
16
+
17
+ # 3. Unescape entities after tags are gone
18
+ text = html.unescape(text)
19
+
20
+ # 4. Normalize whitespace
21
+ return re.sub(r"\s+", " ", text).strip()
22
+
23
+
24
+ def parse_date(date_str: str | None) -> str:
25
+ """Parses an ISO date string into YYYY-MM-DD format."""
26
+ if not date_str:
27
+ return ""
28
+
29
+ try:
30
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
31
+ return dt.strftime("%Y-%m-%d")
32
+ except ValueError:
33
+ return date_str.strip()
jobhunter/main.py ADDED
@@ -0,0 +1,101 @@
1
+ import logging
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from jobhunter.core.logger import console, setup_logging
9
+ from jobhunter.pipeline import evaluate, fetch, send
10
+ from jobhunter.tools import discover, init, prompt, prune
11
+
12
+ app = typer.Typer(
13
+ help="JobHunter - Automated job scraping and filtering pipeline.",
14
+ no_args_is_help=True,
15
+ context_settings={"help_option_names": ["-h", "--help"]},
16
+ )
17
+
18
+ # Reusable CLI Options with enhanced documentation
19
+ SourceOpt = Annotated[str | None, typer.Option("--source", "-s", help="Specific ATS source or aggregator to fetch from (e.g., 'greenhouse', 'remotive').")]
20
+ NoAggOpt = Annotated[bool, typer.Option("--no-aggregators", help="Skip aggregator boards and only scrape direct company ATS boards.")]
21
+ IgnoreSeenOpt = Annotated[bool, typer.Option("--ignore-seen", help="Ignore the 'seen' cache and fetch all jobs regardless of previous processing.")]
22
+ LimitOpt = Annotated[int | None, typer.Option("--limit", "-l", help="Override the maximum number of candidates to process (bypasses config.json limit).")]
23
+ WriteOpt = Annotated[bool, typer.Option("--write", "-w", help="Persist discovered/pruned companies by writing directly to config.json.")]
24
+ JobUidOpt = Annotated[str | None, typer.Option("--job", "-j", help="Evaluate only a specific candidate by their exact Job UID.")]
25
+ DryRunOpt = Annotated[bool, typer.Option("--dry-run", help="Dispatch alerts without modifying the local 'seen' cache or state files.")]
26
+
27
+
28
+ @app.command("init", help="Scaffolds a new JobHunter workspace by downloading default templates and docs.")
29
+ def init_workspace(dir: Annotated[Path, typer.Argument(help="Target directory to initialize the JobHunter workspace.")] = Path(".")):
30
+ init.main(dir=dir)
31
+
32
+
33
+ @app.command("run", help="Run the full pipeline (fetch -> evaluate -> send).")
34
+ def run_all(
35
+ source: SourceOpt = None,
36
+ no_aggregators: NoAggOpt = False,
37
+ ignore_seen: IgnoreSeenOpt = False,
38
+ limit: LimitOpt = None,
39
+ dry_run: DryRunOpt = False,
40
+ ):
41
+ fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
42
+ evaluate.main(limit=limit)
43
+ send.main(dry_run=dry_run)
44
+
45
+
46
+ @app.command("fetch", help="Scrape and filter jobs from configured boards.")
47
+ def fetch_jobs(
48
+ source: SourceOpt = None,
49
+ no_aggregators: NoAggOpt = False,
50
+ ignore_seen: IgnoreSeenOpt = False,
51
+ limit: LimitOpt = None,
52
+ ):
53
+ fetch.main(source_name=source, no_aggregators=no_aggregators, ignore_seen=ignore_seen, limit=limit)
54
+
55
+
56
+ @app.command("evaluate", help="Score and evaluate pending candidates using AI.")
57
+ def evaluate_jobs(
58
+ job: JobUidOpt = None,
59
+ limit: LimitOpt = None,
60
+ ):
61
+ evaluate.main(job_uid=job, limit=limit)
62
+
63
+
64
+ @app.command("send", help="Dispatch passing job evaluations to Telegram.")
65
+ def send_jobs(dry_run: DryRunOpt = False):
66
+ send.main(dry_run=dry_run)
67
+
68
+
69
+ @app.command("discover", help="Discover and validate new company boards via search engines.")
70
+ def discover_boards(
71
+ write: WriteOpt = False,
72
+ source: SourceOpt = None,
73
+ titles: Annotated[str | None, typer.Option("--titles", "-t", help="Comma-separated list of job titles to search for (e.g., 'engineer,developer').")] = None,
74
+ ):
75
+ discover.main(write_flag=write, source_name=source, titles_override=titles)
76
+
77
+
78
+ @app.command("prune", help="Validate and prune broken company endpoints from config.json.")
79
+ def prune_companies(write: WriteOpt = False, source: SourceOpt = None):
80
+ prune.main(write_flag=write, source_name=source)
81
+
82
+
83
+ @app.command("prompt", help="Prints the compiled AI evaluation system prompt for review.")
84
+ def show_prompt():
85
+ prompt.main()
86
+
87
+
88
+ def main():
89
+ """Entry point wrapper to catch global exceptions."""
90
+ setup_logging(level="INFO")
91
+
92
+ try:
93
+ app()
94
+ except KeyboardInterrupt:
95
+ console.print("\n[yellow]Aborted by user.[/yellow]")
96
+ sys.exit(1)
97
+ except Exception as e:
98
+ logger = logging.getLogger(__name__)
99
+ logger.exception(f"Fatal error: {e}")
100
+ console.print(f"[bold red]Fatal error: {e}[/bold red]")
101
+ sys.exit(1)