"""gimme-job CLI entry point.""" from __future__ import annotations import sys from pathlib import Path from typing import Optional import typer from rich.console import Console from rich.panel import Panel from rich.table import Table app = typer.Typer( name="gimme-job", help="Local job posting aggregator — AI-assisted learning, non-AI runtime.", add_completion=False, ) console = Console() # ── init ────────────────────────────────────────────────────────────────────── @app.command() def init( force: bool = typer.Option(False, "--force", help="Re-initialize even if already set up"), ) -> None: """Initialize project directories, database, and config.""" from gimme_job.db.engine import init_db from gimme_job.utils.paths import ensure_workspace_dirs, project_root, sites_dir console.print(Panel("[bold cyan]gimme-job init[/bold cyan]", expand=False)) # Create workspace directories ensure_workspace_dirs() console.print("[green]✓[/green] Workspace directories created") # Create sites/global.yaml if absent global_yaml = sites_dir() / "global.yaml" if not global_yaml.exists() or force: _write_default_global_yaml(global_yaml) console.print(f"[green]✓[/green] Created {global_yaml}") else: console.print(f"[dim] {global_yaml} already exists[/dim]") # Copy .env.example -> .env if absent env_example = project_root() / ".env.example" env_file = project_root() / ".env" if env_example.exists() and not env_file.exists(): import shutil shutil.copy(env_example, env_file) console.print(f"[green]✓[/green] Copied .env.example → .env (fill in your tokens)") else: console.print("[dim] .env already exists[/dim]") # Initialize SQLite DB init_db() console.print("[green]✓[/green] SQLite database initialized") # Preflight checks _check_playwright() _check_ollama() _check_claude() console.print(Panel("[bold green]Initialization complete.[/bold green]", expand=False)) def _write_default_global_yaml(path: Path) -> None: from gimme_job.utils.json_io import write_yaml data = { "runtime": { "timezone": "America/Phoenix", "headless": True, "profile_name": "JobAgent", "slow_mo_ms": 0, "default_timeout_ms": 15000, "navigation_timeout_ms": 30000, "max_pages_per_site": 3, "min_delay_ms": 1200, "max_delay_ms": 3500, }, "search_defaults": { "keywords": ["orthodontist"], "location": "", "remote": False, "date_mode": "today_or_last_24h", "sort": "relevance", "max_items_per_site": 30, }, "summarization": { "ollama_base_url": "http://127.0.0.1:11434", "model": "qwen3.5:9b", "temperature": 0.1, "max_input_items": 200, }, "notification": { "provider": "kakaotalk", "fallback_markdown": True, }, } write_yaml(path, data) def _check_playwright() -> None: try: from playwright.sync_api import sync_playwright with sync_playwright() as p: _ = p.chromium console.print("[green]✓[/green] Playwright available") except Exception as e: console.print(f"[yellow]![/yellow] Playwright check failed: {e}") console.print(" Run: [bold]uv run playwright install chromium[/bold]") def _check_ollama() -> None: try: import httpx from gimme_job.config import load_global_config cfg = load_global_config() r = httpx.get(f"{cfg.summarization.ollama_base_url}/api/tags", timeout=3.0) if r.status_code == 200: console.print("[green]✓[/green] Ollama reachable") else: console.print(f"[yellow]![/yellow] Ollama returned {r.status_code}") except Exception as e: console.print(f"[yellow]![/yellow] Ollama not reachable: {e}") console.print(" Make sure Ollama is running: [bold]ollama serve[/bold]") def _check_claude() -> None: import subprocess try: result = subprocess.run( ["claude", "--version"], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: console.print(f"[green]✓[/green] Claude Code CLI: {result.stdout.strip()}") else: console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)") except FileNotFoundError: console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)") # ── run ─────────────────────────────────────────────────────────────────────── @app.command() def run( site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"), dry_run: bool = typer.Option(False, "--dry-run", help="Extract but do not save or notify"), skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip KakaoTalk notification"), today_only: bool = typer.Option(False, "--today-only", help="Only process today's new items"), limit_sites: Optional[int] = typer.Option(None, "--limit-sites", help="Max sites to run"), ) -> None: """Collect job postings from all enabled sites.""" from gimme_job.config import load_global_config from gimme_job.db.engine import get_engine, get_session_factory from gimme_job.runtime.orchestrator import RunOrchestrator cfg = load_global_config() engine = get_engine() factory = get_session_factory(engine) orchestrator = RunOrchestrator(global_config=cfg, session_factory=factory) summary = orchestrator.run_all( site_filter=site, dry_run=dry_run, skip_notify=skip_notify, limit_sites=limit_sites, ) # Print summary table table = Table(title="Run Summary", show_header=True) table.add_column("Site", style="cyan") table.add_column("Status") table.add_column("Found", justify="right") table.add_column("New", justify="right") for r in summary.site_results: status_color = { "success": "green", "partial": "yellow", "failed": "red", "repair_needed": "red", }.get(r.status.value, "white") table.add_row( r.site_id, f"[{status_color}]{r.status.value}[/{status_color}]", str(r.items_found), str(r.new_items), ) console.print(table) console.print(f"\nTotal: [bold]{summary.total_found}[/bold] found, [bold green]{summary.total_new}[/bold green] new") if summary.summary_text: console.print("\n[bold]Summary sent.[/bold]") # ── learn ───────────────────────────────────────────────────────────────────── @app.command() def learn( site_id: str = typer.Option(..., "--site-id", help="Site identifier (e.g. linkedin)"), url: str = typer.Option(..., "--url", help="Job search results URL to learn from"), profile_name: str = typer.Option("JobAgent", "--profile-name"), headed: bool = typer.Option(True, "--headed/--headless"), timeout_seconds: int = typer.Option(180, "--timeout-seconds"), ) -> None: """Learn a new job site adapter using Claude Code.""" from gimme_job.config import load_global_config from gimme_job.runtime.claude_cli import ClaudeCodeClient from gimme_job.runtime.learn import LearnMode cfg = load_global_config() client = ClaudeCodeClient() mode = LearnMode(global_config=cfg, claude_client=client) console.print(Panel(f"[bold cyan]Learning site: {site_id}[/bold cyan]", expand=False)) success = mode.learn_site( site_id=site_id, url=url, profile_name=profile_name, headed=headed, timeout=timeout_seconds, ) if success: console.print(f"[green]✓[/green] Successfully learned [bold]{site_id}[/bold]") else: console.print(f"[red]✗[/red] Failed to learn [bold]{site_id}[/bold]") raise typer.Exit(1) # ── repair ──────────────────────────────────────────────────────────────────── @app.command() def repair( site: Optional[str] = typer.Argument(None, help="Site ID to repair"), all_: bool = typer.Option(False, "--all", help="Repair all repair_needed sites"), ) -> None: """Repair a broken site adapter using Claude Code.""" from gimme_job.config import load_global_config, list_all_sites from gimme_job.runtime.claude_cli import ClaudeCodeClient from gimme_job.runtime.repair import RepairMode if not site and not all_: console.print("[red]Provide a site ID or --all[/red]") raise typer.Exit(1) cfg = load_global_config() client = ClaudeCodeClient() mode = RepairMode(global_config=cfg, claude_client=client) sites_to_repair = list_all_sites() if all_ else [site] for s in sites_to_repair: console.print(f"[cyan]Repairing {s}...[/cyan]") success = mode.repair_site(s) if success: console.print(f"[green]✓[/green] {s} repaired") else: console.print(f"[red]✗[/red] {s} repair failed") # ── test ────────────────────────────────────────────────────────────────────── @app.command() def test( site: str = typer.Argument(..., help="Site ID to test"), ) -> None: """Run smoke test for a site adapter.""" import subprocess console.print(Panel(f"[bold cyan]Testing: {site}[/bold cyan]", expand=False)) result = subprocess.run( ["uv", "run", "pytest", f"tests/adapters/test_{site}.py", "-v"], cwd=str(Path(__file__).parent.parent), ) raise typer.Exit(result.returncode) # ── notify ──────────────────────────────────────────────────────────────────── @app.command() def notify( today: bool = typer.Option(True, "--today/--no-today", help="Re-send today's digest"), ) -> None: """Re-send today's job summary via KakaoTalk.""" from datetime import date from gimme_job.config import load_global_config from gimme_job.db.engine import get_engine, get_session_factory from gimme_job.db.repo import JobPostingRepo, SummaryRepo from gimme_job.runtime.notifier import NotificationDispatcher cfg = load_global_config() engine = get_engine() factory = get_session_factory(engine) run_date = date.today() with factory() as session: summary_repo = SummaryRepo() summary = summary_repo.get_latest(session, run_date) if summary: text = summary.content else: # Build summary from today's postings postings = JobPostingRepo().get_today_new(session, run_date) if not postings: console.print("[yellow]No new postings found for today.[/yellow]") raise typer.Exit(0) from gimme_job.runtime.summarizer import OllamaSummarizer summarizer = OllamaSummarizer( base_url=cfg.summarization.ollama_base_url, model=cfg.summarization.model, temperature=cfg.summarization.temperature, ) text = summarizer.summarize(postings) dispatcher = NotificationDispatcher(global_config=cfg, session_factory=factory) ok = dispatcher.send(text, run_date) if ok: console.print("[green]✓[/green] Notification sent") else: console.print("[yellow]![/yellow] Notification failed — saved as markdown fallback") # ── status ──────────────────────────────────────────────────────────────────── @app.command() def status() -> None: """Show health status of all configured sites.""" from gimme_job.config import list_all_sites, load_site_manifest from gimme_job.db.engine import get_engine, get_session_factory from gimme_job.db.repo import SiteConfigRepo, SiteRunRepo engine = get_engine() factory = get_session_factory(engine) table = Table(title="Site Status", show_header=True) table.add_column("Site", style="cyan") table.add_column("Enabled") table.add_column("Repair?") table.add_column("Last Run") table.add_column("Last Status") table.add_column("Failures", justify="right") run_repo = SiteRunRepo() config_repo = SiteConfigRepo() with factory() as session: for site_id in list_all_sites(): try: manifest = load_site_manifest(site_id) except Exception: continue config = session.get(__import__("gimme_job.models.db", fromlist=["SiteConfig"]).SiteConfig, site_id) recent = run_repo.get_recent_runs(session, site_id, limit=1) enabled = "[green]yes[/green]" if manifest.enabled else "[dim]no[/dim]" repair = "[red]YES[/red]" if manifest.repair_needed else "[green]no[/green]" last_run = recent[0].started_at.strftime("%m-%d %H:%M") if recent else "[dim]never[/dim]" last_status = recent[0].status if recent else "[dim]-[/dim]" failures = str(config.consecutive_failures) if config else "0" table.add_row(site_id, enabled, repair, last_run, last_status, failures) console.print(table) if __name__ == "__main__": app()