Files
finn-mcp/.github/instructions/cli.instructions.md
T
2026-05-16 06:54:17 +00:00

158 lines
5.6 KiB
Markdown

---
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.<function>` call, one `typer.echo(formatting.render_<thing>(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 <url> [--max-pages 3] [--detail-limit 20] [--no-details] [--no-eiendom] [--with-similar] [--format ...]
finn-eiendom get-ad <finnkode> [--force-refresh] [--no-eiendom] [--with-similar] [--format ...]
finn-eiendom compare <finnkode...> [--no-eiendom] [--no-comps] [--format ...]
finn-eiendom save-feedback <finnkode> <verdict> [--notes "..."]
finn-eiendom shortlist [--run-id ID] [--limit 10] [--format ...]
finn-eiendom diff <url> [--format ...]
finn-eiendom resolve-unit <finn_url>
finn-eiendom get-unit <unit_code> [--force-refresh]
finn-eiendom enrich-ad <finnkode> [--with-similar]
finn-eiendom build-vector <unit_code>
finn-eiendom decode-vector <unit_vector>
finn-eiendom similar-units <unit_vector> [--status RECENTLY_SOLD|FOR_SALE|CURRENT]
finn-eiendom similar-to-liked <finnkode> [--mode recommendations|comps] [--status ...]
finn-eiendom analyze-against-comps <finnkode>
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.<function>(...))` 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`.