--- name: CLI rules description: Rules for the typer-based finn-eiendom CLI applyTo: "finn_eiendom/cli.py,finn_eiendom/__main__.py" --- # CLI rules The CLI is a **thin wrapper** over `service.py`. It is a sibling of `mcp_server.py` — they never call each other and they share the same underlying service functions. Every CLI command maps 1:1 to a service function with the same parameters and defaults. ## Framework Built with [`typer`](https://typer.tiangolo.com/). One `typer.Typer` app: ```python # finn_eiendom/cli.py import asyncio, typer from . import service, formatting app = typer.Typer(no_args_is_help=True, add_completion=False) ``` Entry points in `pyproject.toml`: ```toml [project.scripts] finn-eiendom-mcp = "finn_eiendom.mcp_server:main" finn-eiendom = "finn_eiendom.cli:app" ``` Plus `finn_eiendom/__main__.py`: ```python from .cli import app if __name__ == "__main__": app() ``` So `python -m finn_eiendom ...` works without installation. ## Command body shape ```python @app.command() def analyze_search( url: str, max_pages: int = 3, detail_limit: int = 20, no_details: bool = typer.Option(False, "--no-details"), no_eiendom: bool = typer.Option(False, "--no-eiendom"), with_similar: bool = typer.Option(False, "--with-similar"), format: str = typer.Option("json", "--format"), ) -> None: """Analyze a FINN search URL and return a ranked shortlist.""" result = asyncio.run(service.analyze_search( search_url=url, max_pages=max_pages, detail_limit=detail_limit, include_details=not no_details, include_eiendom_no=not no_eiendom, include_similar_units_for_shortlist=with_similar, )) typer.echo(formatting.render_shortlist(result, format)) ``` Rules: * The command body has at most three sections: option parsing (handled by typer), one `service.` call, one `typer.echo(formatting.render_(result, format))`. * If the body has more than ~20 lines, the logic belongs in `service.py`. * No `print()` — use `typer.echo()` for stdout, `typer.echo(..., err=True)` for stderr. * No business logic, no rendering, no SQLite, no HTTP, no parsing. ## Formats Every command that produces structured output accepts `--format`: * `--format json` (default) — full structured output, pipeable into `jq`. * `--format markdown` — human-readable. * `--format table` — terminal table (only where it makes sense: `analyze-search`, `compare`, `shortlist`, `diff`). All three render paths are produced by `formatting.py`. Never format inline in `cli.py`. Unsupported values raise `ValueError` with a list of supported formats — typer surfaces this as a non-zero exit. ## Commands ```text finn-eiendom analyze-search [--max-pages 3] [--detail-limit 20] [--no-details] [--no-eiendom] [--with-similar] [--format ...] finn-eiendom get-ad [--force-refresh] [--no-eiendom] [--with-similar] [--format ...] finn-eiendom compare [--no-eiendom] [--no-comps] [--format ...] finn-eiendom save-feedback [--notes "..."] finn-eiendom shortlist [--run-id ID] [--limit 10] [--format ...] finn-eiendom diff [--format ...] finn-eiendom resolve-unit finn-eiendom get-unit [--force-refresh] finn-eiendom enrich-ad [--with-similar] finn-eiendom build-vector finn-eiendom decode-vector finn-eiendom similar-units [--status RECENTLY_SOLD|FOR_SALE|CURRENT] finn-eiendom similar-to-liked [--mode recommendations|comps] [--status ...] finn-eiendom analyze-against-comps finn-eiendom cache stats | clear | clear-html | clear-json finn-eiendom serve [--transport stdio|http] [--host 127.0.0.1] [--port 8010] finn-eiendom config show | path finn-eiendom doctor finn-eiendom version ``` Sub-command groups (`cache`, `config`) use `typer.Typer` sub-apps: ```python cache_app = typer.Typer(help="Cache management") app.add_typer(cache_app, name="cache") @cache_app.command("stats") def cache_stats() -> None: typer.echo(formatting.render_cache_stats(service.get_cache_stats(), "json")) ``` ## Async glue Service functions are async; CLI commands are sync. Always use `asyncio.run(service.(...))` at the call boundary. Don't sprinkle `async def` across CLI commands — typer expects sync handlers. ## Exit codes * `0` — success. * `1` — runtime error (raised exception in service). * `2` — usage error (typer's default for bad options). Let exceptions propagate from `service.py` and rely on typer's default handling. Only catch where you want a more specific exit code or message. ## What stays out of cli.py * `import httpx`, `import sqlite3`, `import msgpack` — never. * `from .ad import ...`, `from .search import ...`, `from .eiendom_no import ...`, `from .scoring import ...`, `from .cache import ...`, `from .http import ...` — never. * Inline formatting logic — goes in `formatting.py`. * MCP imports (no `from .mcp_server import ...`). Allowed imports in `cli.py`: ```python import asyncio, json, sys import typer from . import service, formatting, config from .models import FinnAd, EiendomUnit, SimilarUnit # only for type hints ``` `tests/test_architecture.py` enforces this. ## When uncertain about typer Use `context7` instead of guessing: ``` context7:resolve-library-id → "tiangolo/typer" context7:query-docs(id, "Typer sub-apps and option groups") ``` See `docs.instructions.md`.