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
260 changes: 171 additions & 89 deletions src/applypilot/apply/launcher.py

Large diffs are not rendered by default.

79 changes: 29 additions & 50 deletions src/applypilot/apply/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ def _build_profile_summary(profile: dict) -> str:
lines.append(f"Available: {avail.get('earliest_start_date', 'Immediately')}")

# Standard responses
lines.extend([
"Age 18+: Yes",
"Background Check: Yes",
"Felony: No",
"Previously Worked Here: No",
"How Heard: Online Job Board",
])
lines.extend(
[
"Age 18+: Yes",
"Background Check: Yes",
"Felony: No",
"Previously Worked Here: No",
"How Heard: Online Job Board",
]
)

# EEO
lines.append(f"Gender: {eeo.get('gender', 'Decline to self-identify')}")
Expand Down Expand Up @@ -174,7 +176,7 @@ def _build_screening_section(profile: dict) -> str:
return f"""== SCREENING QUESTIONS (be strategic) ==
Hard facts -> answer truthfully from the profile. No guessing. This includes:
- Location/relocation: lives in {city}, cannot relocate
- Work authorization: {work_auth.get('legally_authorized_to_work', 'see profile')}
- Work authorization: {work_auth.get("legally_authorized_to_work", "see profile")}
- Citizenship, clearance, licenses, certifications: answer from profile only
- Criminal/background: answer from profile only

Expand Down Expand Up @@ -204,9 +206,11 @@ def _build_hard_rules(profile: dict) -> str:
if permit_type:
work_auth_rule = f"Work auth: {permit_type}. Sponsorship needed: {sponsorship}."

name_rule = f'Name: Legal name = {full_name}.'
name_rule = f"Name: Legal name = {full_name}."
if preferred_name and preferred_name != full_name.split()[0]:
name_rule += f' Preferred name = {preferred_name}. Use "{display_name}" unless a field specifically says "legal name".'
name_rule += (
f' Preferred name = {preferred_name}. Use "{display_name}" unless a field specifically says "legal name".'
)

return f"""== HARD RULES (never break these) ==
1. Never lie about: citizenship, work authorization, criminal history, education credentials, security clearance, licenses.
Expand All @@ -225,7 +229,7 @@ def _build_captcha_section() -> str:

return f"""== CAPTCHA ==
You solve CAPTCHAs via the CapSolver REST API. No browser extension. You control the entire flow.
API key: {capsolver_key or 'NOT CONFIGURED — skip to MANUAL FALLBACK for all CAPTCHAs'}
API key: $CAPSOLVER_API_KEY env var ({"configured" if capsolver_key else "NOT CONFIGURED — skip to MANUAL FALLBACK for all CAPTCHAs"})
API base: https://api.capsolver.com

CRITICAL RULE: When ANY CAPTCHA appears (hCaptcha, reCAPTCHA, Turnstile -- regardless of what it looks like visually), you MUST:
Expand Down Expand Up @@ -300,22 +304,8 @@ def _build_captcha_section() -> str:
--- CAPTCHA SOLVE ---
Three steps: createTask -> poll -> inject. Do each as a separate browser_evaluate call.

STEP 1 -- CREATE TASK (copy this exactly, fill in the 3 placeholders):
browser_evaluate function: async () => {{{{
const r = await fetch('https://api.capsolver.com/createTask', {{{{
method: 'POST',
headers: {{{{'Content-Type': 'application/json'}}}},
body: JSON.stringify({{{{
clientKey: '{capsolver_key}',
task: {{{{
type: 'TASK_TYPE',
websiteURL: 'PAGE_URL',
websiteKey: 'SITE_KEY'
}}}}
}}}})
}}}});
return await r.json();
}}}}
STEP 1 -- CREATE TASK (use Bash curl, NOT browser_evaluate — keeps API key out of page context):
Bash command: curl -s -X POST https://api.capsolver.com/createTask -H 'Content-Type: application/json' -d '{{"clientKey":"'$CAPSOLVER_API_KEY'","task":{{"type":"TASK_TYPE","websiteURL":"PAGE_URL","websiteKey":"SITE_KEY"}}}}'

TASK_TYPE values (use EXACTLY these strings):
hcaptcha -> HCaptchaTaskProxyLess
Expand All @@ -332,18 +322,8 @@ def _build_captcha_section() -> str:
If errorId > 0 -> CAPTCHA SOLVE failed. Go to MANUAL FALLBACK.

STEP 2 -- POLL (replace TASK_ID with the taskId from step 1):
Loop: browser_wait_for time: 3, then run:
browser_evaluate function: async () => {{{{
const r = await fetch('https://api.capsolver.com/getTaskResult', {{{{
method: 'POST',
headers: {{{{'Content-Type': 'application/json'}}}},
body: JSON.stringify({{{{
clientKey: '{capsolver_key}',
taskId: 'TASK_ID'
}}}})
}}}});
return await r.json();
}}}}
Loop: wait 3 seconds, then run:
Bash command: curl -s -X POST https://api.capsolver.com/getTaskResult -H 'Content-Type: application/json' -d '{{"clientKey":"'$CAPSOLVER_API_KEY'","taskId":"TASK_ID"}}'

- status "processing" -> wait 3s, poll again. Max 10 polls (30s).
- status "ready" -> extract token:
Expand Down Expand Up @@ -417,9 +397,7 @@ def _build_captcha_section() -> str:
4. All else fails -> Output RESULT:CAPTCHA."""


def build_prompt(job: dict, tailored_resume: str,
cover_letter: str | None = None,
dry_run: bool = False) -> str:
def build_prompt(job: dict, tailored_resume: str, cover_letter: str | None = None, dry_run: bool = False) -> str:
"""Build the full instruction prompt for the apply agent.

Loads the user profile and search config internally. All personal data
Expand Down Expand Up @@ -500,6 +478,7 @@ def build_prompt(job: dict, tailored_resume: str,

# SSO domains the agent cannot sign into (loaded from config/sites.yaml)
from applypilot.config import load_blocked_sso

blocked_sso = load_blocked_sso()

# Preferred display name
Expand All @@ -516,10 +495,10 @@ def build_prompt(job: dict, tailored_resume: str,
prompt = f"""You are an autonomous job application agent. Your ONE mission: get this candidate an interview. You have all the information and tools. Think strategically. Act decisively. Submit the application.

== JOB ==
URL: {job.get('application_url') or job['url']}
Title: {job['title']}
Company: {job.get('site', 'Unknown')}
Fit Score: {job.get('fit_score', 'N/A')}/10
URL: {job.get("application_url") or job["url"]}
Title: {job["title"]}
Company: {job.get("site", "Unknown")}
Fit Score: {job.get("fit_score", "N/A")}/10

== FILES ==
Resume PDF (upload this): {pdf_path}
Expand Down Expand Up @@ -562,13 +541,13 @@ def build_prompt(job: dict, tailored_resume: str,
2. browser_snapshot to read the page. Then run CAPTCHA DETECT (see CAPTCHA section). If a CAPTCHA is found, solve it before continuing.
3. LOCATION CHECK. Read the page for location info. If not eligible, output RESULT and stop.
4. Find and click the Apply button. If email-only (page says "email resume to X"):
- send_email with subject "Application for {job['title']} -- {display_name}", body = 2-3 sentence pitch + contact info, attach resume PDF: ["{pdf_path}"]
- send_email with subject "Application for {job["title"]} -- {display_name}", body = 2-3 sentence pitch + contact info, attach resume PDF: ["{pdf_path}"]
- Output RESULT:APPLIED. Done.
After clicking Apply: browser_snapshot. Run CAPTCHA DETECT -- many sites trigger CAPTCHAs right after the Apply click. If found, solve before continuing.
5. Login wall?
5a. FIRST: check the URL. If you landed on {', '.join(blocked_sso)}, or any SSO/OAuth page -> STOP. Output RESULT:FAILED:sso_required. Do NOT try to sign in to Google/Microsoft/SSO.
5a. FIRST: check the URL. If you landed on {", ".join(blocked_sso)}, or any SSO/OAuth page -> STOP. Output RESULT:FAILED:sso_required. Do NOT try to sign in to Google/Microsoft/SSO.
5b. Check for popups. Run browser_tabs action "list". If a new tab/window appeared (login popup), switch to it with browser_tabs action "select". Check the URL there too -- if it's SSO -> RESULT:FAILED:sso_required.
5c. Regular login form (employer's own site)? Try sign in: {personal['email']} / {personal.get('password', '')}
5c. Regular login form (employer's own site)? Try sign in: {personal["email"]}. For password, run: echo $APPLYPILOT_SITE_PASSWORD
5d. After clicking Login/Sign-in: run CAPTCHA DETECT. Login pages frequently have invisible CAPTCHAs that silently block form submissions. If found, solve it then retry login.
5e. Sign in failed? Try sign up with same email and password.
5f. Need email verification? Use search_emails + read_email to get the code.
Expand Down Expand Up @@ -608,7 +587,7 @@ def build_prompt(job: dict, tailored_resume: str,
- Dropdown won't fill? browser_click to open it, then browser_click the option.
- Checkbox won't check via fill_form? Use browser_click on it instead. Snapshot to verify.
- Phone field with country prefix: just type digits {phone_digits}
- Date fields: {datetime.now().strftime('%m/%d/%Y')}
- Date fields: {datetime.now().strftime("%m/%d/%Y")}
- Validation errors after submit? Take BOTH snapshot AND screenshot. Snapshot shows text errors, screenshot shows red-highlighted fields. Fix all, retry.
- Honeypot fields (hidden, "leave blank"): skip them.
- Format-sensitive fields: read the placeholder text, match it exactly.
Expand Down
86 changes: 49 additions & 37 deletions src/applypilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
# Helpers
# ---------------------------------------------------------------------------


def _bootstrap() -> None:
"""Common setup: load env, create dirs, init DB."""
from applypilot.config import load_env, ensure_dirs
Expand All @@ -53,10 +54,13 @@ def _version_callback(value: bool) -> None:
# Commands
# ---------------------------------------------------------------------------


@app.callback()
def main(
version: bool = typer.Option(
False, "--version", "-V",
False,
"--version",
"-V",
help="Show version and exit.",
callback=_version_callback,
is_eager=True,
Expand All @@ -77,11 +81,7 @@ def init() -> None:
def run(
stages: Optional[list[str]] = typer.Argument(
None,
help=(
"Pipeline stages to run. "
f"Valid: {', '.join(VALID_STAGES)}, all. "
"Defaults to 'all' if omitted."
),
help=(f"Pipeline stages to run. Valid: {', '.join(VALID_STAGES)}, all. Defaults to 'all' if omitted."),
),
min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for tailor/cover stages."),
workers: int = typer.Option(1, "--workers", "-w", help="Parallel threads for discovery/enrichment stages."),
Expand All @@ -108,25 +108,20 @@ def run(
# Validate stage names
for s in stage_list:
if s != "all" and s not in VALID_STAGES:
console.print(
f"[red]Unknown stage:[/red] '{s}'. "
f"Valid stages: {', '.join(VALID_STAGES)}, all"
)
console.print(f"[red]Unknown stage:[/red] '{s}'. Valid stages: {', '.join(VALID_STAGES)}, all")
raise typer.Exit(code=1)

# Gate AI stages behind Tier 2
llm_stages = {"score", "tailor", "cover"}
if any(s in stage_list for s in llm_stages) or "all" in stage_list:
from applypilot.config import check_tier

check_tier(2, "AI scoring/tailoring")

# Validate the --validation flag value
valid_modes = ("strict", "normal", "lenient")
if validation not in valid_modes:
console.print(
f"[red]Invalid --validation value:[/red] '{validation}'. "
f"Choose from: {', '.join(valid_modes)}"
)
console.print(f"[red]Invalid --validation value:[/red] '{validation}'. Choose from: {', '.join(valid_modes)}")
raise typer.Exit(code=1)

result = run_pipeline(
Expand Down Expand Up @@ -154,7 +149,9 @@ def apply(
url: Optional[str] = typer.Option(None, "--url", help="Apply to a specific job URL."),
gen: bool = typer.Option(False, "--gen", help="Generate prompt file for manual debugging instead of running."),
mark_applied: Optional[str] = typer.Option(None, "--mark-applied", help="Manually mark a job URL as applied."),
mark_failed: Optional[str] = typer.Option(None, "--mark-failed", help="Manually mark a job URL as failed (provide URL)."),
mark_failed: Optional[str] = typer.Option(
None, "--mark-failed", help="Manually mark a job URL as failed (provide URL)."
),
fail_reason: Optional[str] = typer.Option(None, "--fail-reason", help="Reason for --mark-failed."),
reset_failed: bool = typer.Option(False, "--reset-failed", help="Reset all failed jobs for retry."),
) -> None:
Expand All @@ -168,33 +165,37 @@ def apply(

if mark_applied:
from applypilot.apply.launcher import mark_job

mark_job(mark_applied, "applied")
console.print(f"[green]Marked as applied:[/green] {mark_applied}")
return

if mark_failed:
from applypilot.apply.launcher import mark_job

mark_job(mark_failed, "failed", reason=fail_reason)
console.print(f"[yellow]Marked as failed:[/yellow] {mark_failed} ({fail_reason or 'manual'})")
return

if reset_failed:
from applypilot.apply.launcher import reset_failed as do_reset

count = do_reset()
console.print(f"[green]Reset {count} failed job(s) for retry.[/green]")
return

# --- Full apply mode ---

# Check 1: Tier 3 required (Claude Code CLI + Chrome)
console.print(
"[yellow]Security: Auto-apply runs with --permission-mode bypassPermissions. "
"Review generated prompts before use.[/yellow]"
)

check_tier(3, "auto-apply")

# Check 2: Profile exists
if not _profile_path.exists():
console.print(
"[red]Profile not found.[/red]\n"
"Run [bold]applypilot init[/bold] to create your profile first."
)
console.print("[red]Profile not found.[/red]\nRun [bold]applypilot init[/bold] to create your profile first.")
raise typer.Exit(code=1)

# Check 3: Tailored resumes exist (skip for --gen with --url)
Expand All @@ -212,6 +213,7 @@ def apply(

if gen:
from applypilot.apply.launcher import gen_prompt, BASE_CDP_PORT

target = url or ""
if not target:
console.print("[red]--gen requires --url to specify which job.[/red]")
Expand All @@ -224,9 +226,7 @@ def apply(
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}"
f" claude --model {model} -p --mcp-config {mcp_path} --permission-mode bypassPermissions < {prompt_file}"
)
return

Expand Down Expand Up @@ -337,8 +337,13 @@ 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,
SEARCH_CONFIG_PATH,
ENV_PATH,
get_chrome_path,
)

load_env()
Expand Down Expand Up @@ -373,13 +378,20 @@ def doctor() -> None:
# jobspy (discovery dep installed separately)
try:
import jobspy # noqa: F401

results.append(("python-jobspy", ok_mark, "Job board scraping available"))
except ImportError:
results.append(("python-jobspy", warn_mark,
"pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex"))
results.append(
(
"python-jobspy",
warn_mark,
"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"))
Expand All @@ -392,41 +404,40 @@ def doctor() -> None:
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')"))
results.append(("LLM API key", fail_mark, "Set GEMINI_API_KEY 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))
else:
results.append(("Claude Code CLI", fail_mark,
"Install from https://claude.ai/code (needed for auto-apply)"))
results.append(("Claude Code CLI", fail_mark, "Install from https://claude.ai/code (needed for auto-apply)"))

# Chrome
try:
chrome_path = get_chrome_path()
results.append(("Chrome/Chromium", ok_mark, chrome_path))
except FileNotFoundError:
results.append(("Chrome/Chromium", fail_mark,
"Install Chrome or set CHROME_PATH env var (needed for auto-apply)"))
results.append(
("Chrome/Chromium", fail_mark, "Install Chrome or set CHROME_PATH env var (needed for auto-apply)")
)

# Node.js / npx (for Playwright MCP)
npx_bin = shutil.which("npx")
if npx_bin:
results.append(("Node.js (npx)", ok_mark, npx_bin))
else:
results.append(("Node.js (npx)", fail_mark,
"Install Node.js 18+ from nodejs.org (needed for auto-apply)"))
results.append(("Node.js (npx)", fail_mark, "Install Node.js 18+ from nodejs.org (needed for auto-apply)"))

# CapSolver (optional)
capsolver = os.environ.get("CAPSOLVER_API_KEY")
if capsolver:
results.append(("CapSolver API key", ok_mark, "CAPTCHA solving enabled"))
else:
results.append(("CapSolver API key", "[dim]optional[/dim]",
"Set CAPSOLVER_API_KEY in .env for CAPTCHA solving"))
results.append(
("CapSolver API key", "[dim]optional[/dim]", "Set CAPSOLVER_API_KEY in .env for CAPTCHA solving")
)

# --- Render results ---
console.print()
Expand All @@ -441,6 +452,7 @@ def doctor() -> None:

# Tier summary
from applypilot.config import get_tier, TIER_LABELS

tier = get_tier()
console.print(f"[bold]Current tier: Tier {tier} — {TIER_LABELS[tier]}[/bold]")

Expand Down
Loading