Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions src/applypilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
109 changes: 87 additions & 22 deletions src/applypilot/wizard/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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


Expand All @@ -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 --
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]")


Expand Down Expand Up @@ -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]"
Expand Down
30 changes: 30 additions & 0 deletions tests/test_init_env_merge.py
Original file line number Diff line number Diff line change
@@ -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