From c10969873bce3742ed7ed04aa386c3d8cd529e63 Mon Sep 17 00:00:00 2001 From: Parideboy Date: Tue, 23 Jun 2026 02:05:38 +0200 Subject: [PATCH] feat(cli): add `headroom dashboard` and surface the dashboard URL (#1277) (#1292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The savings dashboard is served at `GET /dashboard` (`headroom/proxy/server.py`) but was effectively undiscoverable: there was no `headroom dashboard` command, the `wrap` startup banner only printed `Proxy ready on http://127.0.0.1:PORT` (never the dashboard URL), and the docs buried it — so users on current releases didn't know it existed (#1277). This makes it discoverable from the CLI, the wrap banner, and the docs. Closes #1277 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `headroom/cli/proxy.py`: new `headroom dashboard` command — prints `http://127.0.0.1:/dashboard` and opens it in a browser (stdlib `webbrowser`); `--no-open` just prints, `--port`/`HEADROOM_PORT` honored. Headless failures are swallowed (URL already printed). - `headroom/cli/wrap.py`: print the dashboard URL alongside "Proxy ready" so every `wrap` surfaces it. - `docs/content/docs/installation.mdx` + `README.md`: document `headroom dashboard`. - `docs/content/docs/mcp.mdx`: document the Codex MCP `command: "headroom"` PATH pitfall (#768) — a project-venv (`uv add`) install isn't on the host's PATH; install globally with `uv tool install` / pipx, or use an absolute path. - `tests/test_cli_dashboard.py`: new tests. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_cli_dashboard.py -q 3 passed $ python -m ruff check headroom/cli/proxy.py headroom/cli/wrap.py tests/test_cli_dashboard.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, branch fix/1277-dashboard-discoverability off headroomlabs-ai/main - Exact command / steps: built the CLI and invoked the new command via the real entry-point import (`from headroom.cli.main import main; main(['dashboard','--no-open','--port','8787'], standalone_mode=False)`) and checked it is registered (`'dashboard' in main.commands`). - Observed result: prints ` Dashboard: http://127.0.0.1:8787/dashboard`, `'dashboard' in main.commands` → `True`, exit 0. The three new tests pass (prints URL + no browser on `--no-open`; opens the URL by default; a raising `webbrowser.open` does not crash the command). - Not tested: did not load the rendered `/dashboard` HTML against a live proxy in CI — the change only adds a launcher/printer for the existing route; the route itself is unchanged. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 --- README.md | 1 + docs/content/docs/installation.mdx | 12 ++++++++ docs/content/docs/mcp.mdx | 15 ++++++++++ headroom/cli/proxy.py | 26 +++++++++++++++++ headroom/cli/wrap.py | 1 + tests/test_cli_dashboard.py | 46 ++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+) create mode 100644 tests/test_cli_dashboard.py diff --git a/README.md b/README.md index 34e07ae60..2edd33b9a 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ headroom proxy --port 8787 # drop-in proxy, zero code changes # 3 — See the savings headroom perf +headroom dashboard # live savings dashboard (proxy must be running) ``` Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**. diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 4b500f514..162cc2a91 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -271,6 +271,18 @@ Some tests require Rust tooling. The recommended way to install rust is using `rustup`. You can find the official installation instructions [here](https://rust-lang.org/tools/install/). +## Dashboard + +Headroom serves a live savings dashboard while the proxy is running. Open it with: + +```bash +headroom dashboard # opens http://localhost:8787/dashboard in your browser +headroom dashboard --no-open # just print the URL +``` + +Or browse to `http://localhost:8787/dashboard` directly (use `--port` / `HEADROOM_PORT` if you +run the proxy on a different port). + ## Next steps diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index 9b8fd2eca..82bc5e597 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -154,6 +154,21 @@ For multiple proxy instances, register one stdio MCP server per proxy URL: Do not assume that a running proxy exposes an HTTP MCP endpoint at `/mcp`. If `http://127.0.0.1:/mcp` returns `404`, use the stdio configuration above. +### `command: "headroom"` fails to start + +The configurations above use `"command": "headroom"`, which only works if the `headroom` +executable is on the PATH your MCP host (Codex, etc.) sees at startup. If you installed Headroom +into a project virtualenv — for example with `uv add headroom-ai` — the CLI lives only inside +that venv, and the host fails at launch with: + +```text +MCP client for `headroom` failed to start: MCP startup failed: No such file or directory (os error 2) +``` + +Install Headroom so it's globally on PATH — `uv tool install "headroom-ai[mcp]"` (or +`pipx install "headroom-ai[mcp]"`) — or replace `"headroom"` with the absolute path to the binary +(`command -v headroom`, or `where headroom` on Windows). + ## Cross-tool compatibility | Tool | MCP Support | Setup | diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 2d9a9bf0c..5f7fa0f63 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -90,6 +90,32 @@ def _selected_context_tool() -> str: return raw +@main.command() +@click.option( + "--port", + "-p", + default=8787, + type=int, + envvar="HEADROOM_PORT", + help="Proxy port (default: 8787, env: HEADROOM_PORT)", +) +@click.option("--no-open", is_flag=True, help="Print the URL instead of opening a browser") +def dashboard(port: int, no_open: bool) -> None: + """Open the Headroom savings dashboard in your browser. + + Requires a running proxy (start one with `headroom proxy` or `headroom wrap ...`). + """ + import webbrowser + + url = f"http://127.0.0.1:{port}/dashboard" + click.echo(f" Dashboard: {url}") + if not no_open: + try: + webbrowser.open(url) + except Exception: # noqa: BLE001 — headless/no browser: URL already printed + pass + + @main.command() @click.option( "--host", diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 86756a592..35cdc0478 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -2506,6 +2506,7 @@ def _ensure_proxy( ), ) click.echo(f" Proxy ready on http://127.0.0.1:{port}") + click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard") return proc except RuntimeError as e: click.echo(f" Error: {e}") diff --git a/tests/test_cli_dashboard.py b/tests/test_cli_dashboard.py new file mode 100644 index 000000000..0bf421e97 --- /dev/null +++ b/tests/test_cli_dashboard.py @@ -0,0 +1,46 @@ +"""Tests for the `headroom dashboard` command (#1277).""" + +from __future__ import annotations + +import webbrowser + +from click.testing import CliRunner + +from headroom.cli.main import main + + +def test_dashboard_no_open_prints_url(monkeypatch): + """--no-open prints the dashboard URL and never opens a browser.""" + opened: list[str] = [] + monkeypatch.setattr(webbrowser, "open", lambda u, *a, **k: opened.append(u) or True) + + result = CliRunner().invoke(main, ["dashboard", "--no-open", "--port", "9999"]) + + assert result.exit_code == 0, result.output + assert "http://127.0.0.1:9999/dashboard" in result.output + assert opened == [] # browser must not be launched + + +def test_dashboard_opens_browser_by_default(monkeypatch): + """Without --no-open the command opens the URL in a browser.""" + opened: list[str] = [] + monkeypatch.setattr(webbrowser, "open", lambda u, *a, **k: opened.append(u) or True) + + result = CliRunner().invoke(main, ["dashboard", "--port", "1234"]) + + assert result.exit_code == 0, result.output + assert opened == ["http://127.0.0.1:1234/dashboard"] + + +def test_dashboard_browser_failure_is_swallowed(monkeypatch): + """A headless box where webbrowser.open raises must not crash the command.""" + + def _boom(*_a, **_k): + raise RuntimeError("no display") + + monkeypatch.setattr(webbrowser, "open", _boom) + + result = CliRunner().invoke(main, ["dashboard"]) + + assert result.exit_code == 0, result.output + assert "/dashboard" in result.output