diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b45478a42..f0ba1dd7e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Currency: Korean won (KRW) in the preferred-currency picker, with correct zero-decimal formatting (#2669, refs #2449). Thanks @kes02!
- Dashboard v1 snapshot: Claude rows now include all claude-swap accounts (windows, pace, active flag, redacted identity) when the integration is enabled.
+- CLI: add a built-in auto-refreshing web dashboard at `/` to `codexbar serve`.
- CLI: expose Codex cost-history completeness in JSON and add an experimental provider-native-only scan mode (#2520). Thanks @NickGuAI!
- Usage & Spend: add an accessible daily, weekly, and cumulative token-activity heatmap backed by the existing persistent cost scan cache (#2548). Thanks @Yuxin-Qiao!
- Settings: the sidebar is now resizable — drag the divider between 200–380pt; the width persists across launches.
diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift
index 8c653ccaf3..2a32b42ab2 100644
--- a/Sources/CodexBarCLI/CLIHelp.swift
+++ b/Sources/CodexBarCLI/CLIHelp.swift
@@ -196,8 +196,9 @@ extension CodexBarCLI {
Description:
Start a foreground HTTP server that exposes existing CLI JSON payloads and a
- token-gated dashboard snapshot. The server binds to 127.0.0.1 by default;
- `localhost` is normalized to 127.0.0.1.
+ token-gated dashboard snapshot, with a built-in web UI at /. The static web UI
+ is always open; it sends a browser-entered token only when fetching snapshot data.
+ The server binds to 127.0.0.1 by default; `localhost` is normalized to 127.0.0.1.
GET /dashboard/v1/snapshot requires "Authorization: Bearer YOUR_TOKEN" and fails
closed (401) when no token is configured. Set the token with --dashboard-token or,
preferably, the CODEXBAR_DASHBOARD_TOKEN environment variable (argv leaks via ps).
@@ -205,10 +206,11 @@ extension CodexBarCLI {
request. A non-loopback --host therefore requires both a dashboard token and
--allow-plain-http, which records that you accept that trade-off. On a
non-loopback host the token also gates /usage and /cost (account data);
- /health is always open. Use a TLS-terminating reverse proxy for anything
+ / and /health are always open. Use a TLS-terminating reverse proxy for anything
beyond a trusted network segment.
Endpoints:
+ GET / Built-in web dashboard
GET /health
GET /usage
GET /usage?provider=claude
diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift
index d5dde141e9..4047d41861 100644
--- a/Sources/CodexBarCLI/CLIServeCommand.swift
+++ b/Sources/CodexBarCLI/CLIServeCommand.swift
@@ -38,6 +38,7 @@ struct ServeOptions: CommanderParsable {
}
enum CLIServeRoute: Equatable {
+ case webUI
case health
case usage(provider: String?)
case cost(provider: String?)
@@ -59,6 +60,8 @@ enum CLIServeRouter {
let normalizedProvider = provider?.isEmpty == false ? provider : nil
switch path {
+ case "/":
+ return .webUI
case "/health":
return .health
case "/usage":
@@ -109,7 +112,7 @@ struct ServeRuntime {
let dashboardAuth: CLIServeDashboardAuth
/// True for non-loopback binds: every data route (`/usage`, `/cost`,
/// `/dashboard/v1/snapshot`) then requires the bearer token, so account data
- /// is never exposed to the network unauthenticated. `/health` stays open.
+ /// is never exposed to the network unauthenticated. `/` and `/health` stay open.
/// Resolved once at startup from the bind host.
let dataRoutesRequireAuth: Bool
@@ -823,6 +826,8 @@ extension CodexBarCLI {
}
switch route {
+ case .webUI:
+ return CLIServeWebUI.response()
case .health:
return Self.serveHealthResponse(version: runtime.healthVersion)
case let .usage(provider):
diff --git a/Sources/CodexBarCLI/CLIServeWebUI.swift b/Sources/CodexBarCLI/CLIServeWebUI.swift
new file mode 100644
index 0000000000..9069bb4b0d
--- /dev/null
+++ b/Sources/CodexBarCLI/CLIServeWebUI.swift
@@ -0,0 +1,773 @@
+import Foundation
+
+enum CLIServeWebUI {
+ static func response() -> CLILocalHTTPResponse {
+ CLILocalHTTPResponse(
+ status: .ok,
+ body: Data(self.html.utf8),
+ contentType: "text/html; charset=utf-8",
+ extraHeaders: [("Cache-Control", "no-store")])
+ }
+
+ static let html = #"""
+
+
+
+
+
+
+
+ CodexBar Dashboard
+
+
+
+
+
+
+
+
CodexBar
+
+
+
+ Loading…
+ Stale
+
+
+
+
+
+
This server requires a dashboard token
+
Enter the bearer token configured for this CodexBar server.
+
+
+
+
+
Dashboard unavailable
+
+
+
+
+
+
+
+
+
+
+ """#
+}
diff --git a/Tests/CodexBarTests/CLIServeRawHTTPTests.swift b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift
index c10bc96af3..8f221eea4c 100644
--- a/Tests/CodexBarTests/CLIServeRawHTTPTests.swift
+++ b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift
@@ -126,6 +126,33 @@ struct CLIServeRawHTTPTests {
// MARK: - Dashboard snapshot auth (production handler)
+ @Test
+ func `web UI returns HTML with no-store`() async throws {
+ try await Self.withServeRuntime(token: nil, body: { port in
+ let response = try await Self.rawExchange(
+ port: port,
+ request: "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
+
+ #expect(response.statusLine == "HTTP/1.1 200 OK")
+ #expect(response.headerValue("Content-Type") == "text/html; charset=utf-8")
+ #expect(response.headerValue("Cache-Control") == "no-store")
+ #expect(response.body.contains("CodexBar Dashboard"))
+ #expect(response.body.contains("/dashboard/v1/snapshot"))
+ })
+ }
+
+ @Test
+ func `web UI stays open when a dashboard token is configured`() async throws {
+ try await Self.withServeRuntime(token: "secret", bindHost: "0.0.0.0", body: { port in
+ let response = try await Self.rawExchange(
+ port: port,
+ request: "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
+
+ #expect(response.statusLine == "HTTP/1.1 200 OK")
+ #expect(response.headerValue("Content-Type") == "text/html; charset=utf-8")
+ })
+ }
+
@Test
func `snapshot without credentials returns 401 with challenge and no-store`() async throws {
try await Self.withServeRuntime(token: "secret", body: { port in
diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift
index 0635449461..2505d406c4 100644
--- a/Tests/CodexBarTests/CLIServeRouterTests.swift
+++ b/Tests/CodexBarTests/CLIServeRouterTests.swift
@@ -128,7 +128,8 @@ struct CLIServeRouterTests {
}
@Test
- func `routes health usage and cost endpoints`() throws {
+ func `routes web UI health usage cost and dashboard endpoints`() throws {
+ #expect(try CLIServeRouter.route(method: "GET", path: "/", queryItems: [:]) == .webUI)
#expect(try CLIServeRouter.route(method: "GET", path: "/health", queryItems: [:]) == .health)
#expect(try CLIServeRouter.route(method: "GET", path: "/usage", queryItems: [:]) == .usage(provider: nil))
#expect(
@@ -160,6 +161,18 @@ struct CLIServeRouterTests {
}
}
+ @Test
+ func `rejects post to web UI`() {
+ do {
+ _ = try CLIServeRouter.route(method: "POST", path: "/", queryItems: [:])
+ Issue.record("Expected methodNotAllowed")
+ } catch let error as CLIServeRouteError {
+ #expect(error == .methodNotAllowed)
+ } catch {
+ Issue.record("Unexpected error: \(error)")
+ }
+ }
+
@Test
func `rejects unknown paths`() {
do {
diff --git a/docs/cli.md b/docs/cli.md
index 25a5a2b26e..ac3013a906 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -83,18 +83,18 @@ See `docs/configuration.md` for the schema.
- Stdout contains only the snapshot document. Diagnostics and optional `--json-output` logs go to stderr.
- `--pretty` formats the document. `--timeout ` accepts `0...86400`, defaults to `30`, and uses `0` to disable the command deadline.
- Starts no HTTP server and requires no dashboard bearer token. See `docs/dashboard-api.md` for the shared payload contract.
-- `codexbar serve` starts a foreground HTTP server for usage and cost JSON plus a token-gated dashboard snapshot.
+- `codexbar serve` starts a foreground HTTP server for usage and cost JSON, a token-gated dashboard snapshot, and a built-in web UI at `/`.
- `--host ` accepts `localhost` or an IPv4 address and defaults to `127.0.0.1`; `localhost` is normalized to `127.0.0.1`. Binding a non-loopback host requires a dashboard token **and** `--allow-plain-http` (see `docs/dashboard-api.md` for the threat model).
- `--port ` defaults to `8080`.
- `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL.
- `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely.
- `--dashboard-token ` sets the static bearer token for `GET /dashboard/v1/snapshot`. Prefer the `CODEXBAR_DASHBOARD_TOKEN` environment variable (it wins over the flag; a flag value leaks via `ps`). Empty or whitespace-only tokens are startup errors. Without a token the snapshot route fails closed with `401`.
- - On a **non-loopback** host the token gates **all data routes** — `/usage`, `/cost`, and `/dashboard/v1/snapshot` all require `Authorization: Bearer YOUR_TOKEN`, so account data is never exposed to the network unauthenticated. `/health` is always open. On the default loopback bind, `/usage` and `/cost` stay unauthenticated.
+ - On a **non-loopback** host the token gates **all data routes** — `/usage`, `/cost`, and `/dashboard/v1/snapshot` all require `Authorization: Bearer YOUR_TOKEN`, so account data is never exposed to the network unauthenticated. The static web UI at `/` and `/health` are always open. On the default loopback bind, `/usage` and `/cost` stay unauthenticated.
- `--allow-plain-http` is the explicit acknowledgment that the bearer token crosses the network **in cleartext on every request** when serving on a non-loopback host. `serve` refuses to start on a non-loopback host without it.
- Provider config is reloaded for each usage/cost request; cache entries are keyed by the loaded config so provider toggles and source changes do not require restarting `serve`.
- Transient refresh failures fall back to the last good response for up to ten refresh intervals (minimum five minutes) so polling clients do not flicker between data and errors; disabled when `--refresh-interval 0`.
- The default loopback bind rejects non-loopback `Host` headers; a configured non-loopback `--host` additionally accepts its own name. No CORS, TLS, or daemon mode.
- - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`.
+ - Endpoints: `GET /` (web UI), `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`.
- `GET /dashboard/v1/snapshot` requires `Authorization: Bearer YOUR_TOKEN`; responses (and all `401`s) carry `Cache-Control: no-store`. The token is never accepted via query string. See `docs/dashboard-api.md` for the payload contract.
- `GET /health` returns `{"status":"ok"}` plus a `version` field with the running build (e.g. `"0.37.2"`) when resolvable; clients can compare it against `codexbar --version` to detect a `serve` process still running an older binary after an update.
- Codex usage responses include every visible Codex account, matching the menu bar switcher.
diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md
index f58f7e1139..285aa73b29 100644
--- a/docs/dashboard-api.md
+++ b/docs/dashboard-api.md
@@ -26,7 +26,13 @@ Both transports use the same producer and schema-v1 payload. The one-shot comman
The HTTP route is gated by a static bearer token and **fails closed**: without a configured token every request answers `401`. The token is only ever read from the `Authorization` header — a query-string parameter named `token` is never accepted. Every response on the dashboard route — including all `401`s and error responses — carries `Cache-Control: no-store`.
-On the default loopback bind, `/usage` and `/cost` are unchanged and unauthenticated. On a **non-loopback** bind the same token gates **all data routes**: `/usage`, `/cost`, and `/dashboard/v1/snapshot` each require `Authorization: Bearer YOUR_TOKEN`, so account data never leaves the machine unauthenticated. `/health` is always open (it carries only a status and version string, useful for liveness probes).
+On the default loopback bind, `/usage` and `/cost` are unchanged and unauthenticated. On a **non-loopback** bind the same token gates **all data routes**: `/usage`, `/cost`, and `/dashboard/v1/snapshot` each require `Authorization: Bearer YOUR_TOKEN`, so account data never leaves the machine unauthenticated. `/` and `/health` are always open; neither response contains account data.
+
+## Built-in web UI
+
+`GET /` serves a self-contained web dashboard that polls `/dashboard/v1/snapshot`. The static HTML is always unauthenticated, including on non-loopback binds, because it contains no account data. When the snapshot route returns `401`, the page asks for the dashboard token, stores it in the browser under the localStorage key `codexbar.dashboardToken`, and sends it in the `Authorization` header on each snapshot request. The token is never added to the URL.
+
+The UI does not change the transport threat model: `codexbar serve` is plain HTTP. Off-loopback, a token typed into the page transits the network in cleartext like every other request unless a TLS-terminating reverse proxy protects the connection.
## One-shot command semantics
@@ -65,7 +71,7 @@ Transport is **plain HTTP**. There is no TLS in `codexbar serve`, which means:
- The bearer token crosses the network **in cleartext on every request**. Anyone who can observe the path (same Wi-Fi, ARP spoofing, a compromised switch, your ISP on a routed path) can capture the token and replay it until the server restarts with a new one.
- The response bodies — plan labels, usage percentages, email domains, cost figures — cross the network in cleartext too.
-- Because non-loopback binds gate `/usage`, `/cost`, and `/dashboard/v1/snapshot` behind the same token, a passive observer sees your account data but an active client without the token gets `401` on every data route. `/health` is the only unauthenticated route off-loopback.
+- Because non-loopback binds gate `/usage`, `/cost`, and `/dashboard/v1/snapshot` behind the same token, a passive observer sees your account data but an active client without the token gets `401` on every data route. Only the account-free static UI at `/` and `/health` are unauthenticated off-loopback.
Deployments, from safest to least safe: