diff --git a/CHANGELOG.md b/CHANGELOG.md index 5682b270..f8507aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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] + +### Changed +- `applypilot doctor` checks for the Playwright browser; README and the init + wizard tell you to run `playwright install chromium`. +- Re-running `applypilot init` merges into the existing `.env` (preserving keys + like `CAPSOLVER_API_KEY`) and prompts before overwriting `profile.json` / + `searches.yaml`. A plain-text resume is now required (or an explicit skip). + ## [0.2.0] - 2026-02-17 ### Added diff --git a/README.md b/README.md index 136b4f7b..a5fdabd0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Three commands. That's it. ```bash pip install applypilot pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex +playwright install chromium # the headless browser used by enrich, smart-extract, and PDF rendering applypilot init # one-time setup: resume, profile, preferences, API keys applypilot doctor # verify your setup — shows what's installed and what's missing applypilot run # discover > enrich > score > tailor > cover letters @@ -89,6 +90,7 @@ Each stage is independent. Run them all or pick what you need. | Component | Required For | Details | |-----------|-------------|---------| | Python 3.11+ | Everything | Core runtime | +| Playwright Chromium | Enrich, smart-extract, PDF | `playwright install chromium` after pip install (not bundled) | | 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 | | Chrome/Chromium | Auto-apply | Auto-detected on most systems | diff --git a/src/applypilot/cli.py b/src/applypilot/cli.py index 6c8be912..dc4eafa8 100644 --- a/src/applypilot/cli.py +++ b/src/applypilot/cli.py @@ -412,6 +412,21 @@ def doctor() -> None: results.append(("Chrome/Chromium", fail_mark, "Install Chrome or set CHROME_PATH env var (needed for auto-apply)")) + # Playwright browser (separate from Chrome; pip does not install it). + # NOTE: os is imported locally above; avoid Path here -- a NameError would be + # swallowed by this except and always report MISSING. + try: + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + pw_exe = p.chromium.executable_path + if pw_exe and os.path.exists(pw_exe): + results.append(("Playwright browser", ok_mark, pw_exe)) + else: + raise FileNotFoundError(pw_exe) + except Exception: + results.append(("Playwright browser", fail_mark, + "Run: playwright install chromium (needed for enrich/smart-extract/PDF)")) + # Node.js / npx (for Playwright MCP) npx_bin = shutil.which("npx") if npx_bin: diff --git a/src/applypilot/wizard/init.py b/src/applypilot/wizard/init.py index 0f893c3a..4b86931c 100644 --- a/src/applypilot/wizard/init.py +++ b/src/applypilot/wizard/init.py @@ -31,6 +31,35 @@ console = Console() +def _merge_env(existing_text: str, new_pairs: dict) -> str: + """Merge new KEY=VALUE pairs into an existing .env, preserving everything else. + + Re-running init must not silently drop keys the user set elsewhere (a + CapSolver key, a manual CHROME_PATH). Unknown keys and comments are kept; + keys present in new_pairs are overwritten in place. + """ + lines = existing_text.splitlines() + out: list[str] = [] + seen: set[str] = set() + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + out.append(line) + continue + key = stripped.split("=", 1)[0].strip() + if key in new_pairs: + out.append(f"{key}={new_pairs[key]}") + seen.add(key) + else: + out.append(line) + if not out or out[0].strip() != "# ApplyPilot configuration": + out.insert(0, "# ApplyPilot configuration") + for key, value in new_pairs.items(): + if key not in seen: + out.append(f"{key}={value}") + return "\n".join(out) + "\n" + + # --------------------------------------------------------------------------- # Resume # --------------------------------------------------------------------------- @@ -59,18 +88,28 @@ def _setup_resume() -> None: shutil.copy2(src, RESUME_PDF_PATH) console.print(f"[green]Copied to {RESUME_PDF_PATH}[/green]") - # Also ask for a plain-text version for LLM consumption - txt_path_str = Prompt.ask( - "Plain-text version of your resume (.txt)", - default="", - ) - if txt_path_str.strip(): - txt_src = Path(txt_path_str.strip().strip('"').strip("'")).expanduser().resolve() + # A plain-text resume is REQUIRED -- scoring, tailoring and cover + # letters read it and crash hours later without it. Loop until we + # get a valid file or the user explicitly types 'skip'. + while True: + txt_path_str = Prompt.ask( + "Plain-text version of your resume (.txt path, or 'skip')" + ).strip() + if txt_path_str.lower() == "skip": + console.print( + f"[red]Scoring, tailoring, and cover letters will FAIL without " + f"resume.txt. Add it later at {RESUME_PATH}.[/red]" + ) + break + if not txt_path_str: + console.print("[yellow]Enter a .txt path or type 'skip'.[/yellow]") + continue + txt_src = Path(txt_path_str.strip('"').strip("'")).expanduser().resolve() if txt_src.exists(): shutil.copy2(txt_src, RESUME_PATH) console.print(f"[green]Copied to {RESUME_PATH}[/green]") - else: - console.print("[yellow]File not found, skipping plain-text copy.[/yellow]") + break + console.print(f"[red]File not found:[/red] {txt_src}") break @@ -82,6 +121,12 @@ def _setup_profile() -> dict: """Walk through profile questions and return a nested profile dict.""" console.print(Panel("[bold]Step 2: Profile[/bold]\nTell ApplyPilot about yourself. This powers scoring, tailoring, and auto-fill.")) + if PROFILE_PATH.exists() and not Confirm.ask( + f"profile.json already exists at {PROFILE_PATH} — overwrite?", default=False + ): + console.print("[dim]Keeping existing profile.json[/dim]") + return json.loads(PROFILE_PATH.read_text(encoding="utf-8")) + profile: dict = {} # -- Personal -- @@ -188,6 +233,12 @@ def _setup_searches() -> None: """Generate a searches.yaml from user input.""" console.print(Panel("[bold]Step 3: Job Search Config[/bold]\nDefine what you're looking for.")) + if SEARCH_CONFIG_PATH.exists() and not Confirm.ask( + f"searches.yaml already exists at {SEARCH_CONFIG_PATH} — overwrite?", default=False + ): + console.print("[dim]Keeping existing searches.yaml[/dim]") + return + location = Prompt.ask("Target location (e.g. 'Remote', 'Canada', 'New York, NY')", default="Remote") distance_str = Prompt.ask("Search radius in miles (0 for remote-only)", default="0") try: @@ -252,26 +303,24 @@ def _setup_ai_features() -> None: default="gemini", ) - env_lines = ["# ApplyPilot configuration", ""] + new_pairs: dict[str, str] = {} 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(f"GEMINI_API_KEY={api_key}") - env_lines.append(f"LLM_MODEL={model}") + new_pairs["GEMINI_API_KEY"] = api_key + new_pairs["LLM_MODEL"] = Prompt.ask("Model", default="gemini-2.0-flash") elif provider == "openai": api_key = Prompt.ask("OpenAI API key") - model = Prompt.ask("Model", default="gpt-4o-mini") - env_lines.append(f"OPENAI_API_KEY={api_key}") - env_lines.append(f"LLM_MODEL={model}") + new_pairs["OPENAI_API_KEY"] = api_key + new_pairs["LLM_MODEL"] = Prompt.ask("Model", default="gpt-4o-mini") 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(f"LLM_URL={url}") - env_lines.append(f"LLM_MODEL={model}") + new_pairs["LLM_URL"] = Prompt.ask("Local LLM endpoint URL", default="http://localhost:8080/v1") + new_pairs["LLM_MODEL"] = Prompt.ask("Model name", default="local-model") - env_lines.append("") - ENV_PATH.write_text("\n".join(env_lines), encoding="utf-8") + # Merge into any existing .env so previously saved keys (e.g. a CapSolver + # key, or a manually added CHROME_PATH) survive a re-run of init. + existing = ENV_PATH.read_text(encoding="utf-8") if ENV_PATH.exists() else "" + ENV_PATH.write_text(_merge_env(existing, new_pairs), encoding="utf-8") console.print(f"[green]AI configuration saved to {ENV_PATH}[/green]") @@ -376,6 +425,22 @@ def run_wizard() -> None: else: tier_lines.append(f" [dim]✗ Tier {t} — {label} ({cmds})[/dim]") + # Warn if the Playwright browser isn't installed (pip doesn't bundle it). + import os as _os + try: + from playwright.sync_api import sync_playwright + with sync_playwright() as _p: + _pw_exe = _p.chromium.executable_path + _pw_ok = bool(_pw_exe and _os.path.exists(_pw_exe)) + except Exception: + _pw_ok = False + if not _pw_ok: + console.print( + "[yellow]Playwright browser not found.[/yellow] " + "Run [bold]playwright install chromium[/bold] " + "(needed for enrich, smart-extract, and PDF rendering).\n" + ) + 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]" diff --git a/tests/test_init_env_merge.py b/tests/test_init_env_merge.py new file mode 100644 index 00000000..3dfb0875 --- /dev/null +++ b/tests/test_init_env_merge.py @@ -0,0 +1,30 @@ +"""F16: re-running init must not destroy existing .env keys.""" +from applypilot.wizard.init import _merge_env + + +def test_merge_preserves_unknown_keys(): + out = _merge_env("CAPSOLVER_API_KEY=abc\nGEMINI_API_KEY=old", {"GEMINI_API_KEY": "new"}) + assert "CAPSOLVER_API_KEY=abc" in out + assert "GEMINI_API_KEY=new" in out + assert "GEMINI_API_KEY=old" not in out + # Each key appears exactly once. + assert out.count("CAPSOLVER_API_KEY=") == 1 + assert out.count("GEMINI_API_KEY=") == 1 + + +def test_merge_appends_new_keys(): + out = _merge_env("CHROME_PATH=/usr/bin/chrome", {"GEMINI_API_KEY": "k"}) + assert "CHROME_PATH=/usr/bin/chrome" in out + assert "GEMINI_API_KEY=k" in out + + +def test_merge_from_empty(): + out = _merge_env("", {"GEMINI_API_KEY": "k", "LLM_MODEL": "m"}) + assert "GEMINI_API_KEY=k" in out + assert "LLM_MODEL=m" in out + + +def test_merge_keeps_comments(): + out = _merge_env("# my notes\nCAPSOLVER_API_KEY=x", {"LLM_MODEL": "m"}) + assert "# my notes" in out + assert "CAPSOLVER_API_KEY=x" in out