158 lines
5.6 KiB
Markdown
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`. |