From a554a169d8cfd4ac73eba58dc672eaa4cdfae61f Mon Sep 17 00:00:00 2001 From: tejjgv Date: Sat, 5 Sep 2026 23:14:24 +0530 Subject: [PATCH] first commit --- Apply-agent | 1 + src/applypilot/apply/launcher.py | 161 ++++++++++++++++++++++------- src/applypilot/apply/prompt.py | 34 +++++-- src/applypilot/cli.py | 169 +++++++++++++++++++++++-------- src/applypilot/config.py | 42 +++++++- src/applypilot/database.py | 4 +- src/applypilot/wizard/init.py | 41 ++++++-- 7 files changed, 353 insertions(+), 99 deletions(-) create mode 160000 Apply-agent diff --git a/Apply-agent b/Apply-agent new file mode 160000 index 000000000..4a8d521f6 --- /dev/null +++ b/Apply-agent @@ -0,0 +1 @@ +Subproject commit 4a8d521f67f5139811c0a910ef37410f8e6d836a diff --git a/src/applypilot/apply/launcher.py b/src/applypilot/apply/launcher.py index 341a11a36..2101af348 100644 --- a/src/applypilot/apply/launcher.py +++ b/src/applypilot/apply/launcher.py @@ -110,10 +110,30 @@ def acquire_job(target_url: str | None = None, min_score: int = 7, fit_score, location, full_description, cover_letter_path FROM jobs WHERE (url = ? OR application_url = ? OR application_url LIKE ? OR url LIKE ?) - AND tailored_resume_path IS NOT NULL - AND apply_status != 'in_progress' + AND (apply_status IS NULL OR apply_status != 'in_progress') LIMIT 1 """, (target_url, target_url, like, like)).fetchone() + + if not row: + from urllib.parse import urlparse + parsed = urlparse(target_url) + site = parsed.netloc.replace("www.", "") or "Custom" + path_parts = [p for p in parsed.path.split('/') if p] + title = path_parts[-1].replace('-', ' ').replace('_', ' ').title() if path_parts else "Job Application" + now = datetime.now(timezone.utc).isoformat() + + conn.execute(""" + INSERT OR IGNORE INTO jobs (url, application_url, title, site, fit_score, strategy, discovered_at) + VALUES (?, ?, ?, ?, 10, 'manual_url', ?) + """, (target_url, target_url, title, site, now)) + conn.commit() + + row = conn.execute(""" + SELECT url, title, site, application_url, tailored_resume_path, + fit_score, location, full_description, cover_letter_path + FROM jobs + WHERE url = ? + """, (target_url,)).fetchone() else: blocked_sites, blocked_patterns = _load_blocked() # Build parameterized filters to avoid SQL injection @@ -131,13 +151,12 @@ def acquire_job(target_url: str | None = None, min_score: int = 7, SELECT url, title, site, application_url, tailored_resume_path, fit_score, location, full_description, cover_letter_path FROM jobs - WHERE tailored_resume_path IS NOT NULL - AND (apply_status IS NULL OR apply_status = 'failed') + WHERE (apply_status IS NULL OR apply_status = 'failed') AND (apply_attempts IS NULL OR apply_attempts < ?) AND fit_score >= ? {site_clause} {url_clauses} - ORDER BY fit_score DESC, url + ORDER BY (CASE WHEN tailored_resume_path IS NOT NULL THEN 0 ELSE 1 END), fit_score DESC, url LIMIT 1 """, [config.DEFAULTS["max_apply_attempts"]] + params).fetchone() @@ -221,12 +240,14 @@ def gen_prompt(target_url: str, min_score: int = 7, if not job: return None - # Read resume text + # Read resume text (tailored or main master resume fallback) resume_path = job.get("tailored_resume_path") - txt_path = Path(resume_path).with_suffix(".txt") if resume_path else None + txt_path = Path(resume_path).with_suffix(".txt") if resume_path and Path(resume_path).exists() else None resume_text = "" if txt_path and txt_path.exists(): resume_text = txt_path.read_text(encoding="utf-8") + elif config.RESUME_PATH.exists(): + resume_text = config.RESUME_PATH.read_text(encoding="utf-8") prompt = prompt_mod.build_prompt(job=job, tailored_resume=resume_text) @@ -303,12 +324,14 @@ def run_job(job: dict, port: int, worker_id: int = 0, 'applied', 'expired', 'captcha', 'login_issue', 'failed:reason', or 'skipped'. """ - # Read tailored resume text + # Read tailored resume text (or main master resume fallback) resume_path = job.get("tailored_resume_path") - txt_path = Path(resume_path).with_suffix(".txt") if resume_path else None + txt_path = Path(resume_path).with_suffix(".txt") if resume_path and Path(resume_path).exists() else None resume_text = "" if txt_path and txt_path.exists(): resume_text = txt_path.read_text(encoding="utf-8") + elif config.RESUME_PATH.exists(): + resume_text = config.RESUME_PATH.read_text(encoding="utf-8") # Build the prompt agent_prompt = prompt_mod.build_prompt( @@ -317,48 +340,85 @@ def run_job(job: dict, port: int, worker_id: int = 0, dry_run=dry_run, ) - # Write per-worker MCP config + # Detect configured agent CLI + agent_info = config.get_agent_cli_info() + if not agent_info: + raise RuntimeError("No browser agent CLI found. Please install Claude Code CLI or Antigravity CLI (agy).") + + agent_type, agent_exec = agent_info + + # Worker directory + worker_dir = reset_worker_dir(worker_id) + mcp_data = _make_mcp_config(port) + + # Write per-worker MCP configs 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") - - # 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", "-", - ] + mcp_config_path.write_text(json.dumps(mcp_data), encoding="utf-8") + # Also write mcp_config.json into worker_dir for Antigravity (agy) + (worker_dir / "mcp_config.json").write_text(json.dumps(mcp_data), encoding="utf-8") + + # For agy: update the global playwright MCP server to point at the correct CDP port + if agent_type == "antigravity": + try: + subprocess.run( + [ + agent_exec, "mcp", "add", + "playwright", + "npx", "@playwright/mcp@latest", + f"--cdp-endpoint=http://localhost:{port}", + f"--viewport-size={config.DEFAULTS['viewport']}", + ], + check=False, capture_output=True, timeout=10, + ) + except Exception as e: + logger.warning("Could not update agy playwright MCP config: %s", e) + + # Build agent command + if agent_type == "antigravity": + cmd = [ + agent_exec, + "--dangerously-skip-permissions", + "--output-format", "stream-json", + "-p", agent_prompt, + ] + if model and model not in ("sonnet", "haiku"): + cmd.extend(["--model", model]) + else: + cmd = [ + agent_exec, + "--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", "-", + ] env = os.environ.copy() env.pop("CLAUDECODE", None) env.pop("CLAUDE_CODE_ENTRYPOINT", None) - worker_dir = reset_worker_dir(worker_id) - 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") - add_event(f"[W{worker_id}] Starting: {job['title'][:40]} @ {job.get('site', '')}") + add_event(f"[W{worker_id}] Starting [{agent_type}]: {job['title'][:40]} @ {job.get('site', '')}") worker_log = config.LOG_DIR / f"worker-{worker_id}.log" ts_header = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_header = ( f"\n{'=' * 60}\n" - f"[{ts_header}] {job['title']} @ {job.get('site', '')}\n" + f"[{ts_header}] [{agent_type}] {job['title']} @ {job.get('site', '')}\n" f"URL: {job.get('application_url') or job['url']}\n" f"Score: {job.get('fit_score', 'N/A')}/10\n" f"{'=' * 60}\n" @@ -383,7 +443,8 @@ def run_job(job: dict, port: int, worker_id: int = 0, with _claude_lock: _claude_procs[worker_id] = proc - proc.stdin.write(agent_prompt) + if agent_type == "claude": + proc.stdin.write(agent_prompt) proc.stdin.close() text_parts: list[str] = [] @@ -397,6 +458,9 @@ def run_job(job: dict, port: int, worker_id: int = 0, try: msg = json.loads(line) msg_type = msg.get("type") + event = msg.get("event") + + # Claude Code JSON stream format if msg_type == "assistant": for block in msg.get("message", {}).get("content", []): bt = block.get("type") @@ -437,6 +501,27 @@ def run_job(job: dict, port: int, worker_id: int = 0, "turns": msg.get("num_turns", 0), } text_parts.append(msg.get("result", "")) + + # Antigravity (agy) JSON stream format + elif event == "step_update": + su = msg.get("step_update", {}) + text_delta = su.get("text_delta") + if text_delta: + text_parts.append(text_delta) + lf.write(text_delta) + elif event == "result": + res = msg.get("result", {}) + if isinstance(res, dict): + usage = res.get("usage", {}) + stats = { + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "cache_read": usage.get("cache_read_tokens", 0), + "turns": res.get("num_turns", 0), + } + resp_text = res.get("response", "") + text_parts.append(resp_text) + lf.write(resp_text + "\n") except json.JSONDecodeError: text_parts.append(line) lf.write(line + "\n") diff --git a/src/applypilot/apply/prompt.py b/src/applypilot/apply/prompt.py index 37c3790a1..42c0caefb 100644 --- a/src/applypilot/apply/prompt.py +++ b/src/applypilot/apply/prompt.py @@ -440,13 +440,33 @@ def build_prompt(job: dict, tailored_resume: str, personal = profile["personal"] # --- Resolve resume PDF path --- - resume_path = job.get("tailored_resume_path") - if not resume_path: - raise ValueError(f"No tailored resume for job: {job.get('title', 'unknown')}") - - src_pdf = Path(resume_path).with_suffix(".pdf").resolve() - if not src_pdf.exists(): - raise ValueError(f"Resume PDF not found: {src_pdf}") + src_pdf: Path | None = None + tailored_path = job.get("tailored_resume_path") + if tailored_path: + cand = Path(tailored_path).with_suffix(".pdf").resolve() + if cand.exists(): + src_pdf = cand + + if src_pdf is None: + if config.RESUME_PDF_PATH.exists(): + src_pdf = config.RESUME_PDF_PATH.resolve() + elif config.RESUME_PATH.exists(): + cand_pdf = config.RESUME_PATH.with_suffix(".pdf").resolve() + if cand_pdf.exists(): + src_pdf = cand_pdf + else: + try: + from applypilot.scoring.pdf import convert_to_pdf + src_pdf = convert_to_pdf(config.RESUME_PATH, config.RESUME_PDF_PATH).resolve() + except Exception as e: + logger.warning("Could not convert main resume to PDF: %s", e) + + if not src_pdf or not src_pdf.exists(): + raise ValueError(f"No resume (tailored or main master) found for job: {job.get('title', 'unknown')}") + + # Fall back to main master resume text if tailored_resume string is empty + if not tailored_resume and config.RESUME_PATH.exists(): + tailored_resume = config.RESUME_PATH.read_text(encoding="utf-8") # Copy to a clean filename for upload (recruiters see the filename) full_name = personal["full_name"] diff --git a/src/applypilot/cli.py b/src/applypilot/cli.py index 6c8be9128..2d39c4875 100644 --- a/src/applypilot/cli.py +++ b/src/applypilot/cli.py @@ -49,6 +49,40 @@ def _version_callback(value: bool) -> None: raise typer.Exit() +def _ensure_custom_url(url: str) -> str: + """Ensure a user-provided job URL exists in the database, inserting it if missing.""" + from urllib.parse import urlparse + from datetime import datetime, timezone + from applypilot.database import get_connection + + cleaned_url = url.strip() + if not cleaned_url.startswith(("http://", "https://")): + cleaned_url = "https://" + cleaned_url + + conn = get_connection() + like = f"%{cleaned_url.split('?')[0].rstrip('/')}%" + row = conn.execute( + "SELECT url FROM jobs WHERE url = ? OR application_url = ? OR application_url LIKE ? OR url LIKE ?", + (cleaned_url, cleaned_url, like, like) + ).fetchone() + + if not row: + parsed = urlparse(cleaned_url) + site_name = parsed.netloc.replace("www.", "") or "Custom URL" + path_parts = [p for p in parsed.path.split('/') if p] + title = path_parts[-1].replace('-', ' ').replace('_', ' ').title() if path_parts else "Job Application" + now = datetime.now(timezone.utc).isoformat() + + conn.execute(""" + INSERT OR IGNORE INTO jobs (url, application_url, title, site, fit_score, strategy, discovered_at) + VALUES (?, ?, ?, ?, 10, 'manual_url', ?) + """, (cleaned_url, cleaned_url, title, site_name, now)) + conn.commit() + console.print(f"[cyan]Added custom job URL to database:[/cyan] {title} ({site_name})") + + return cleaned_url + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @@ -147,7 +181,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: str = typer.Option("haiku", "--model", "-m", help="Claude/LLM model name."), + agent_cli: Optional[str] = typer.Option(None, "--agent-cli", "-a", help="Browser agent CLI to use ('claude' or 'antigravity'/'agy')."), 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."), @@ -161,7 +196,11 @@ def apply( """Launch auto-apply to submit job applications.""" _bootstrap() - from applypilot.config import check_tier, PROFILE_PATH as _profile_path + import os + if agent_cli: + os.environ["AGENT_CLI"] = agent_cli + + from applypilot.config import check_tier, get_agent_cli_info, PROFILE_PATH as _profile_path from applypilot.database import get_connection # --- Utility modes (no Chrome/Claude needed) --- @@ -186,7 +225,10 @@ def apply( # --- Full apply mode --- - # Check 1: Tier 3 required (Claude Code CLI + Chrome) + if url: + url = _ensure_custom_url(url) + + # Check 1: Tier 3 required (Browser Agent CLI + Chrome) check_tier(3, "auto-apply") # Check 2: Profile exists @@ -197,18 +239,48 @@ def apply( ) raise typer.Exit(code=1) - # Check 3: Tailored resumes exist (skip for --gen with --url) + # Check 3: Resume exists (tailored or main master resume) + from applypilot.config import RESUME_PATH as _resume_path, RESUME_PDF_PATH as _resume_pdf_path if not (gen and url): + has_main_resume = _resume_path.exists() or _resume_pdf_path.exists() conn = get_connection() - ready = conn.execute( - "SELECT COUNT(*) FROM jobs WHERE tailored_resume_path IS NOT NULL AND applied_at IS NULL" + ready_tailored = conn.execute( + "SELECT COUNT(*) FROM jobs WHERE tailored_resume_path IS NOT NULL AND applied_at IS NULL AND (apply_status IS NULL OR apply_status = 'failed')" ).fetchone()[0] - if ready == 0: + + if ready_tailored == 0: + if not has_main_resume: + console.print( + "[red]No tailored resumes ready and no master resume found.[/red]\n" + "Run [bold]applypilot init[/bold] to set up your master resume, or run [bold]applypilot run score tailor[/bold] first." + ) + raise typer.Exit(code=1) + + # Check if there are any jobs to apply to using the main resume + if url: + like = f"%{url.split('?')[0].rstrip('/')}%" + target_count = conn.execute( + "SELECT COUNT(*) FROM jobs WHERE (url = ? OR application_url = ? OR application_url LIKE ? OR url LIKE ?) AND (apply_status IS NULL OR apply_status != 'in_progress')", + (url, url, like, like), + ).fetchone()[0] + if target_count == 0: + console.print(f"[red]Job not found for URL:[/red] {url}") + raise typer.Exit(code=1) + else: + ready_total = conn.execute( + "SELECT COUNT(*) FROM jobs WHERE (apply_status IS NULL OR apply_status = 'failed') AND (apply_attempts IS NULL OR apply_attempts < 5) AND fit_score >= ?", + (min_score,), + ).fetchone()[0] + if ready_total == 0: + console.print( + f"[yellow]No eligible jobs ready to apply (fit_score >= {min_score}).[/yellow]\n" + "Run [bold]applypilot run[/bold] to discover and score jobs first." + ) + raise typer.Exit(code=1) + console.print( - "[red]No tailored resumes ready.[/red]\n" - "Run [bold]applypilot run score tailor[/bold] first to prepare applications." + "[yellow]No tailored resumes ready. Proceeding with main master resume...[/yellow]\n" ) - raise typer.Exit(code=1) if gen: from applypilot.apply.launcher import gen_prompt, BASE_CDP_PORT @@ -223,18 +295,29 @@ def apply( 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}" - ) + agent_info = get_agent_cli_info() + agent_name = agent_info[0] if agent_info else "claude" + if agent_name == "antigravity": + console.print( + f" agy --dangerously-skip-permissions --output-format stream-json -p \"$(cat {prompt_file})\"" + ) + else: + console.print( + f" claude --model {model} -p " + f"--mcp-config {mcp_path} " + f"--permission-mode bypassPermissions < {prompt_file}" + ) return from applypilot.apply.launcher import main as apply_main effective_limit = limit if limit is not None else (0 if continuous else 1) + agent_info = get_agent_cli_info() + agent_desc = f"{agent_info[0]} ({agent_info[1]})" if agent_info else "unknown" + console.print("\n[bold blue]Launching Auto-Apply[/bold blue]") + console.print(f" Agent CLI: {agent_desc}") console.print(f" Limit: {'unlimited' if continuous else effective_limit}") console.print(f" Workers: {workers}") console.print(f" Model: {model}") @@ -334,52 +417,49 @@ def dashboard() -> None: @app.command() 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, - ) + """Check required dependencies and environment setup.""" + _bootstrap() - load_env() + import os, shutil + from applypilot.config import get_chrome_path - ok_mark = "[green]OK[/green]" - fail_mark = "[red]MISSING[/red]" - warn_mark = "[yellow]WARN[/yellow]" + ok_mark = "[bold green]OK[/bold green]" + fail_mark = "[bold red]MISSING[/bold red]" + warn_mark = "[bold yellow]WARN[/bold yellow]" - results: list[tuple[str, str, str]] = [] # (check, status, note) + results: list[tuple[str, str, str]] = [] # --- Tier 1 checks --- - # Profile + # profile.json + from applypilot.config import PROFILE_PATH, SEARCH_CONFIG_PATH, RESUME_PATH, RESUME_PDF_PATH if PROFILE_PATH.exists(): results.append(("profile.json", ok_mark, str(PROFILE_PATH))) else: - results.append(("profile.json", fail_mark, "Run 'applypilot init' to create")) + results.append(("profile.json", fail_mark, "Run 'applypilot init' to create profile")) - # Resume + # resume if RESUME_PATH.exists(): results.append(("resume.txt", ok_mark, str(RESUME_PATH))) 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")) + results.append(("resume.txt", fail_mark, "Add resume.txt to ~/.applypilot/")) - # Search config + # searches.yaml if SEARCH_CONFIG_PATH.exists(): results.append(("searches.yaml", ok_mark, str(SEARCH_CONFIG_PATH))) else: - results.append(("searches.yaml", warn_mark, "Will use example config — run 'applypilot init'")) + results.append(("searches.yaml", warn_mark, "Using package default searches")) - # jobspy (discovery dep installed separately) + # python-jobspy try: import jobspy # noqa: F401 - results.append(("python-jobspy", ok_mark, "Job board scraping available")) + results.append(("python-jobspy", ok_mark, "Job scraper library installed")) except ImportError: 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")) @@ -396,13 +476,22 @@ def doctor() -> None: "Set GEMINI_API_KEY in ~/.applypilot/.env (run 'applypilot init')")) # --- Tier 3 checks --- - # Claude Code CLI + # Browser Agent CLI (Claude Code or Antigravity) claude_bin = shutil.which("claude") + agy_bin = shutil.which("agy") or shutil.which("antigravity") + 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", "[dim]optional[/dim]", "Install from https://claude.ai/code")) + + if agy_bin: + results.append(("Antigravity CLI", ok_mark, agy_bin)) + else: + results.append(("Antigravity CLI", "[dim]optional[/dim]", "Install agy CLI")) + + if not claude_bin and not agy_bin: + results.append(("Agent CLI", fail_mark, "Install Claude Code CLI (https://claude.ai/code) or Antigravity CLI (agy)")) # Chrome try: @@ -446,9 +535,9 @@ def doctor() -> None: 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 3 unlocks: auto-apply (needs Claude Code CLI or Antigravity CLI + 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 Antigravity CLI + Chrome + Node.js)[/dim]") console.print() diff --git a/src/applypilot/config.py b/src/applypilot/config.py index 8c3978073..5a640e5ac 100644 --- a/src/applypilot/config.py +++ b/src/applypilot/config.py @@ -197,12 +197,43 @@ def load_env(): } +def get_agent_cli_info() -> tuple[str, str] | None: + """Detect available browser agent CLI (Claude Code CLI or Antigravity CLI). + + Returns: + Tuple of (agent_type, executable_path), e.g. ("claude", "C:\\...\\claude.exe") + or ("antigravity", "C:\\...\\agy.exe"), or None if neither is found. + """ + load_env() + override = os.environ.get("AGENT_CLI") or os.environ.get("APPLYPILOT_AGENT_CLI") + if override: + ov = override.strip().lower() + if ov in ("claude", "claude-code"): + bin_path = shutil.which("claude") + if bin_path: + return ("claude", bin_path) + elif ov in ("antigravity", "agy"): + bin_path = shutil.which("agy") or shutil.which("antigravity") + if bin_path: + return ("antigravity", bin_path) + + claude_bin = shutil.which("claude") + agy_bin = shutil.which("agy") or shutil.which("antigravity") + + if claude_bin: + return ("claude", claude_bin) + if agy_bin: + return ("antigravity", agy_bin) + + return None + + 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 3 (Full Auto-Apply): + Agent CLI (Claude Code or Antigravity) + Chrome """ load_env() @@ -210,14 +241,14 @@ def get_tier() -> int: if not has_llm: return 1 - has_claude = shutil.which("claude") is not None + has_agent = get_agent_cli_info() is not None try: get_chrome_path() has_chrome = True except FileNotFoundError: has_chrome = False - if has_claude and has_chrome: + if has_agent and has_chrome: return 3 return 2 @@ -241,8 +272,8 @@ def check_tier(required: int, feature: str) -> None: 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 >= 3: - if not shutil.which("claude"): - missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]") + if get_agent_cli_info() is None: + missing.append("Browser Agent CLI — install Claude Code CLI (https://claude.ai/code) or Antigravity CLI (agy)") try: get_chrome_path() except FileNotFoundError: @@ -258,3 +289,4 @@ def check_tier(required: int, feature: str) -> None: _console.print(f" - {m}") _console.print() raise SystemExit(1) + diff --git a/src/applypilot/database.py b/src/applypilot/database.py index a1779c02a..b4857009a 100644 --- a/src/applypilot/database.py +++ b/src/applypilot/database.py @@ -318,9 +318,9 @@ def get_stats(conn: sqlite3.Connection | None = None) -> dict: stats["ready_to_apply"] = conn.execute( "SELECT COUNT(*) FROM jobs " - "WHERE tailored_resume_path IS NOT NULL " + "WHERE (tailored_resume_path IS NOT NULL OR fit_score >= 7) " "AND applied_at IS NULL " - "AND application_url IS NOT NULL" + "AND (application_url IS NOT NULL OR url IS NOT NULL)" ).fetchone()[0] return stats diff --git a/src/applypilot/wizard/init.py b/src/applypilot/wizard/init.py index 0f893c3ab..ba6a70376 100644 --- a/src/applypilot/wizard/init.py +++ b/src/applypilot/wizard/init.py @@ -280,27 +280,53 @@ def _setup_ai_features() -> None: # --------------------------------------------------------------------------- def _setup_auto_apply() -> None: - """Configure autonomous job application (requires Claude Code CLI).""" + """Configure autonomous job application (requires Claude Code CLI or Antigravity 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 Antigravity 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 - if shutil.which("claude"): + # Check for Agent CLIs + claude_bin = shutil.which("claude") + agy_bin = shutil.which("agy") or shutil.which("antigravity") + + chosen_agent = None + + if claude_bin and agy_bin: + console.print("[green]Both Claude Code CLI and Antigravity CLI detected.[/green]") + choice = Prompt.ask( + "Which browser agent CLI would you like to use?", + choices=["antigravity", "claude"], + default="antigravity", + ) + chosen_agent = choice + elif agy_bin: + console.print("[green]Antigravity CLI (agy) detected.[/green]") + chosen_agent = "antigravity" + elif claude_bin: console.print("[green]Claude Code CLI detected.[/green]") + chosen_agent = "claude" 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." + "[yellow]No browser agent CLI found on PATH.[/yellow]\n" + "Install Claude Code CLI from: [bold]https://claude.ai/code[/bold] or Antigravity CLI (agy).\n" + "Auto-apply won't work until an agent CLI is installed." ) + if chosen_agent: + # Save to .env + env_content = "" + if ENV_PATH.exists(): + env_content = ENV_PATH.read_text(encoding="utf-8") + if "AGENT_CLI" not in env_content: + env_content = env_content.rstrip() + f"\nAGENT_CLI={chosen_agent}\n" + ENV_PATH.write_text(env_content, encoding="utf-8") + # Optional: CapSolver for CAPTCHAs console.print("\n[dim]Some job sites use CAPTCHAs. CapSolver can handle them automatically.[/dim]") if Confirm.ask("Configure CapSolver API key? (optional)", default=False): @@ -320,6 +346,7 @@ def _setup_auto_apply() -> None: console.print("[dim]Skipped. Add CAPSOLVER_API_KEY to .env later if needed.[/dim]") + # --------------------------------------------------------------------------- # Main entry # ---------------------------------------------------------------------------