Skip to content
Open
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# Copy to ~/.applypilot/.env and fill in your values.

# LLM Provider (pick one)
GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest)
# OPENAI_API_KEY= # OpenAI (GPT-4o-mini)
# LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (llama.cpp, Ollama)
# LLM_MODEL= # Override model name
GEMINI_API_KEY= # Gemini (recommended, cheapest)
# OPENAI_API_KEY= # OpenAI
# ANTHROPIC_API_KEY= # Anthropic Claude
# LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (OpenAI-compatible: llama.cpp, Ollama, vLLM)
# LLM_MODEL= # Override model name (provider-specific)

# Auto-Apply (optional)
CAPSOLVER_API_KEY= # For CAPTCHA solving during auto-apply
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ applypilot apply --dry-run # fill forms without submitting
## Two Paths

### Full Pipeline (recommended)
**Requires:** Python 3.11+, Node.js (for npx), Gemini API key (free), Claude Code CLI, Chrome
**Requires:** Python 3.11+, Node.js (for npx), an LLM key (Gemini/OpenAI/Claude) or `LLM_URL`, Claude Code CLI, Chrome

Runs all 6 stages, from job discovery to autonomous application submission. This is the full power of ApplyPilot.

### Discovery + Tailoring Only
**Requires:** Python 3.11+, Gemini API key (free)
**Requires:** Python 3.11+, an LLM key (Gemini/OpenAI/Claude) or `LLM_URL`

Runs stages 1-5: discovers jobs, scores them, tailors your resume, generates cover letters. You submit applications manually with the AI-prepared materials.

Expand Down Expand Up @@ -88,18 +88,25 @@ Each stage is independent. Run them all or pick what you need.
|-----------|-------------|---------|
| Python 3.11+ | Everything | Core runtime |
| Node.js 18+ | Auto-apply | Needed for `npx` to run Playwright MCP server |
| Gemini API key | Scoring, tailoring, cover letters | Free tier (15 RPM / 1M tokens/day) is enough |
| LLM credentials or local endpoint | Scoring, tailoring, cover letters | Set one of `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `LLM_URL`. Optional: set `LLM_MODEL` (for example `gemini/gemini-3.0-flash`) to override the default model. |
| Chrome/Chromium | Auto-apply | Auto-detected on most systems |
| Claude Code CLI | Auto-apply | Install from [claude.ai/code](https://claude.ai/code) |

**Gemini API key is free.** Get one at [aistudio.google.com](https://aistudio.google.com). OpenAI and local models (Ollama/llama.cpp) are also supported.
**Gemini API key is free.** Get one at [aistudio.google.com](https://aistudio.google.com). OpenAI, Claude, and local models (Ollama/llama.cpp/vLLM) are also supported.
ApplyPilot uses Gemini through LiteLLM's native Gemini provider path, and Gemini API version routing is owned by LiteLLM.

### Optional

| Component | What It Does |
|-----------|-------------|
| CapSolver API key | Solves CAPTCHAs during auto-apply (hCaptcha, reCAPTCHA, Turnstile, FunCaptcha). Without it, CAPTCHA-blocked applications just fail gracefully |

### Gemini Smoke Check (optional)

```bash
GEMINI_API_KEY=your_key_here pytest -m smoke -q tests/test_gemini_smoke.py
```

> **Note:** python-jobspy is installed separately with `--no-deps` because it pins an exact numpy version in its metadata that conflicts with pip's resolver. It works fine with modern numpy at runtime.

---
Expand All @@ -115,7 +122,7 @@ Your personal data in one structured file: contact info, work authorization, com
Job search queries, target titles, locations, boards. Run multiple searches with different parameters.

### `.env`
API keys and runtime config: `GEMINI_API_KEY`, `LLM_MODEL`, `CAPSOLVER_API_KEY` (optional).
API keys and runtime config: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_URL`, optional `LLM_MODEL`, optional `LLM_API_KEY`, and `CAPSOLVER_API_KEY`.

### Package configs (shipped with ApplyPilot)
- `config/employers.yaml` - Workday employer registry (48 preconfigured)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ classifiers = [
dependencies = [
"typer>=0.9.0",
"rich>=13.0",
"litellm~=1.63.0",
"httpx>=0.24",
"beautifulsoup4>=4.12",
"playwright>=1.40",
Expand Down
2 changes: 1 addition & 1 deletion src/applypilot/apply/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
import threading
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

Expand Down
4 changes: 2 additions & 2 deletions src/applypilot/apply/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from applypilot import config
from applypilot.database import get_connection
from applypilot.apply import chrome, dashboard, prompt as prompt_mod
from applypilot.apply import prompt as prompt_mod
from applypilot.apply.chrome import (
launch_chrome, cleanup_worker, kill_all_chrome,
reset_worker_dir, cleanup_on_exit, _kill_process_tree,
Expand Down Expand Up @@ -125,7 +125,7 @@ def acquire_job(target_url: str | None = None, min_score: int = 7,
params.extend(blocked_sites)
url_clauses = ""
if blocked_patterns:
url_clauses = " ".join(f"AND url NOT LIKE ?" for _ in blocked_patterns)
url_clauses = " ".join("AND url NOT LIKE ?" for _ in blocked_patterns)
params.extend(blocked_patterns)
row = conn.execute(f"""
SELECT url, title, site, application_url, tailored_resume_path,
Expand Down
77 changes: 54 additions & 23 deletions src/applypilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import os
from typing import Optional

import typer
Expand All @@ -11,11 +12,37 @@

from applypilot import __version__

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%H:%M:%S",
)

def _configure_logging() -> None:
"""Set consistent logging output for CLI runs."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%H:%M:%S",
)

# Keep LiteLLM internals quiet by default; warnings/errors still surface.
for name in ("LiteLLM", "litellm"):
noisy = logging.getLogger(name)
noisy.handlers.clear()
noisy.setLevel(logging.WARNING)
noisy.propagate = True

# Route verbose tailor/cover loggers to a file instead of the terminal.
# Per-attempt warnings and validation details are useful for debugging
# but too noisy for normal CLI output.
from applypilot.config import LOG_DIR
LOG_DIR.mkdir(parents=True, exist_ok=True)
_file_fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S")
for logger_name in ("applypilot.scoring.tailor", "applypilot.scoring.cover_letter"):
file_log = logging.getLogger(logger_name)
file_log.propagate = False # suppress terminal output
fh = logging.FileHandler(LOG_DIR / f"{logger_name.split('.')[-1]}.log", encoding="utf-8")
fh.setFormatter(_file_fmt)
file_log.addHandler(fh)


_configure_logging()

app = typer.Typer(
name="applypilot",
Expand Down Expand Up @@ -211,7 +238,7 @@ def apply(
raise typer.Exit(code=1)

if gen:
from applypilot.apply.launcher import gen_prompt, BASE_CDP_PORT
from applypilot.apply.launcher import gen_prompt
target = url or ""
if not target:
console.print("[red]--gen requires --url to specify which job.[/red]")
Expand All @@ -222,7 +249,7 @@ def apply(
raise typer.Exit(code=1)
mcp_path = _profile_path.parent / ".mcp-apply-0.json"
console.print(f"[green]Wrote prompt to:[/green] {prompt_file}")
console.print(f"\n[bold]Run manually:[/bold]")
console.print("\n[bold]Run manually:[/bold]")
console.print(
f" claude --model {model} -p "
f"--mcp-config {mcp_path} "
Expand Down Expand Up @@ -338,7 +365,7 @@ def doctor() -> None:
import shutil
from applypilot.config import (
load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH,
SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path,
SEARCH_CONFIG_PATH, get_chrome_path,
)

load_env()
Expand Down Expand Up @@ -379,21 +406,25 @@ def doctor() -> None:
"pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex"))

# --- Tier 2 checks ---
import os
has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
has_local = bool(os.environ.get("LLM_URL"))
if has_gemini:
model = os.environ.get("LLM_MODEL", "gemini-2.0-flash")
results.append(("LLM API key", ok_mark, f"Gemini ({model})"))
elif has_openai:
model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
results.append(("LLM API key", ok_mark, f"OpenAI ({model})"))
elif has_local:
results.append(("LLM API key", ok_mark, f"Local: {os.environ.get('LLM_URL')}"))
else:
results.append(("LLM API key", fail_mark,
"Set GEMINI_API_KEY in ~/.applypilot/.env (run 'applypilot init')"))
from applypilot.llm import resolve_llm_config

try:
llm_cfg = resolve_llm_config()
if llm_cfg.api_base:
results.append(("LLM API key", ok_mark, f"Custom endpoint: {llm_cfg.api_base} ({llm_cfg.model})"))
else:
label = {
"gemini": "Gemini",
"openai": "OpenAI",
"anthropic": "Anthropic",
}.get(llm_cfg.provider, llm_cfg.provider)
results.append(("LLM API key", ok_mark, f"{label} ({llm_cfg.model})"))
except RuntimeError:
results.append(
("LLM API key", fail_mark,
"Set one of GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, LLM_URL, "
"or set LLM_MODEL with LLM_API_KEY in ~/.applypilot/.env")
)

# --- Tier 3 checks ---
# Claude Code CLI
Expand Down
24 changes: 21 additions & 3 deletions src/applypilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,14 @@ def get_tier() -> int:
"""
load_env()

has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
has_provider_source = any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_URL")
)
has_model_and_generic_key = bool((os.environ.get("LLM_MODEL") or "").strip()) and bool(
(os.environ.get("LLM_API_KEY") or "").strip()
)
has_llm = has_provider_source or has_model_and_generic_key
if not has_llm:
return 1

Expand Down Expand Up @@ -238,8 +245,19 @@ def check_tier(required: int, feature: str) -> None:
_console = Console(stderr=True)

missing: list[str] = []
if required >= 2 and not any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")):
missing.append("LLM API key — run [bold]applypilot init[/bold] or set GEMINI_API_KEY")
has_provider_source = any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_URL")
)
has_model_and_generic_key = bool((os.environ.get("LLM_MODEL") or "").strip()) and bool(
(os.environ.get("LLM_API_KEY") or "").strip()
)
if required >= 2 and not (has_provider_source or has_model_and_generic_key):
missing.append(
"LLM config — run [bold]applypilot init[/bold] or set one of "
"GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / LLM_URL "
"(or set LLM_MODEL with LLM_API_KEY)"
)
if required >= 3:
if not shutil.which("claude"):
missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]")
Expand Down
2 changes: 1 addition & 1 deletion src/applypilot/discovery/jobspy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from jobspy import scrape_jobs

from applypilot import config
from applypilot.database import get_connection, init_db, store_jobs
from applypilot.database import get_connection, init_db

log = logging.getLogger(__name__)

Expand Down
10 changes: 4 additions & 6 deletions src/applypilot/discovery/smartextract.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,15 @@
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote_plus

import httpx
import yaml
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

from applypilot import config
from applypilot.config import CONFIG_DIR
from applypilot.database import get_connection, init_db, store_jobs, get_stats
from applypilot.database import init_db, get_stats
from applypilot.llm import get_client

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -393,7 +391,7 @@ def judge_api_responses(api_responses: list[dict]) -> list[dict]:
)

try:
raw = client.ask(prompt, temperature=0.0, max_tokens=1024)
raw = client.chat([{"role": "user", "content": prompt}], max_output_tokens=1024)
verdict = extract_json(raw)
is_relevant = verdict.get("relevant", False)
reason = verdict.get("reason", "?")
Expand Down Expand Up @@ -424,7 +422,7 @@ def format_strategy_briefing(intel: dict) -> str:
sections.append(f"\nJSON-LD: {len(job_postings)} JobPosting entries found (usable!)")
sections.append(f"First JobPosting:\n{json.dumps(job_postings[0], indent=2)[:3000]}")
else:
sections.append(f"\nJSON-LD: NO JobPosting entries (json_ld strategy will NOT work)")
sections.append("\nJSON-LD: NO JobPosting entries (json_ld strategy will NOT work)")
if other:
types = [j.get("@type", "?") if isinstance(j, dict) else "?" for j in other]
sections.append(f"Other JSON-LD types (NOT job data): {types}")
Expand Down Expand Up @@ -642,7 +640,7 @@ def ask_llm(prompt: str) -> tuple[str, float, dict]:
"""Send prompt to LLM. Returns (response_text, seconds_taken, metadata)."""
client = get_client()
t0 = time.time()
text = client.ask(prompt, temperature=0.0, max_tokens=4096)
text = client.chat([{"role": "user", "content": prompt}], max_output_tokens=4096)
elapsed = time.time() - t0
meta = {
"finish_reason": "stop",
Expand Down
6 changes: 2 additions & 4 deletions src/applypilot/enrichment/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

from applypilot import config
from applypilot.config import DB_PATH
from applypilot.database import get_connection, init_db, ensure_columns
from applypilot.database import init_db
from applypilot.llm import get_client

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -465,7 +463,7 @@ def extract_with_llm(page, url: str) -> dict:
try:
client = get_client()
t0 = time.time()
raw = client.ask(prompt, temperature=0.0, max_tokens=4096)
raw = client.chat([{"role": "user", "content": prompt}], max_output_tokens=4096)
elapsed = time.time() - t0
log.info("LLM: %d chars in, %.1fs", len(prompt), elapsed)

Expand Down
Loading