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