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

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, 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

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.