5.6 KiB
name, description, applyTo
| name | description | applyTo |
|---|---|---|
| CLI rules | Rules for the typer-based finn-eiendom CLI | 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. One typer.Typer app:
# 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:
[project.scripts]
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
finn-eiendom = "finn_eiendom.cli:app"
Plus finn_eiendom/__main__.py:
from .cli import app
if __name__ == "__main__":
app()
So python -m finn_eiendom ... works without installation.
Command body shape
@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, onetyper.echo(formatting.render_<thing>(result, format)). - If the body has more than ~20 lines, the logic belongs in
service.py. - No
print()— usetyper.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 intojq.--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
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:
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:
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.