diff --git a/.env.example b/.env.example index df7cc386b..97ec57aae 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,12 @@ # Copy to ~/.applypilot/.env and fill in your values. # LLM Provider (pick one) +# LLM_PROVIDER=gemini # Explicit provider selection (gemini/openai/local/codex) 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 +# For Codex: set LLM_PROVIDER=codex and run `codex login` # Auto-Apply (optional) CAPSOLVER_API_KEY= # For CAPTCHA solving during auto-apply diff --git a/CHANGELOG.md b/CHANGELOG.md index 5682b2701..69cbaccd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to ApplyPilot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Codex provider support** - added Codex as an LLM provider option for scoring, tailoring, + cover letters, and apply-time automation + ## [0.2.0] - 2026-02-17 ### Added diff --git a/src/applypilot/agent.py b/src/applypilot/agent.py new file mode 100644 index 000000000..bd4fda55a --- /dev/null +++ b/src/applypilot/agent.py @@ -0,0 +1,54 @@ +"""Helpers for selecting and launching the autonomous agent backend.""" + +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass + +from applypilot import config + + +@dataclass(frozen=True) +class AgentBackend: + """Execution backend for the auto-apply agent.""" + + name: str + binary: str + model: str + + +def get_agent_backend(preferred: str | None = None, model: str | None = None) -> AgentBackend: + """Resolve the backend and default model to use.""" + backend = (preferred or config.get_agent_backend()).strip().lower() + if backend not in config.AGENT_BACKENDS: + backend = config.get_agent_backend() + + if backend == "codex": + binary = shutil.which("codex") or "codex" + resolved_model = model or "gpt-5.4-mini" + else: + binary = shutil.which("claude") or "claude" + resolved_model = model or "haiku" + + return AgentBackend(name=backend, binary=binary, model=resolved_model) + + +def codex_login_ok() -> tuple[bool, str]: + """Check Codex login status.""" + return config.codex_login_status() + + +def build_playwright_override_args(cdp_port: int) -> list[str]: + """Build `codex exec -c` overrides for the Playwright MCP server.""" + args = [ + "@playwright/mcp@latest", + f"--cdp-endpoint=http://localhost:{cdp_port}", + f"--viewport-size={config.DEFAULTS['viewport']}", + ] + return [ + "-c", + 'mcp_servers.playwright.command="npx"', + "-c", + f"mcp_servers.playwright.args={json.dumps(args)}", + ] diff --git a/src/applypilot/apply/launcher.py b/src/applypilot/apply/launcher.py index 341a11a36..8d4f61670 100644 --- a/src/applypilot/apply/launcher.py +++ b/src/applypilot/apply/launcher.py @@ -1,8 +1,9 @@ -"""Apply orchestration: acquire jobs, spawn Claude Code sessions, track results. +"""Apply orchestration: acquire jobs, spawn agent sessions, track results. This is the main entry point for the apply pipeline. It pulls jobs from -the database, launches Chrome + Claude Code for each one, parses the -result, and updates the database. Supports parallel workers via --workers. +the database, launches Chrome plus the selected agent backend for each one, +parses the result, and updates the database. Supports parallel workers via +--workers. """ import atexit @@ -23,9 +24,10 @@ from rich.console import Console from rich.live import Live +from applypilot.agent import build_playwright_override_args, get_agent_backend 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, @@ -49,7 +51,7 @@ def _load_blocked(): # Thread-safe shutdown coordination _stop_event = threading.Event() -# Track active Claude Code processes for skip (Ctrl+C) handling +# Track active agent processes for skip (Ctrl+C) handling _claude_procs: dict[int, subprocess.Popen] = {} _claude_lock = threading.Lock() @@ -83,6 +85,11 @@ def _make_mcp_config(cdp_port: int) -> dict: } +def _make_codex_overrides(cdp_port: int) -> list[str]: + """Build `codex exec -c` overrides for Playwright MCP.""" + return build_playwright_override_args(cdp_port) + + # --------------------------------------------------------------------------- # Database operations # --------------------------------------------------------------------------- @@ -125,7 +132,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, @@ -211,8 +218,9 @@ def release_lock(url: str) -> None: # --------------------------------------------------------------------------- def gen_prompt(target_url: str, min_score: int = 7, - model: str = "sonnet", worker_id: int = 0) -> Path | None: - """Generate a prompt file and print the Claude CLI command for manual debugging. + model: str = "sonnet", worker_id: int = 0, + agent: str | None = None) -> Path | None: + """Generate a prompt file and print the agent CLI command for manual debugging. Returns: Path to the generated prompt file, or None if no job found. @@ -290,13 +298,101 @@ def reset_failed() -> int: return cursor.rowcount +def build_manual_command( + *, + agent: str | None = None, + model: str = "sonnet", + worker_id: int = 0, + prompt_file: Path, +) -> str: + """Build a copy-paste command for manually running the agent.""" + backend = get_agent_backend(agent, model=model) + port = BASE_CDP_PORT + worker_id + mcp_path = config.APP_DIR / f".mcp-apply-{worker_id}.json" + + if backend.name == "codex": + return ( + f"codex exec --model {backend.model} --dangerously-bypass-approvals-and-sandbox " + f'--cd "{config.APP_DIR}" --skip-git-repo-check ' + f"{' '.join(_make_codex_overrides(port))} " + f'--output-last-message "{prompt_file.with_suffix(".last-message.txt")}" ' + f'< "{prompt_file}"' + ) + + return ( + f"claude --model {backend.model} -p " + f"--mcp-config {mcp_path} " + f"--permission-mode bypassPermissions < {prompt_file}" + ) + + +def _build_agent_command( + backend_name: str, + binary: str, + model: str, + port: int, + worker_id: int, + worker_dir: Path, +) -> tuple[list[str], Path | None]: + """Build the runtime command for the selected backend.""" + if backend_name == "codex": + last_message_path = config.LOG_DIR / ( + f"codex_{datetime.now().strftime('%Y%m%d_%H%M%S')}_w{worker_id}.last-message.txt" + ) + cmd = [ + binary, + "exec", + "--model", + model, + "--dangerously-bypass-approvals-and-sandbox", + "--cd", + str(worker_dir), + "--skip-git-repo-check", + "--output-last-message", + str(last_message_path), + *_make_codex_overrides(port), + "-", + ] + return cmd, last_message_path + + mcp_config_path = config.APP_DIR / f".mcp-apply-{worker_id}.json" + cmd = [ + binary, + "--model", + model, + "-p", + "--mcp-config", + str(mcp_config_path), + "--permission-mode", + "bypassPermissions", + "--no-session-persistence", + "--disallowedTools", + ( + "mcp__gmail__draft_email,mcp__gmail__modify_email," + "mcp__gmail__delete_email,mcp__gmail__download_attachment," + "mcp__gmail__batch_modify_emails,mcp__gmail__batch_delete_emails," + "mcp__gmail__create_label,mcp__gmail__update_label," + "mcp__gmail__delete_label,mcp__gmail__get_or_create_label," + "mcp__gmail__list_email_labels,mcp__gmail__create_filter," + "mcp__gmail__list_filters,mcp__gmail__get_filter," + "mcp__gmail__delete_filter" + ), + "--output-format", + "stream-json", + "--verbose", + "-", + ] + return cmd, None + + # --------------------------------------------------------------------------- # Per-job execution # --------------------------------------------------------------------------- def run_job(job: dict, port: int, worker_id: int = 0, - model: str = "sonnet", dry_run: bool = False) -> tuple[str, int]: - """Spawn a Claude Code session for one job application. + model: str = "sonnet", dry_run: bool = False, + agent: str | None = None) -> tuple[str, int]: + """Spawn an agent session for one job application. Returns: Tuple of (status_string, duration_ms). Status is one of: @@ -317,31 +413,12 @@ def run_job(job: dict, port: int, worker_id: int = 0, dry_run=dry_run, ) - # Write per-worker MCP config - mcp_config_path = config.APP_DIR / f".mcp-apply-{worker_id}.json" - mcp_config_path.write_text(json.dumps(_make_mcp_config(port)), encoding="utf-8") + backend = get_agent_backend(agent, model=model) - # Build claude command - cmd = [ - "claude", - "--model", model, - "-p", - "--mcp-config", str(mcp_config_path), - "--permission-mode", "bypassPermissions", - "--no-session-persistence", - "--disallowedTools", ( - "mcp__gmail__draft_email,mcp__gmail__modify_email," - "mcp__gmail__delete_email,mcp__gmail__download_attachment," - "mcp__gmail__batch_modify_emails,mcp__gmail__batch_delete_emails," - "mcp__gmail__create_label,mcp__gmail__update_label," - "mcp__gmail__delete_label,mcp__gmail__get_or_create_label," - "mcp__gmail__list_email_labels,mcp__gmail__create_filter," - "mcp__gmail__list_filters,mcp__gmail__get_filter," - "mcp__gmail__delete_filter" - ), - "--output-format", "stream-json", - "--verbose", "-", - ] + # Write per-worker MCP config for Claude; Codex receives TOML overrides. + mcp_config_path = config.APP_DIR / f".mcp-apply-{worker_id}.json" + if backend.name == "claude": + mcp_config_path.write_text(json.dumps(_make_mcp_config(port)), encoding="utf-8") env = os.environ.copy() env.pop("CLAUDECODE", None) @@ -349,6 +426,15 @@ def run_job(job: dict, port: int, worker_id: int = 0, worker_dir = reset_worker_dir(worker_id) + cmd, last_message_path = _build_agent_command( + backend.name, + backend.binary, + backend.model, + port, + worker_id, + worker_dir, + ) + update_state(worker_id, status="applying", job_title=job["title"], company=job.get("site", ""), score=job.get("fit_score", 0), start_time=time.time(), actions=0, last_action="starting") @@ -397,7 +483,7 @@ def run_job(job: dict, port: int, worker_id: int = 0, try: msg = json.loads(line) msg_type = msg.get("type") - if msg_type == "assistant": + if backend.name == "claude" and msg_type == "assistant": for block in msg.get("message", {}).get("content", []): bt = block.get("type") if bt == "text": @@ -427,6 +513,15 @@ def run_job(job: dict, port: int, worker_id: int = 0, update_state(worker_id, actions=cur_actions + 1, last_action=desc[:35]) + elif backend.name == "codex" and msg_type == "item.completed": + item = msg.get("item", {}) + if item.get("type") == "agent_message": + text = item.get("text", "") + if text: + text_parts.append(text) + lf.write(text + "\n") + elif msg_type == "turn.completed": + continue elif msg_type == "result": stats = { "input_tokens": msg.get("usage", {}).get("input_tokens", 0), @@ -449,11 +544,14 @@ def run_job(job: dict, port: int, worker_id: int = 0, return "skipped", int((time.time() - start) * 1000) output = "\n".join(text_parts) + if not output and last_message_path and last_message_path.exists(): + output = last_message_path.read_text(encoding="utf-8") elapsed = int(time.time() - start) duration_ms = int((time.time() - start) * 1000) ts = datetime.now().strftime("%Y%m%d_%H%M%S") - job_log = config.LOG_DIR / f"claude_{ts}_w{worker_id}_{job.get('site', 'unknown')[:20]}.txt" + agent_slug = backend.name + job_log = config.LOG_DIR / f"{agent_slug}_{ts}_w{worker_id}_{job.get('site', 'unknown')[:20]}.txt" job_log.write_text(output, encoding="utf-8") if stats: @@ -548,7 +646,8 @@ def _is_permanent_failure(result: str) -> bool: def worker_loop(worker_id: int = 0, limit: int = 1, target_url: str | None = None, min_score: int = 7, headless: bool = False, - model: str = "sonnet", dry_run: bool = False) -> tuple[int, int]: + model: str = "sonnet", dry_run: bool = False, + agent: str | None = None) -> tuple[int, int]: """Run jobs sequentially until limit is reached or queue is empty. Args: @@ -557,7 +656,7 @@ def worker_loop(worker_id: int = 0, limit: int = 1, target_url: Apply to a specific URL. min_score: Minimum fit_score threshold. headless: Run Chrome headless. - model: Claude model name. + model: Agent model name. dry_run: Don't click Submit. Returns: @@ -602,7 +701,8 @@ def worker_loop(worker_id: int = 0, limit: int = 1, chrome_proc = launch_chrome(worker_id, port=port, headless=headless) result, duration_ms = run_job(job, port=port, worker_id=worker_id, - model=model, dry_run=dry_run) + model=model, dry_run=dry_run, + agent=agent) if result == "skipped": release_lock(job["url"]) @@ -653,7 +753,8 @@ def worker_loop(worker_id: int = 0, limit: int = 1, def main(limit: int = 1, target_url: str | None = None, min_score: int = 7, headless: bool = False, model: str = "sonnet", dry_run: bool = False, continuous: bool = False, - poll_interval: int = 60, workers: int = 1) -> None: + poll_interval: int = 60, workers: int = 1, + agent: str | None = None) -> None: """Launch the apply pipeline. Args: @@ -661,11 +762,12 @@ def main(limit: int = 1, target_url: str | None = None, target_url: Apply to a specific URL. min_score: Minimum fit_score threshold. headless: Run Chrome in headless mode. - model: Claude model name. + model: Agent model name. dry_run: Don't click Submit. continuous: Run forever, polling for new jobs. poll_interval: Seconds between DB polls when queue is empty. workers: Number of parallel workers (default 1). + agent: Agent backend override. """ global POLL_INTERVAL POLL_INTERVAL = poll_interval @@ -737,6 +839,7 @@ def _refresh(): headless=headless, model=model, dry_run=dry_run, + agent=agent, ) else: # Multi-worker — distribute limit across workers @@ -760,6 +863,7 @@ def _refresh(): headless=headless, model=model, dry_run=dry_run, + agent=agent, ): i for i in range(workers) } diff --git a/src/applypilot/apply/prompt.py b/src/applypilot/apply/prompt.py index 37c3790a1..a8d0eec2f 100644 --- a/src/applypilot/apply/prompt.py +++ b/src/applypilot/apply/prompt.py @@ -1,6 +1,6 @@ """Prompt builder for the autonomous job application agent. -Constructs the full instruction prompt that tells Claude Code / the AI agent +Constructs the full instruction prompt that tells the AI agent how to fill out a job application form using Playwright MCP tools. All personal data is loaded from the user's profile -- nothing is hardcoded. """ @@ -196,7 +196,6 @@ def _build_hard_rules(profile: dict) -> str: display_name = f"{preferred_name} {preferred_last}".strip() if preferred_last else preferred_name # Build work auth rule dynamically - auth_info = work_auth.get("legally_authorized_to_work", "") sponsorship = work_auth.get("require_sponsorship", "") permit_type = work_auth.get("work_permit_type", "") diff --git a/src/applypilot/cli.py b/src/applypilot/cli.py index 6c8be9128..2b9ffc5cc 100644 --- a/src/applypilot/cli.py +++ b/src/applypilot/cli.py @@ -10,6 +10,7 @@ from rich.table import Table from applypilot import __version__ +from applypilot.agent import get_agent_backend, codex_login_ok logging.basicConfig( level=logging.INFO, @@ -147,7 +148,8 @@ def apply( limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max applications to submit."), workers: int = typer.Option(1, "--workers", "-w", help="Number of parallel browser workers."), min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for job selection."), - model: str = typer.Option("haiku", "--model", "-m", help="Claude model name."), + model: Optional[str] = typer.Option(None, "--model", "-m", help="Agent model name. Defaults depend on backend."), + agent: str = typer.Option("auto", "--agent", help="Auto-apply backend: auto, claude, or codex."), continuous: bool = typer.Option(False, "--continuous", "-c", help="Run forever, polling for new jobs."), dry_run: bool = typer.Option(False, "--dry-run", help="Preview actions without submitting."), headless: bool = typer.Option(False, "--headless", help="Run browsers in headless mode."), @@ -186,7 +188,12 @@ def apply( # --- Full apply mode --- - # Check 1: Tier 3 required (Claude Code CLI + Chrome) + import os + + backend = get_agent_backend(agent if agent != "auto" else None, model=model) + os.environ["APPLYPILOT_AGENT"] = backend.name + + # Check 1: Tier 3 required (agent CLI + Chrome) check_tier(3, "auto-apply") # Check 2: Profile exists @@ -211,33 +218,33 @@ 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, build_manual_command target = url or "" if not target: console.print("[red]--gen requires --url to specify which job.[/red]") raise typer.Exit(code=1) - prompt_file = gen_prompt(target, min_score=min_score, model=model) + prompt_file = gen_prompt(target, min_score=min_score, model=backend.model, agent=backend.name) if not prompt_file: console.print("[red]No matching job found for that URL.[/red]") 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( - f" claude --model {model} -p " - f"--mcp-config {mcp_path} " - f"--permission-mode bypassPermissions < {prompt_file}" - ) + console.print("\n[bold]Run manually:[/bold]") + console.print(f" {build_manual_command(agent=backend.name, model=backend.model, worker_id=0, prompt_file=prompt_file)}") return from applypilot.apply.launcher import main as apply_main + if backend.name == "codex": + ok, detail = codex_login_ok() + if not ok: + console.print(f"[yellow]Codex login status:[/yellow] {detail}") effective_limit = limit if limit is not None else (0 if continuous else 1) console.print("\n[bold blue]Launching Auto-Apply[/bold blue]") console.print(f" Limit: {'unlimited' if continuous else effective_limit}") console.print(f" Workers: {workers}") - console.print(f" Model: {model}") + console.print(f" Backend: {backend.name}") + console.print(f" Model: {backend.model}") console.print(f" Headless: {headless}") console.print(f" Dry run: {dry_run}") if url: @@ -249,10 +256,11 @@ def apply( target_url=url, min_score=min_score, headless=headless, - model=model, + model=backend.model, dry_run=dry_run, continuous=continuous, workers=workers, + agent=backend.name, ) @@ -337,8 +345,9 @@ def doctor() -> None: """Check your setup and diagnose missing requirements.""" import shutil from applypilot.config import ( - load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH, - SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path, + load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH, RESUME_TEX_PATH, + SEARCH_CONFIG_PATH, get_chrome_path, get_agent_backend, get_llm_provider, + codex_login_status, ) load_env() @@ -359,11 +368,16 @@ def doctor() -> None: # Resume if RESUME_PATH.exists(): results.append(("resume.txt", ok_mark, str(RESUME_PATH))) + elif RESUME_TEX_PATH.exists(): + results.append(("resume.txt", warn_mark, "Only LaTeX source found — text is derived automatically")) elif RESUME_PDF_PATH.exists(): results.append(("resume.txt", warn_mark, "Only PDF found — plain-text needed for AI stages")) else: results.append(("resume.txt", fail_mark, "Run 'applypilot init' to add your resume")) + if RESUME_TEX_PATH.exists(): + results.append(("resume.tex", ok_mark, str(RESUME_TEX_PATH))) + # Search config if SEARCH_CONFIG_PATH.exists(): results.append(("searches.yaml", ok_mark, str(SEARCH_CONFIG_PATH))) @@ -380,29 +394,49 @@ def doctor() -> None: # --- 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')}")) + provider = get_llm_provider() + model = os.environ.get("LLM_MODEL", "") + if provider == "gemini": + results.append(("LLM provider", ok_mark, f"Gemini ({model or 'gemini-2.0-flash'})")) + elif provider == "openai": + results.append(("LLM provider", ok_mark, f"OpenAI ({model or 'gpt-4o-mini'})")) + elif provider == "local": + results.append(("LLM provider", ok_mark, f"Local: {os.environ.get('LLM_URL')}")) + elif provider == "codex": + ok, detail = codex_login_status() + if ok: + results.append(("LLM provider", ok_mark, f"Codex ({model or 'gpt-5.4-mini'})")) + results.append(("Codex login", ok_mark, detail)) + else: + results.append(("LLM provider", fail_mark, "Codex selected but not logged in")) + results.append(("Codex login", fail_mark, detail)) else: - results.append(("LLM API key", fail_mark, - "Set GEMINI_API_KEY in ~/.applypilot/.env (run 'applypilot init')")) + results.append(("LLM provider", fail_mark, + "Set LLM_PROVIDER in ~/.applypilot/.env (run 'applypilot init')")) # --- Tier 3 checks --- - # Claude Code CLI - claude_bin = shutil.which("claude") - if claude_bin: - results.append(("Claude Code CLI", ok_mark, claude_bin)) + backend = get_agent_backend() + results.append(("Agent backend", ok_mark, backend)) + if backend == "codex": + from applypilot.config import codex_login_status + codex_bin = shutil.which("codex") + if codex_bin: + ok, detail = codex_login_status() + if ok: + results.append(("Codex CLI", ok_mark, codex_bin)) + results.append(("Codex login", ok_mark, detail)) + else: + results.append(("Codex CLI", ok_mark, codex_bin)) + results.append(("Codex login", fail_mark, detail)) + else: + results.append(("Codex CLI", fail_mark, "Install Codex from https://github.com/openai/codex")) else: - results.append(("Claude Code CLI", fail_mark, - "Install from https://claude.ai/code (needed for auto-apply)")) + claude_bin = shutil.which("claude") + if claude_bin: + results.append(("Claude Code CLI", ok_mark, claude_bin)) + else: + results.append(("Claude Code CLI", fail_mark, + "Install from https://claude.ai/code (needed for auto-apply)")) # Chrome try: @@ -445,10 +479,10 @@ def doctor() -> None: console.print(f"[bold]Current tier: Tier {tier} — {TIER_LABELS[tier]}[/bold]") if tier == 1: - console.print("[dim] → Tier 2 unlocks: scoring, tailoring, cover letters (needs LLM API key)[/dim]") - console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") + console.print("[dim] → Tier 2 unlocks: scoring, tailoring, cover letters (needs an LLM provider)[/dim]") + console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI or Codex + Chrome + Node.js)[/dim]") elif tier == 2: - console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") + console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI or Codex + Chrome + Node.js)[/dim]") console.print() diff --git a/src/applypilot/config.py b/src/applypilot/config.py index 8c3978073..5f5f837d0 100644 --- a/src/applypilot/config.py +++ b/src/applypilot/config.py @@ -13,6 +13,7 @@ PROFILE_PATH = APP_DIR / "profile.json" RESUME_PATH = APP_DIR / "resume.txt" RESUME_PDF_PATH = APP_DIR / "resume.pdf" +RESUME_TEX_PATH = APP_DIR / "resume.tex" SEARCH_CONFIG_PATH = APP_DIR / "searches.yaml" ENV_PATH = APP_DIR / ".env" @@ -196,28 +197,109 @@ def load_env(): 3: ["apply"], } +AGENT_BACKENDS = ("claude", "codex") +LLM_PROVIDERS = ("gemini", "openai", "local", "codex") + + +def get_llm_provider() -> str: + """Return the configured provider for scoring/tailoring/cover letters.""" + load_env() + + configured = os.environ.get("LLM_PROVIDER", "").strip().lower() + if configured in LLM_PROVIDERS: + return configured + + if os.environ.get("GEMINI_API_KEY") and not os.environ.get("LLM_URL"): + return "gemini" + if os.environ.get("OPENAI_API_KEY") and not os.environ.get("LLM_URL"): + return "openai" + if os.environ.get("LLM_URL"): + return "local" + + return "" + + +def get_agent_backend() -> str: + """Return the configured auto-apply agent backend. + + The backend can be pinned via APPLYPILOT_AGENT. If unset, Claude is + preferred when available; otherwise Codex is used when installed. + """ + load_env() + + configured = os.environ.get("APPLYPILOT_AGENT", "").strip().lower() + if configured in AGENT_BACKENDS: + return configured + + if shutil.which("claude"): + return "claude" + if shutil.which("codex"): + return "codex" + return "claude" + + +def codex_login_status() -> tuple[bool, str]: + """Check whether Codex is logged in.""" + import subprocess + + codex_bin = shutil.which("codex") + if not codex_bin: + return False, "Codex CLI not found" + + try: + proc = subprocess.run( + [codex_bin, "login", "status"], + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + return False, str(exc) + + output = (proc.stdout or proc.stderr or "").strip() + ok = proc.returncode == 0 and bool(output) + return ok, output or f"exit code {proc.returncode}" + def get_tier() -> int: """Detect the current tier based on available dependencies. Tier 1 (Discovery): Python + pip - Tier 2 (AI Scoring & Tailoring): + LLM API key - Tier 3 (Full Auto-Apply): + Claude Code CLI + Chrome + Tier 2 (AI Scoring & Tailoring): + LLM provider + Tier 3 (Full Auto-Apply): + Claude Code CLI or Codex + Chrome """ load_env() - has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")) + provider = get_llm_provider() + if provider == "codex": + has_llm, _ = codex_login_status() + elif provider in ("gemini", "openai", "local"): + has_llm = True + else: + has_llm = False + if not has_llm: return 1 + backend = get_agent_backend() + has_claude = shutil.which("claude") is not None + has_codex = shutil.which("codex") is not None try: get_chrome_path() has_chrome = True except FileNotFoundError: has_chrome = False - if has_claude and has_chrome: + if backend == "claude": + has_agent = has_claude + elif backend == "codex": + has_agent, _ = codex_login_status() + has_agent = has_agent and has_codex + else: + has_agent = has_claude or has_codex + + if has_agent and has_chrome: return 3 return 2 @@ -238,11 +320,23 @@ 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") + if required >= 2: + provider = get_llm_provider() + if provider == "codex": + logged_in, detail = codex_login_status() + if not logged_in: + missing.append(f"Codex login — run [bold]codex login[/bold] ({detail})") + elif provider not in ("gemini", "openai", "local"): + missing.append("LLM provider — run [bold]applypilot init[/bold] to configure Gemini, OpenAI, local, or Codex") if required >= 3: - if not shutil.which("claude"): - missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]") + backend = get_agent_backend() + if backend == "codex": + logged_in, detail = codex_login_status() + if not logged_in: + missing.append("Codex login — run [bold]codex login[/bold]") + else: + if not shutil.which("claude"): + missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]") try: get_chrome_path() except FileNotFoundError: diff --git a/src/applypilot/llm.py b/src/applypilot/llm.py index 1fb7be647..9dc5350ee 100644 --- a/src/applypilot/llm.py +++ b/src/applypilot/llm.py @@ -1,17 +1,22 @@ -""" -Unified LLM client for ApplyPilot. +"""Unified LLM client for ApplyPilot. Auto-detects provider from environment: - GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash) - OPENAI_API_KEY -> OpenAI (default: gpt-4o-mini) - LLM_URL -> Local llama.cpp / Ollama compatible endpoint + LLM_PROVIDER=codex -> Codex CLI chat wrapper + GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash) + OPENAI_API_KEY -> OpenAI (default: gpt-4o-mini) + LLM_URL -> Local llama.cpp / Ollama compatible endpoint LLM_MODEL env var overrides the model name for any provider. """ +import json import logging import os +import shutil +import subprocess import time +import tempfile +from pathlib import Path import httpx @@ -21,19 +26,59 @@ # Provider detection # --------------------------------------------------------------------------- -def _detect_provider() -> tuple[str, str, str]: - """Return (base_url, model, api_key) based on environment variables. +def _detect_provider() -> tuple[str, str, str, str]: + """Return (provider, base_url, model, api_key) from environment variables. Reads env at call time (not module import time) so that load_env() called in _bootstrap() is always visible here. """ + provider = os.environ.get("LLM_PROVIDER", "").strip().lower() gemini_key = os.environ.get("GEMINI_API_KEY", "") openai_key = os.environ.get("OPENAI_API_KEY", "") local_url = os.environ.get("LLM_URL", "") model_override = os.environ.get("LLM_MODEL", "") + if provider == "codex": + codex_bin = shutil.which("codex") + if not codex_bin: + raise RuntimeError( + "LLM_PROVIDER=codex is set, but the Codex CLI was not found on PATH." + ) + return ("codex", codex_bin, model_override or "gpt-5.4-mini", "") + + if provider == "gemini": + if not gemini_key: + raise RuntimeError("LLM_PROVIDER=gemini is set, but GEMINI_API_KEY is missing.") + return ( + "gemini", + "https://generativelanguage.googleapis.com/v1beta/openai", + model_override or "gemini-2.0-flash", + gemini_key, + ) + + if provider == "openai": + if not openai_key: + raise RuntimeError("LLM_PROVIDER=openai is set, but OPENAI_API_KEY is missing.") + return ( + "openai", + "https://api.openai.com/v1", + model_override or "gpt-4o-mini", + openai_key, + ) + + if provider == "local": + if not local_url: + raise RuntimeError("LLM_PROVIDER=local is set, but LLM_URL is missing.") + return ( + "local", + local_url.rstrip("/"), + model_override or "local-model", + os.environ.get("LLM_API_KEY", ""), + ) + if gemini_key and not local_url: return ( + "gemini", "https://generativelanguage.googleapis.com/v1beta/openai", model_override or "gemini-2.0-flash", gemini_key, @@ -41,6 +86,7 @@ def _detect_provider() -> tuple[str, str, str]: if openai_key and not local_url: return ( + "openai", "https://api.openai.com/v1", model_override or "gpt-4o-mini", openai_key, @@ -48,6 +94,7 @@ def _detect_provider() -> tuple[str, str, str]: if local_url: return ( + "local", local_url.rstrip("/"), model_override or "local-model", os.environ.get("LLM_API_KEY", ""), @@ -55,7 +102,7 @@ def _detect_provider() -> tuple[str, str, str]: raise RuntimeError( "No LLM provider configured. " - "Set GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL in your environment." + "Set LLM_PROVIDER=codex or provide GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL." ) @@ -207,7 +254,7 @@ def chat( return self._chat_compat(messages, temperature, max_tokens) - except _GeminiCompatForbidden as exc: + except _GeminiCompatForbidden: # Model not available on OpenAI-compat layer — switch to native. log.warning( "Gemini compat endpoint returned 403 for model '%s'. " @@ -273,6 +320,126 @@ def close(self) -> None: self._client.close() +class CodexLLMClient: + """Thin wrapper that turns Codex CLI into an LLM provider.""" + + def __init__(self, model: str) -> None: + self.model = model + self._codex_bin = shutil.which("codex") or "codex" + + from applypilot.config import codex_login_status + + ok, detail = codex_login_status() + if not ok: + raise RuntimeError( + "Codex provider selected, but Codex is not logged in. " + f"Run `codex login` first ({detail})." + ) + + @staticmethod + def _messages_to_prompt(messages: list[dict]) -> str: + blocks: list[str] = [] + for message in messages: + role = str(message.get("role", "user")).upper() + content = str(message.get("content", "")).strip() + if not content: + continue + blocks.append(f"{role}:\n{content}") + blocks.append("ASSISTANT:") + return "\n\n".join(blocks).strip() + "\n" + + @staticmethod + def _extract_jsonl_message(stdout: str) -> str: + """Best-effort extraction of the last assistant message from JSONL.""" + last_text = "" + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + item = event.get("item") if isinstance(event, dict) else None + if not isinstance(item, dict): + continue + + if item.get("type") in {"agent_message", "message"}: + text = item.get("text") or item.get("content") or "" + if isinstance(text, str) and text.strip(): + last_text = text.strip() + return last_text + + def chat( + self, + messages: list[dict], + temperature: float = 0.0, + max_tokens: int = 4096, + ) -> str: + """Send a chat completion request through the Codex CLI.""" + del temperature, max_tokens + + prompt = self._messages_to_prompt(messages) + fd, last_message = tempfile.mkstemp(prefix="applypilot-codex-", suffix=".txt") + os.close(fd) + last_message_path = Path(last_message) + + try: + cmd = [ + self._codex_bin, + "exec", + "--json", + "--ephemeral", + "--ignore-user-config", + "--ignore-rules", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--model", + self.model, + "--output-last-message", + str(last_message_path), + "-", + ] + + proc = subprocess.run( + cmd, + input=prompt, + text=True, + capture_output=True, + check=False, + ) + + if last_message_path.exists(): + last_message = last_message_path.read_text(encoding="utf-8").strip() + if last_message and proc.returncode == 0: + return last_message + + fallback = self._extract_jsonl_message(proc.stdout or "") + if fallback and proc.returncode == 0: + return fallback + + detail = (proc.stderr or proc.stdout or "").strip() + if not detail: + detail = "Codex returned no assistant message." + raise RuntimeError( + f"Codex request failed with exit code {proc.returncode}: {detail[:500]}" + ) + finally: + try: + last_message_path.unlink(missing_ok=True) + except OSError: + pass + + def ask(self, prompt: str, **kwargs) -> str: + """Convenience: single user prompt -> assistant response.""" + return self.chat([{"role": "user", "content": prompt}], **kwargs) + + def close(self) -> None: + return None + + class _GeminiCompatForbidden(Exception): """Sentinel: Gemini OpenAI-compat returned 403. Switch to native API.""" def __init__(self, response: httpx.Response) -> None: @@ -284,14 +451,17 @@ def __init__(self, response: httpx.Response) -> None: # Singleton # --------------------------------------------------------------------------- -_instance: LLMClient | None = None +_instance: LLMClient | CodexLLMClient | None = None -def get_client() -> LLMClient: +def get_client() -> LLMClient | CodexLLMClient: """Return (or create) the module-level LLMClient singleton.""" global _instance if _instance is None: - base_url, model, api_key = _detect_provider() - log.info("LLM provider: %s model: %s", base_url, model) - _instance = LLMClient(base_url, model, api_key) + provider, base_url, model, api_key = _detect_provider() + log.info("LLM provider: %s model: %s", provider, model) + if provider == "codex": + _instance = CodexLLMClient(model) + else: + _instance = LLMClient(base_url, model, api_key) return _instance diff --git a/src/applypilot/resume.py b/src/applypilot/resume.py new file mode 100644 index 000000000..35e53d676 --- /dev/null +++ b/src/applypilot/resume.py @@ -0,0 +1,190 @@ +"""Resume file helpers. + +Supports plain text resumes, PDF inputs, and LaTeX source resumes. The LaTeX +path is used to derive a text version for LLM stages and to auto-compile a PDF +when possible. +""" + +from __future__ import annotations + +import logging +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +from applypilot.config import RESUME_PATH, RESUME_PDF_PATH, RESUME_TEX_PATH + +log = logging.getLogger(__name__) + + +def _normalize_text(text: str) -> str: + """Collapse excessive whitespace while preserving paragraph breaks.""" + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def extract_text_from_latex(tex: str) -> str: + """Best-effort LaTeX to plain-text conversion. + + Uses pandoc when available. Falls back to a lightweight regex-based + stripper so AI stages still have a usable resume text file. + """ + pandoc = shutil.which("pandoc") + if pandoc: + try: + proc = subprocess.run( + [pandoc, "-f", "latex", "-t", "plain", "--wrap=none"], + input=tex, + text=True, + capture_output=True, + check=True, + ) + return _normalize_text(proc.stdout) + except subprocess.CalledProcessError: + log.debug("pandoc LaTeX extraction failed", exc_info=True) + + text = tex + text = re.sub(r"(?m)^%.*$", "", text) + text = re.sub(r"\\documentclass(?:\[[^\]]*\])?\{[^}]*\}", "", text) + text = re.sub(r"\\usepackage(?:\[[^\]]*\])?\{[^}]*\}", "", text) + text = re.sub(r"\\begin\{(?:document|center|flushleft|flushright)\}", "\n", text) + text = re.sub(r"\\end\{(?:document|center|flushleft|flushright)\}", "\n", text) + text = re.sub(r"\\section\*?\{([^}]*)\}", r"\n\1\n", text) + text = re.sub(r"\\subsection\*?\{([^}]*)\}", r"\n\1\n", text) + text = re.sub(r"\\subsubsection\*?\{([^}]*)\}", r"\n\1\n", text) + text = re.sub(r"\\textbf\{([^}]*)\}", r"\1", text) + text = re.sub(r"\\textit\{([^}]*)\}", r"\1", text) + text = re.sub(r"\\emph\{([^}]*)\}", r"\1", text) + text = re.sub(r"\\href\{[^}]*\}\{([^}]*)\}", r"\1", text) + text = re.sub(r"\\url\{([^}]*)\}", r"\1", text) + text = re.sub(r"\\item\s*", "\n- ", text) + text = re.sub(r"\\begin\{(?:itemize|enumerate)\}", "\n", text) + text = re.sub(r"\\end\{(?:itemize|enumerate)\}", "\n", text) + text = re.sub(r"\\\\", "\n", text) + text = re.sub(r"\\[a-zA-Z@]+(?:\[[^\]]*\])?(?:\{[^}]*\})*", "", text) + text = re.sub(r"\$[^$]*\$", "", text) + text = re.sub(r"[{}]", "", text) + return _normalize_text(text) + + +def load_resume_text() -> str: + """Load the base resume text for AI stages. + + Prefers `resume.txt`, then falls back to a LaTeX source by extracting + plain text from it. + """ + if RESUME_PATH.exists(): + return RESUME_PATH.read_text(encoding="utf-8") + + if RESUME_TEX_PATH.exists(): + return extract_text_from_latex(RESUME_TEX_PATH.read_text(encoding="utf-8")) + + raise FileNotFoundError( + f"Resume not found. Expected {RESUME_PATH} or {RESUME_TEX_PATH}." + ) + + +def compile_latex_to_pdf(tex_path: Path, output_path: Path | None = None) -> Path: + """Compile a LaTeX document into PDF. + + Uses latexmk when available, falling back to xelatex or pdflatex. + """ + tex_path = Path(tex_path).expanduser().resolve() + if not tex_path.exists(): + raise FileNotFoundError(tex_path) + + output_path = Path(output_path) if output_path else tex_path.with_suffix(".pdf") + output_path.parent.mkdir(parents=True, exist_ok=True) + + latexmk = shutil.which("latexmk") + xelatex = shutil.which("xelatex") + pdflatex = shutil.which("pdflatex") + if not any((latexmk, xelatex, pdflatex)): + raise FileNotFoundError("No LaTeX engine found (latexmk, xelatex, or pdflatex)") + + with tempfile.TemporaryDirectory(prefix="applypilot-latex-") as tmp: + tmpdir = Path(tmp) + cwd = tex_path.parent + + if latexmk: + cmd = [ + latexmk, + "-cd", + "-interaction=nonstopmode", + "-halt-on-error", + "-file-line-error", + f"-output-directory={tmpdir}", + ] + if xelatex: + cmd.append("-xelatex") + else: + cmd.append("-pdf") + cmd.append(tex_path.name) + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError((proc.stdout + "\n" + proc.stderr).strip()) + else: + engine = xelatex or pdflatex + assert engine is not None + cmd = [ + engine, + "-interaction=nonstopmode", + "-halt-on-error", + "-file-line-error", + f"-output-directory={tmpdir}", + tex_path.name, + ] + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError((proc.stdout + "\n" + proc.stderr).strip()) + # A second pass helps resolve references when latexmk is unavailable. + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError((proc.stdout + "\n" + proc.stderr).strip()) + + produced = tmpdir / tex_path.with_suffix(".pdf").name + if not produced.exists(): + raise RuntimeError(f"LaTeX compile finished without producing {produced.name}") + + shutil.copy2(produced, output_path) + return output_path + + +def ingest_resume_source(src: Path) -> dict[str, Path]: + """Copy a source resume into the ApplyPilot profile directory. + + Returns a mapping of artifact name to written path. + """ + src = Path(src).expanduser().resolve() + if not src.exists(): + raise FileNotFoundError(src) + + artifacts: dict[str, Path] = {} + suffix = src.suffix.lower() + + if suffix == ".txt": + shutil.copy2(src, RESUME_PATH) + artifacts["resume_txt"] = RESUME_PATH + elif suffix == ".pdf": + shutil.copy2(src, RESUME_PDF_PATH) + artifacts["resume_pdf"] = RESUME_PDF_PATH + elif suffix == ".tex": + shutil.copy2(src, RESUME_TEX_PATH) + artifacts["resume_tex"] = RESUME_TEX_PATH + + extracted = extract_text_from_latex(src.read_text(encoding="utf-8")) + RESUME_PATH.write_text(extracted + "\n", encoding="utf-8") + artifacts["resume_txt"] = RESUME_PATH + + try: + compile_latex_to_pdf(RESUME_TEX_PATH, RESUME_PDF_PATH) + artifacts["resume_pdf"] = RESUME_PDF_PATH + except Exception as exc: + log.warning("LaTeX resume compile failed: %s", exc) + else: + raise ValueError("Unsupported resume format. Use .txt, .pdf, or .tex.") + + return artifacts diff --git a/src/applypilot/scoring/cover_letter.py b/src/applypilot/scoring/cover_letter.py index c16cdd5f7..a415cf807 100644 --- a/src/applypilot/scoring/cover_letter.py +++ b/src/applypilot/scoring/cover_letter.py @@ -5,15 +5,15 @@ profile at runtime. No hardcoded personal information. """ -import json import logging import re import time from datetime import datetime, timezone -from applypilot.config import COVER_LETTER_DIR, RESUME_PATH, load_profile -from applypilot.database import get_connection, get_jobs_by_stage +from applypilot.config import COVER_LETTER_DIR, load_profile +from applypilot.database import get_connection from applypilot.llm import get_client +from applypilot.resume import load_resume_text from applypilot.scoring.validator import ( BANNED_WORDS, LLM_LEAK_PHRASES, @@ -198,7 +198,7 @@ def run_cover_letters(min_score: int = 7, limit: int = 20, {"generated": int, "errors": int, "elapsed": float} """ profile = load_profile() - resume_text = RESUME_PATH.read_text(encoding="utf-8") + resume_text = load_resume_text() conn = get_connection() # Fetch jobs that have tailored resumes but no cover letter yet diff --git a/src/applypilot/scoring/pdf.py b/src/applypilot/scoring/pdf.py index 2b87b6734..87b08c757 100644 --- a/src/applypilot/scoring/pdf.py +++ b/src/applypilot/scoring/pdf.py @@ -1,13 +1,15 @@ """Text-to-PDF conversion for tailored resumes and cover letters. Parses the structured text resume format, renders via an HTML/CSS template, -and exports to PDF using headless Chromium via Playwright. +and exports to PDF using headless Chromium via Playwright. If the source file +is LaTeX, it is compiled directly with a local LaTeX engine. """ import logging from pathlib import Path from applypilot.config import TAILORED_DIR +from applypilot.resume import compile_latex_to_pdf log = logging.getLogger(__name__) @@ -372,6 +374,10 @@ def convert_to_pdf( Path to the generated PDF (or HTML) file. """ text_path = Path(text_path) + if text_path.suffix.lower() == ".tex": + out = output_path or text_path.with_suffix(".pdf") + return compile_latex_to_pdf(text_path, out) + text = text_path.read_text(encoding="utf-8") resume = parse_resume(text) html = build_html(resume) @@ -407,16 +413,19 @@ def batch_convert(limit: int = 50) -> int: return 0 txt_files = sorted(TAILORED_DIR.glob("*.txt")) - # Exclude _JOB.txt and _CL.txt files from resume conversion + tex_files = sorted(TAILORED_DIR.glob("*.tex")) + # Exclude _JOB.txt files from resume conversion # (they get their own conversion calls) - candidates = [ - f for f in txt_files - if not f.name.endswith("_JOB.txt") - ] + candidates_by_stem: dict[str, Path] = {} + for f in txt_files: + if not f.name.endswith("_JOB.txt"): + candidates_by_stem[f.stem] = f + for f in tex_files: + candidates_by_stem[f.stem] = f # LaTeX wins if both exist # Filter to those without a corresponding PDF to_convert: list[Path] = [] - for f in candidates: + for f in candidates_by_stem.values(): pdf_path = f.with_suffix(".pdf") if not pdf_path.exists(): to_convert.append(f) diff --git a/src/applypilot/scoring/scorer.py b/src/applypilot/scoring/scorer.py index 97692d5f7..500200119 100644 --- a/src/applypilot/scoring/scorer.py +++ b/src/applypilot/scoring/scorer.py @@ -5,15 +5,14 @@ profile and resume file. """ -import json import logging import re import time from datetime import datetime, timezone -from applypilot.config import RESUME_PATH, load_profile from applypilot.database import get_connection, get_jobs_by_stage from applypilot.llm import get_client +from applypilot.resume import load_resume_text log = logging.getLogger(__name__) @@ -111,7 +110,7 @@ def run_scoring(limit: int = 0, rescore: bool = False) -> dict: Returns: {"scored": int, "errors": int, "elapsed": float, "distribution": list} """ - resume_text = RESUME_PATH.read_text(encoding="utf-8") + resume_text = load_resume_text() conn = get_connection() if rescore: diff --git a/src/applypilot/scoring/tailor.py b/src/applypilot/scoring/tailor.py index 352fb5ff9..a78de5d97 100644 --- a/src/applypilot/scoring/tailor.py +++ b/src/applypilot/scoring/tailor.py @@ -14,17 +14,15 @@ import re import time from datetime import datetime, timezone -from pathlib import Path -from applypilot.config import RESUME_PATH, TAILORED_DIR, load_profile +from applypilot.config import TAILORED_DIR, load_profile from applypilot.database import get_connection, get_jobs_by_stage from applypilot.llm import get_client +from applypilot.resume import load_resume_text from applypilot.scoring.validator import ( BANNED_WORDS, - FABRICATION_WATCHLIST, sanitize_text, validate_json_fields, - validate_tailored_resume, ) log = logging.getLogger(__name__) @@ -53,12 +51,10 @@ def _build_tailor_prompt(profile: dict) -> str: # Preserved entities companies = resume_facts.get("preserved_companies", []) - projects = resume_facts.get("preserved_projects", []) school = resume_facts.get("preserved_school", "") real_metrics = resume_facts.get("real_metrics", []) companies_str = ", ".join(companies) if companies else "N/A" - projects_str = ", ".join(projects) if projects else "N/A" metrics_str = ", ".join(real_metrics) if real_metrics else "N/A" # Include ALL banned words from the validator so the LLM knows exactly @@ -468,7 +464,7 @@ def run_tailoring(min_score: int = 7, limit: int = 20, {"approved": int, "failed": int, "errors": int, "elapsed": float} """ profile = load_profile() - resume_text = RESUME_PATH.read_text(encoding="utf-8") + resume_text = load_resume_text() conn = get_connection() jobs = get_jobs_by_stage(conn=conn, stage="pending_tailor", min_score=min_score, limit=limit) diff --git a/src/applypilot/wizard/init.py b/src/applypilot/wizard/init.py index 0f893c3ab..43dc4f68b 100644 --- a/src/applypilot/wizard/init.py +++ b/src/applypilot/wizard/init.py @@ -4,16 +4,17 @@ - resume.txt (and optionally resume.pdf) - profile.json - searches.yaml - - .env (LLM API key) + - .env (LLM provider configuration) """ from __future__ import annotations import json +import os import shutil +import subprocess from pathlib import Path -import typer from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm, Prompt @@ -27,17 +28,38 @@ SEARCH_CONFIG_PATH, ensure_dirs, ) +from applypilot.resume import ingest_resume_source console = Console() +def _set_env_var(key: str, value: str) -> None: + """Create or update a key in ~/.applypilot/.env.""" + lines: list[str] = [] + if ENV_PATH.exists(): + for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + lines.append(line) + continue + if line.split("=", 1)[0].strip() == key: + continue + lines.append(line) + lines.append(f"{key}={value}") + ENV_PATH.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + + # --------------------------------------------------------------------------- # Resume # --------------------------------------------------------------------------- def _setup_resume() -> None: """Prompt for resume file and copy into APP_DIR.""" - console.print(Panel("[bold]Step 1: Resume[/bold]\nPoint to your master resume file (.txt or .pdf).")) + console.print(Panel( + "[bold]Step 1: Resume[/bold]\n" + "Point to your master resume file (.txt, .pdf, or .tex).\n" + "LaTeX resumes will be copied, converted to text for AI stages, and compiled to PDF when possible." + )) while True: path_str = Prompt.ask("Resume file path") @@ -48,14 +70,11 @@ def _setup_resume() -> None: continue suffix = src.suffix.lower() - if suffix not in (".txt", ".pdf"): - console.print("[red]Unsupported format.[/red] Provide a .txt or .pdf file.") + if suffix not in (".txt", ".pdf", ".tex"): + console.print("[red]Unsupported format.[/red] Provide a .txt, .pdf, or .tex file.") continue - if suffix == ".txt": - shutil.copy2(src, RESUME_PATH) - console.print(f"[green]Copied to {RESUME_PATH}[/green]") - elif suffix == ".pdf": + if suffix == ".pdf": shutil.copy2(src, RESUME_PDF_PATH) console.print(f"[green]Copied to {RESUME_PDF_PATH}[/green]") @@ -71,6 +90,14 @@ def _setup_resume() -> None: console.print(f"[green]Copied to {RESUME_PATH}[/green]") else: console.print("[yellow]File not found, skipping plain-text copy.[/yellow]") + else: + artifacts = ingest_resume_source(src) + for label, path in artifacts.items(): + console.print(f"[green]Created {label.replace('_', ' ')}:[/green] {path}") + if suffix == ".tex" and RESUME_PDF_PATH.exists(): + console.print(f"[green]Compiled LaTeX resume to:[/green] {RESUME_PDF_PATH}") + elif suffix == ".tex": + console.print("[yellow]LaTeX source copied, but PDF compilation was skipped or failed.[/yellow]") break @@ -238,17 +265,21 @@ def _setup_ai_features() -> None: console.print(Panel( "[bold]Step 4: AI Features (optional)[/bold]\n" "An LLM powers job scoring, resume tailoring, and cover letters.\n" - "Without this, you can still discover and enrich jobs." + "Without this, you can still discover and enrich jobs.\n" + "Codex is available here as a tailoring provider." )) if not Confirm.ask("Enable AI scoring and resume tailoring?", default=True): console.print("[dim]Discovery-only mode. You can configure AI later with [bold]applypilot init[/bold].[/dim]") return - console.print("Supported providers: [bold]Gemini[/bold] (recommended, free tier), OpenAI, local (Ollama/llama.cpp)") + console.print( + "Supported providers: [bold]Gemini[/bold] (recommended, free tier), " + "OpenAI, local (Ollama/llama.cpp), [bold]Codex[/bold]" + ) provider = Prompt.ask( "Provider", - choices=["gemini", "openai", "local"], + choices=["gemini", "openai", "local", "codex"], default="gemini", ) @@ -257,18 +288,40 @@ def _setup_ai_features() -> None: if provider == "gemini": api_key = Prompt.ask("Gemini API key (from aistudio.google.com)") model = Prompt.ask("Model", default="gemini-2.0-flash") + env_lines.append("LLM_PROVIDER=gemini") env_lines.append(f"GEMINI_API_KEY={api_key}") env_lines.append(f"LLM_MODEL={model}") elif provider == "openai": api_key = Prompt.ask("OpenAI API key") model = Prompt.ask("Model", default="gpt-4o-mini") + env_lines.append("LLM_PROVIDER=openai") env_lines.append(f"OPENAI_API_KEY={api_key}") env_lines.append(f"LLM_MODEL={model}") elif provider == "local": url = Prompt.ask("Local LLM endpoint URL", default="http://localhost:8080/v1") model = Prompt.ask("Model name", default="local-model") + env_lines.append("LLM_PROVIDER=local") env_lines.append(f"LLM_URL={url}") env_lines.append(f"LLM_MODEL={model}") + elif provider == "codex": + model = Prompt.ask("Codex model", default="gpt-5.4-mini") + env_lines.append("LLM_PROVIDER=codex") + env_lines.append(f"LLM_MODEL={model}") + console.print( + "[yellow]Codex selected.[/yellow] This configures scoring, tailoring, and cover letters." + ) + ok, detail = _check_codex_login() + if ok: + console.print(f"[green]Codex login detected.[/green] {detail}") + else: + console.print(f"[yellow]Codex is not logged in yet.[/yellow] {detail}") + if Confirm.ask("Run `codex login` now?", default=True): + subprocess.run(["codex", "login"], check=False) + ok, detail = _check_codex_login() + if ok: + console.print(f"[green]Codex login detected.[/green] {detail}") + else: + console.print(f"[yellow]Codex login still missing.[/yellow] {detail}") env_lines.append("") ENV_PATH.write_text("\n".join(env_lines), encoding="utf-8") @@ -279,27 +332,66 @@ def _setup_ai_features() -> None: # Auto-Apply # --------------------------------------------------------------------------- +def _check_codex_login() -> tuple[bool, str]: + """Check whether Codex is logged in.""" + try: + proc = subprocess.run( + ["codex", "login", "status"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + return False, str(exc) + + output = (proc.stdout or proc.stderr or "").strip() + ok = proc.returncode == 0 and bool(output) + return ok, output or f"exit code {proc.returncode}" + + def _setup_auto_apply() -> None: - """Configure autonomous job application (requires Claude Code CLI).""" + """Configure autonomous job application (requires an agent CLI).""" console.print(Panel( "[bold]Step 5: Auto-Apply (optional)[/bold]\n" "ApplyPilot can autonomously fill and submit job applications\n" - "using Claude Code as the browser agent." + "using Claude Code or Codex as the browser agent." )) if not Confirm.ask("Enable autonomous job applications?", default=True): console.print("[dim]You can apply manually using the tailored resumes ApplyPilot generates.[/dim]") return - # Check for Claude Code CLI + agent_choices = [] if shutil.which("claude"): - console.print("[green]Claude Code CLI detected.[/green]") + agent_choices.append("claude") + if shutil.which("codex"): + agent_choices.append("codex") + if not agent_choices: + agent_choices = ["claude", "codex"] + + default_agent = os.environ.get("APPLYPILOT_AGENT") or ("claude" if "claude" in agent_choices else agent_choices[0]) + if default_agent not in agent_choices: + default_agent = "claude" if "claude" in agent_choices else agent_choices[0] + agent = Prompt.ask("Agent CLI", choices=agent_choices, default=default_agent) + _set_env_var("APPLYPILOT_AGENT", agent) + + if agent == "codex": + ok, detail = _check_codex_login() + if ok: + console.print(f"[green]Codex login detected.[/green] {detail}") + else: + console.print(f"[yellow]Codex is not logged in yet.[/yellow] {detail}") + if Confirm.ask("Run `codex login` now?", default=True): + subprocess.run(["codex", "login"], check=False) else: - console.print( - "[yellow]Claude Code CLI not found on PATH.[/yellow]\n" - "Install it from: [bold]https://claude.ai/code[/bold]\n" - "Auto-apply won't work until Claude Code is installed." - ) + if shutil.which("claude"): + console.print("[green]Claude Code CLI detected.[/green]") + else: + console.print( + "[yellow]Claude Code CLI not found on PATH.[/yellow]\n" + "Install it from: [bold]https://claude.ai/code[/bold]\n" + "Auto-apply won't work until Claude Code is installed." + ) # Optional: CapSolver for CAPTCHAs console.print("\n[dim]Some job sites use CAPTCHAs. CapSolver can handle them automatically.[/dim]") @@ -356,7 +448,7 @@ def run_wizard() -> None: _setup_ai_features() console.print() - # Step 5: Auto-apply (Claude Code detection) + # Step 5: Auto-apply (Claude Code or Codex detection) _setup_auto_apply() console.print() @@ -378,9 +470,9 @@ def run_wizard() -> None: unlock_hint = "" if tier == 1: - unlock_hint = "\n[dim]To unlock Tier 2: configure an LLM API key (re-run [bold]applypilot init[/bold]).[/dim]" + unlock_hint = "\n[dim]To unlock Tier 2: configure an LLM provider (re-run [bold]applypilot init[/bold]).[/dim]" elif tier == 2: - unlock_hint = "\n[dim]To unlock Tier 3: install Claude Code CLI + Chrome.[/dim]" + unlock_hint = "\n[dim]To unlock Tier 3: install Claude Code CLI or Codex, plus Chrome.[/dim]" console.print( Panel.fit(